feat: init mainPage portfolio site
Vue 3 模块化作品集主页,浅色极简风格,中英双语懒加载 i18n。 - 模块注册表(import.meta.glob)约定式收集项目模块,新增页签无需改导航/路由 - i18n: 中文同步注入主 chunk,英文动态 import() 拆独立 chunk,仅切换时下载 - 第一个页签 mobile-game: 展示 448/449 移动游戏毕设(概览/截图轮播/版本时间线/团队/排行榜/ML视频) - 排行榜复用 /449/449rest.php,失败优雅降级 - 构建: vite outDir=/root/html,emptyOutDir=false,产物用 mainPage/ 命名空间 - 样式: CSS 变量令牌,无 UI 框架运行时依赖
This commit is contained in:
21
src/modules/index.js
Normal file
21
src/modules/index.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// ★ 模块注册表 -- 扩展点核心
|
||||
// 用 import.meta.glob 约定式收集每个子模块的 index.js,
|
||||
// 自动生成统一的项目列表。新增项目页签 = 新建 modules/<新项目>/ 文件夹,
|
||||
// 无需改动导航/路由代码。
|
||||
//
|
||||
// 每个模块 index.js 默认导出形如:
|
||||
// {
|
||||
// id: 'mobile-game', // 唯一标识,同时用作路由 path
|
||||
// tabKey: 'tabs.mobileGame', // i18n 键,用于页签标题
|
||||
// order: 1, // 页签排序
|
||||
// component: () => import('./MobileGame.vue') // 懒加载组件
|
||||
// }
|
||||
|
||||
const moduleFiles = import.meta.glob('./*/index.js', { eager: true })
|
||||
|
||||
const modules = Object.values(moduleFiles)
|
||||
.map((mod) => mod.default)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99))
|
||||
|
||||
export default modules
|
||||
150
src/modules/mobile-game/MobileGame.vue
Normal file
150
src/modules/mobile-game/MobileGame.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import HeroSection from './components/HeroSection.vue'
|
||||
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
|
||||
import VersionTimeline from './components/VersionTimeline.vue'
|
||||
import TeamCard from './components/TeamCard.vue'
|
||||
import Leaderboard from './components/Leaderboard.vue'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
|
||||
// 站点根绝对路径,用绑定避免被 Vite 当作构建期静态资源解析
|
||||
const videoPoster = '/448/2.jpg'
|
||||
const videoSrc = '/448/ml/449ml.mp4'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-game">
|
||||
<HeroSection />
|
||||
|
||||
<div class="container">
|
||||
<!-- 概览 + 截图轮播 -->
|
||||
<section class="block">
|
||||
<div class="block__grid">
|
||||
<div class="block__text">
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('项目概览', 'Overview') }}</small>
|
||||
{{ L('从角色控制器到陀螺仪滚球', 'From character controller to gyro ball') }}
|
||||
</h2>
|
||||
<p>
|
||||
{{ L(
|
||||
'项目跨越 448 与 449 两门课程。448 用 Unity 开发游戏本体,产出可浏览器试玩的 WebGL 构建与可安装的 Android APK;449 搭建展示网站与基于 PHP+MySQL 的玩家分数排行榜。主要贡献者 Zikai Wang(zikai-ui / main / Roll-a-Ball 系列)与 Hongxiang He(2D DobyAdventure)。',
|
||||
'The project spans courses 448 and 449. 448 built the game with Unity, producing browser-playable WebGL builds and installable Android APKs; 449 built the showcase site and a PHP+MySQL player leaderboard. Main contributors: Zikai Wang (zikai-ui / main / Roll-a-Ball series) and Hongxiang He (2D DobyAdventure).'
|
||||
) }}
|
||||
</p>
|
||||
<p>
|
||||
{{ L(
|
||||
'玩法经过多版本迭代:早期是键盘控制生命/能量的角色控制器,最终版改为手机陀螺仪倾斜操控的 3D 滚球,含雪花砖掉落、黄色砖弹跳等机制。还用 Unity ML-Agents 做了强化学习实验。',
|
||||
'Gameplay iterated across versions: early builds were keyboard-controlled character controllers with health/power stats; the final version became a gyroscope-tilt 3D rolling-ball game with falling snowflake bricks and bouncing yellow bricks. ML-Agents reinforcement-learning experiments were also conducted.'
|
||||
) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="block__media">
|
||||
<ScreenshotCarousel />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 版本时间线 -->
|
||||
<section class="block">
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('版本演进', 'Version History') }}</small>
|
||||
{{ L('迭代记录', 'Iteration Record') }}
|
||||
</h2>
|
||||
<VersionTimeline />
|
||||
</section>
|
||||
|
||||
<!-- 双列:团队 + 排行榜 -->
|
||||
<section class="block block--two">
|
||||
<div>
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('团队', 'Team') }}</small>
|
||||
{{ L('成员', 'Members') }}
|
||||
</h2>
|
||||
<TeamCard />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('排行榜', 'Leaderboard') }}</small>
|
||||
{{ L('玩家分数 Top 30', 'Top 30 Scores') }}
|
||||
</h2>
|
||||
<Leaderboard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ML-Agents 演示视频 -->
|
||||
<section class="block">
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('实验', 'Experiment') }}</small>
|
||||
{{ L('ML-Agents 强化学习演示', 'ML-Agents Reinforcement Learning Demo') }}
|
||||
</h2>
|
||||
<div class="video-wrap">
|
||||
<video controls preload="none" :poster="videoPoster">
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
<p class="video-caption">
|
||||
{{ L('胶囊体经 ML-Agents 训练,学习保球不掉与越障。', 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-game {
|
||||
padding-bottom: var(--space-2xl);
|
||||
}
|
||||
.block {
|
||||
padding: var(--space-xl) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.block__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
align-items: start;
|
||||
}
|
||||
.block__text p {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.block__text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.block--two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.video-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.video-wrap video {
|
||||
width: 100%;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
.video-caption {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.block__grid,
|
||||
.block--two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
src/modules/mobile-game/components/HeroSection.vue
Normal file
84
src/modules/mobile-game/components/HeroSection.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import Tag from '../../../components/ui/Tag.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const meta = {
|
||||
course: { zh: '迈阿密大学 · 毕设 448 / 449', en: 'Miami University · Capstone 448 / 449' },
|
||||
stack: { zh: 'Unity · C# · WebGL · Android · PHP', en: 'Unity · C# · WebGL · Android · PHP' },
|
||||
period: { zh: '2022', en: '2022' },
|
||||
role: { zh: '主开发', en: 'Lead Developer' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero__eyebrow">
|
||||
<Tag>{{ locale === 'zh-CN' ? meta.course.zh : meta.course.en }}</Tag>
|
||||
</div>
|
||||
<h1 class="hero__title">
|
||||
{{ $t('tabs.mobileGame') }}
|
||||
</h1>
|
||||
<p class="hero__desc">
|
||||
{{ locale === 'zh-CN'
|
||||
? '一个跨学期的 Unity 移动游戏毕设项目:448 开发游戏本体(WebGL + Android APK),449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
|
||||
: 'A cross-semester Unity mobile-game capstone: 448 built the game (WebGL + Android APK), 449 built the showcase site and player leaderboard. Iterated across versions, from a character controller to a gyroscope-controlled 3D rolling-ball game, with ML-Agents reinforcement-learning experiments.' }}
|
||||
</p>
|
||||
<div class="hero__meta">
|
||||
<div class="hero__meta-item">
|
||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '技术栈' : 'Stack' }}</span>
|
||||
<span>{{ locale === 'zh-CN' ? meta.stack.zh : meta.stack.en }}</span>
|
||||
</div>
|
||||
<div class="hero__meta-item">
|
||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '时间' : 'Period' }}</span>
|
||||
<span>{{ meta.period }}</span>
|
||||
</div>
|
||||
<div class="hero__meta-item">
|
||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '角色' : 'Role' }}</span>
|
||||
<span>{{ locale === 'zh-CN' ? meta.role.zh : meta.role.en }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
padding: var(--space-2xl) 0 var(--space-xl);
|
||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hero__eyebrow {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.hero__title {
|
||||
font-size: 2.3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.hero__desc {
|
||||
max-width: 720px;
|
||||
font-size: 1.02rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xl);
|
||||
}
|
||||
.hero__meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.hero__meta-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
130
src/modules/mobile-game/components/Leaderboard.vue
Normal file
130
src/modules/mobile-game/components/Leaderboard.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const rows = ref([])
|
||||
const state = ref('loading') // loading | ok | error
|
||||
const errorMsg = ref('')
|
||||
|
||||
// 复用 449 现有后端:POST /449/449rest.php {num} 取分页 JSON
|
||||
async function load() {
|
||||
state.value = 'loading'
|
||||
try {
|
||||
const all = []
|
||||
// 拉取 3 页(每页 10 条,共 top30)
|
||||
for (let page = 0; page < 3; page++) {
|
||||
const res = await fetch('/449/449rest.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `num=${page * 10}`
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
// data 形如 { "1": ["name", score], ... }
|
||||
Object.keys(data).forEach((k) => {
|
||||
const entry = data[k]
|
||||
all.push({ rank: all.length + 1, name: entry[0], score: entry[1] })
|
||||
})
|
||||
}
|
||||
rows.value = all
|
||||
state.value = all.length ? 'ok' : 'error'
|
||||
if (!all.length) errorMsg.value = locale.value === 'zh-CN' ? '暂无分数记录' : 'No scores yet'
|
||||
} catch (e) {
|
||||
state.value = 'error'
|
||||
errorMsg.value = locale.value === 'zh-CN'
|
||||
? '排行榜服务暂不可用'
|
||||
: 'Leaderboard service unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="leaderboard">
|
||||
<div v-if="state === 'loading'" class="leaderboard__state">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="state === 'error'" class="leaderboard__state leaderboard__state--error">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<table v-else class="leaderboard__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ locale === 'zh-CN' ? '玩家' : 'Name' }}</th>
|
||||
<th>{{ locale === 'zh-CN' ? '分数' : 'Score' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rows" :key="r.rank" :class="{ 'is-top': r.rank <= 3 }">
|
||||
<td class="leaderboard__rank">{{ r.rank }}</td>
|
||||
<td class="leaderboard__name">{{ r.name }}</td>
|
||||
<td class="leaderboard__score">{{ r.score }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.leaderboard {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.leaderboard__state {
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.leaderboard__state--error {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.leaderboard__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.leaderboard__table th {
|
||||
text-align: left;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.leaderboard__table td {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.leaderboard__table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaderboard__table tbody tr:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.leaderboard__rank {
|
||||
width: 50px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
tr.is-top .leaderboard__rank {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.leaderboard__score {
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
130
src/modules/mobile-game/components/ScreenshotCarousel.vue
Normal file
130
src/modules/mobile-game/components/ScreenshotCarousel.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { screenshots } from '../data/versions.js'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const current = ref(0)
|
||||
let timer = null
|
||||
|
||||
function go(i) {
|
||||
current.value = (i + screenshots.length) % screenshots.length
|
||||
}
|
||||
function next() {
|
||||
go(current.value + 1)
|
||||
}
|
||||
function pause() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
function resume() {
|
||||
if (!timer) timer = setInterval(next, 5000)
|
||||
}
|
||||
|
||||
onMounted(resume)
|
||||
onUnmounted(pause)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="carousel" @mouseenter="pause" @mouseleave="resume">
|
||||
<div class="carousel__viewport">
|
||||
<div class="carousel__track" :style="{ transform: `translateX(-${current * 100}%)` }">
|
||||
<figure v-for="(s, i) in screenshots" :key="i" class="carousel__slide">
|
||||
<img :src="s.src" :alt="locale === 'zh-CN' ? s.caption.zh : s.caption.en" loading="lazy" />
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="carousel__btn carousel__btn--prev" @click="go(current - 1)" aria-label="prev">‹</button>
|
||||
<button class="carousel__btn carousel__btn--next" @click="go(current + 1)" aria-label="next">›</button>
|
||||
|
||||
<div class="carousel__dots">
|
||||
<button
|
||||
v-for="(s, i) in screenshots"
|
||||
:key="i"
|
||||
class="carousel__dot"
|
||||
:class="{ 'is-active': i === current }"
|
||||
@click="go(i)"
|
||||
:aria-label="`slide ${i + 1}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.carousel {
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.carousel__viewport {
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
.carousel__track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
.carousel__slide {
|
||||
flex: 0 0 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.carousel__slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.carousel__btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.carousel__btn:hover {
|
||||
background: #fff;
|
||||
}
|
||||
.carousel__btn--prev { left: 12px; }
|
||||
.carousel__btn--next { right: 12px; }
|
||||
|
||||
.carousel__dots {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.carousel__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid var(--border);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.carousel__dot.is-active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
width: 22px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
64
src/modules/mobile-game/components/TeamCard.vue
Normal file
64
src/modules/mobile-game/components/TeamCard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { team } from '../data/team.js'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="team">
|
||||
<div v-for="m in team" :key="m.name" class="team__card">
|
||||
<div class="team__avatar">{{ m.name.charAt(0) }}</div>
|
||||
<div class="team__info">
|
||||
<div class="team__name">{{ m.name }}</div>
|
||||
<div class="team__role">{{ L(m.role) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
.team__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.team__card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
.team__avatar {
|
||||
flex: 0 0 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.team__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.team__role {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
141
src/modules/mobile-game/components/VersionTimeline.vue
Normal file
141
src/modules/mobile-game/components/VersionTimeline.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { versions } from '../data/versions.js'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="(v, i) in versions"
|
||||
:key="v.id"
|
||||
class="timeline__item"
|
||||
>
|
||||
<div class="timeline__marker">
|
||||
<span class="timeline__dot"></span>
|
||||
<span v-if="i < versions.length - 1" class="timeline__line"></span>
|
||||
</div>
|
||||
|
||||
<div class="timeline__body">
|
||||
<div class="timeline__head">
|
||||
<span class="timeline__series">{{ v.series }}</span>
|
||||
<span class="timeline__version">v{{ v.version }}</span>
|
||||
</div>
|
||||
<div class="timeline__product">{{ v.product }}</div>
|
||||
<div class="timeline__row">
|
||||
<span class="timeline__label">{{ locale === 'zh-CN' ? '操作' : 'Controls' }}</span>
|
||||
<span>{{ L(v.control) }}</span>
|
||||
</div>
|
||||
<div class="timeline__row">
|
||||
<span class="timeline__label">{{ locale === 'zh-CN' ? '说明' : 'Note' }}</span>
|
||||
<span>{{ L(v.note) }}</span>
|
||||
</div>
|
||||
<div class="timeline__row">
|
||||
<span class="timeline__label">Unity</span>
|
||||
<span class="timeline__mono">{{ v.unity }}</span>
|
||||
</div>
|
||||
<div class="timeline__links">
|
||||
<a v-if="v.webgl" :href="v.webgl" target="_blank" rel="noopener">
|
||||
{{ locale === 'zh-CN' ? 'WebGL 试玩' : 'Play WebGL' }} ↗
|
||||
</a>
|
||||
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
|
||||
{{ locale === 'zh-CN' ? '下载 APK' : 'Download APK' }} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
position: relative;
|
||||
}
|
||||
.timeline__item {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding-bottom: var(--space-lg);
|
||||
}
|
||||
.timeline__marker {
|
||||
position: relative;
|
||||
flex: 0 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.timeline__dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
border: 2px solid var(--accent);
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
.timeline__line {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
margin-top: 4px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.timeline__body {
|
||||
flex: 1;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
.timeline__body:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.timeline__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.timeline__series {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
.timeline__version {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
padding: 1px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.timeline__product {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
.timeline__row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.timeline__label {
|
||||
flex: 0 0 48px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.timeline__mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.timeline__links {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
8
src/modules/mobile-game/data/team.js
Normal file
8
src/modules/mobile-game/data/team.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// 团队成员(来自 449 展示页)
|
||||
export const team = [
|
||||
{ name: 'Zikai Wang', role: { zh: '主开发', en: 'Lead Developer' } },
|
||||
{ name: 'Hongxiang He', role: { zh: '开发', en: 'Developer' } },
|
||||
{ name: 'Zhuoyue Sun', role: { zh: '团队', en: 'Team' } },
|
||||
{ name: 'Xingchen Zhou', role: { zh: '团队', en: 'Team' } },
|
||||
{ name: 'Yidong Yao', role: { zh: '团队', en: 'Team' } }
|
||||
]
|
||||
98
src/modules/mobile-game/data/versions.js
Normal file
98
src/modules/mobile-game/data/versions.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// 448 各版本数据(来自对构建产物与 index.html 的分析)
|
||||
export const versions = [
|
||||
{
|
||||
id: 'zikai-0.0.0',
|
||||
series: 'zikai-ui',
|
||||
version: '0.0.0',
|
||||
unity: '—',
|
||||
product: 'My project / 1.0.2',
|
||||
webgl: '/448/zikai-0.0.0/',
|
||||
apk: null,
|
||||
control: { zh: '鼠标左键操作、右键追踪', en: 'Left mouse: operate, Right mouse: track' },
|
||||
note: { zh: '早期鼠标控制原型', en: 'Early mouse-controlled prototype' }
|
||||
},
|
||||
{
|
||||
id: 'zikai-0.0.1',
|
||||
series: 'zikai-ui',
|
||||
version: '0.0.1',
|
||||
unity: '2020.3.30f1',
|
||||
product: 'zikai-ui / 1.0.2',
|
||||
webgl: '/448/zikai-0.0.1/',
|
||||
apk: '/448/zikai-0.0.1/zikai.apk',
|
||||
control: { zh: '生命 Q/W/E/R、能量 A/S/D/F', en: 'Health Q/W/E/R, Power A/S/D/F' },
|
||||
note: { zh: '引入生命/能量属性系统', en: 'Introduced health/power stat system' }
|
||||
},
|
||||
{
|
||||
id: 'zikai-0.0.2',
|
||||
series: 'zikai-ui',
|
||||
version: '0.0.2',
|
||||
unity: '2020.3.30f1',
|
||||
product: 'zikai-ui / 1.0.2',
|
||||
webgl: '/448/zikai-0.0.2/',
|
||||
apk: '/448/zikai-0.0.2/zikai.apk',
|
||||
control: { zh: '同 0.0.1', en: 'Same as 0.0.1' },
|
||||
note: { zh: '构建产物重命名整理', en: 'Build artifact renaming' }
|
||||
},
|
||||
{
|
||||
id: 'zikai-0.0.3',
|
||||
series: 'zikai-ui',
|
||||
version: '0.0.3',
|
||||
unity: '2020.3.30f1',
|
||||
product: 'zikai-ui / 1.0.2.5',
|
||||
webgl: '/448/zikai-0.0.3/',
|
||||
apk: '/448/zikai-0.0.3/zikai.apk',
|
||||
control: { zh: 'WASD 移动、J 跳跃、K 飞行(测试)', en: 'WASD move, J jump, K fly (testing)' },
|
||||
note: { zh: '改为 WASD 操作,最新版', en: 'Switched to WASD, latest version' }
|
||||
},
|
||||
{
|
||||
id: 'main-0.0.0',
|
||||
series: 'main',
|
||||
version: '0.0.0',
|
||||
unity: '2020.3.30f1',
|
||||
product: 'zikai-main / 1.0.2.6',
|
||||
webgl: '/448/main-0.0.0/',
|
||||
apk: '/448/main-0.0.0.apk',
|
||||
control: { zh: '角色控制器', en: 'Character controller' },
|
||||
note: { zh: '基线版本', en: 'Baseline' }
|
||||
},
|
||||
{
|
||||
id: 'main-0.0.1',
|
||||
series: 'main',
|
||||
version: '0.0.1',
|
||||
unity: '2021.3.12f1',
|
||||
product: 'beta / 1.0.2',
|
||||
webgl: '/448/main-0.0.1/',
|
||||
apk: '/448/main-0.0.1/zikai.apk',
|
||||
control: { zh: 'WASD 移动、K 跳跃', en: 'WASD move, K jump' },
|
||||
note: { zh: '升级 Unity,新增屏幕按键', en: 'Upgraded Unity, added on-screen buttons' }
|
||||
},
|
||||
{
|
||||
id: 'ball',
|
||||
series: 'roll-a-ball',
|
||||
version: '0.0.2',
|
||||
unity: '2021.3.12f1',
|
||||
product: '3dRoingBallt2 / 0.0.2',
|
||||
webgl: '/448/ball/',
|
||||
apk: '/448/203wangz199.apk',
|
||||
control: { zh: '手机陀螺仪倾斜移动、上倾/点击跳跃', en: 'Tilt phone to move, tilt up/tap to jump' },
|
||||
note: { zh: '最终版本,3D 滚球', en: 'Final version, 3D rolling ball' }
|
||||
},
|
||||
{
|
||||
id: 'hongXiang-0.0.0',
|
||||
series: 'doby',
|
||||
version: '0.0.0',
|
||||
unity: '2021.2.10f1',
|
||||
product: 'DobyAdventure',
|
||||
webgl: null,
|
||||
apk: '/448/hongXiang-0.0.0/2D game.apk',
|
||||
control: { zh: 'A/D 移动、跳跃、吃金币', en: 'A/D move, jump, collect coins' },
|
||||
note: { zh: '2D 平台跳跃(Hongxiang He)', en: '2D platformer (Hongxiang He)' }
|
||||
}
|
||||
]
|
||||
|
||||
// 截图(复用 448 现有图片)
|
||||
export const screenshots = [
|
||||
{ src: '/448/1.jpg', caption: { zh: '项目截图 1', en: 'Screenshot 1' } },
|
||||
{ src: '/448/2.jpg', caption: { zh: '项目截图 2', en: 'Screenshot 2' } },
|
||||
{ src: '/448/3.jpg', caption: { zh: '项目截图 3', en: 'Screenshot 3' } }
|
||||
]
|
||||
8
src/modules/mobile-game/index.js
Normal file
8
src/modules/mobile-game/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// 模块元数据 -- 模块注册表据此自动生成页签与路由
|
||||
export default {
|
||||
id: 'mobile-game',
|
||||
tabKey: 'tabs.mobileGame',
|
||||
order: 1,
|
||||
// 懒加载组件 -> 独立 chunk
|
||||
component: () => import('./MobileGame.vue')
|
||||
}
|
||||
Reference in New Issue
Block a user