feat: 拆分移动游戏项目为独立 z449 子模块(git submodule + i18n prop 桥接)

- mobile-game 模块源码迁出至独立仓库 z449(third_party/z449),独立可渲染/维护
- z449 App.vue 为便携式组件:自带独立 vue-i18n 实例与 locale 文案
- mainPage 侧 MobileGameWrapper 透传 :locale prop,子模块 watch 同步语言
- mainPage i18n 精简:仅保留 tabs.mobileGame,删除 mobileGame.* 大块文案
- 文档:新增 docs/submodule-z449.md,更新 README/architecture/add-module
This commit is contained in:
Zikai
2026-07-23 07:04:08 +00:00
parent 61e65052d4
commit 6ad6c15e9d
24 changed files with 122 additions and 1193 deletions

View File

@@ -19,88 +19,7 @@ export default {
openExternal: 'Open in new tab',
boardId: 'Board ID',
switch: 'Switch'
},
mobileGame: {
hero: {
title: 'Mobile Game Project',
desc: '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.'
},
meta: {
course: 'Miami University · Capstone 448 / 449',
stack: 'Stack',
stackValue: 'Unity · C# · WebGL · Android · PHP',
period: 'Period',
periodValue: '2022'
},
overview: {
label: 'Overview',
title: 'From character controller to gyro ball',
p1: '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. The game evolved along several parallel lines, from 2D character controllers and platformers, ultimately shifting to a gyroscope-controlled 3D rolling ball, with ML-Agents reinforcement-learning experiments.',
p2: 'Gameplay iterated across versions: early builds were keyboard-controlled 2D 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.'
},
subtabs: {
team: 'Team · Leaderboard',
iterations: 'Iterations',
experiment: 'Experiment'
},
team: {
label: 'Team',
title: 'Members'
},
leaderboard: {
label: 'Leaderboard',
title: 'Top 30 Scores',
colRank: '#',
colName: 'Name',
colScore: 'Score',
noScores: 'No scores yet',
error: 'Leaderboard service unavailable'
},
iterations: {
label: 'Version History',
title: 'Iteration Record',
colControls: 'Controls',
colNote: 'Note',
colUnity: 'Unity',
playWebgl: 'Play WebGL',
downloadApk: 'Download APK',
pivotText: 'Earlier parallel lines: 2D'
},
experiment: {
label: 'Experiment',
title: 'ML-Agents Reinforcement Learning Demo',
caption: 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.'
},
tracks: {
'roll-a-ball': {
name: '3D Rolling Ball (Final Form)',
desc: 'The final form as the project shifted from 2D to 3D. Tilt the phone to roll the ball; snowflake bricks fall, yellow bricks bounce.'
},
'zikai-ui': {
name: 'zikai-ui Character Controller',
desc: 'The most iterated line. A 2D character with health/power stats; controls evolved from mouse, QWER/ASDF keys, finally to WASD.'
},
'main': {
name: 'main Character Controller',
desc: '2D character controller; upgraded Unity 2020 -> 2021, added on-screen buttons and jump.'
},
'doby': {
name: 'DobyAdventure',
desc: '2D side-scrolling platformer: collect coins, score, health, touch joystick.'
}
},
versions: {
'zikai-0.0.0': { control: 'Left mouse: operate, Right mouse: track', note: 'Early mouse-controlled prototype' },
'zikai-0.0.1': { control: 'Health Q/W/E/R, Power A/S/D/F', note: 'Introduced health/power stat system' },
'zikai-0.0.2': { control: 'Same as 0.0.1', note: 'Build artifact renaming' },
'zikai-0.0.3': { control: 'WASD move, J jump, K fly (testing)', note: 'Switched to WASD, latest of this line' },
'main-0.0.0': { control: 'Character controller', note: 'Baseline' },
'main-0.0.1': { control: 'WASD move, K jump', note: 'Upgraded Unity, added on-screen buttons' },
'hongXiang-0.0.0': { control: 'A/D move, jump, collect coins', note: '2D platformer' },
'ball': { control: 'Tilt phone to move, tilt up/tap to jump', note: 'Final version, project shifted to 3D rolling ball' }
},
screenshots: {
caption: 'Screenshot {n}'
}
}
// Mobile-game copy is provided by the z449 submodule's own i18n;
// mainPage keeps only tabs.mobileGame. Locale syncs to z449 via :locale prop.
}

View File

@@ -18,90 +18,7 @@ export default {
openExternal: '在新标签页打开',
boardId: '白板 ID',
switch: '切换'
},
mobileGame: {
hero: {
title: '移动游戏项目',
desc: '一个跨学期的 Unity 移动游戏毕设项目448 开发游戏本体WebGL + Android APK449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
},
meta: {
course: '迈阿密大学 · 毕设 448 / 449',
stack: '技术栈',
stackValue: 'Unity · C# · WebGL · Android · PHP',
period: '时间',
periodValue: '2022'
},
overview: {
label: '项目概览',
title: '从角色控制器到陀螺仪滚球',
p1: '项目跨越 448 与 449 两门课程。448 用 Unity 开发游戏本体,产出可浏览器试玩的 WebGL 构建与可安装的 Android APK449 搭建展示网站与基于 PHP+MySQL 的玩家分数排行榜。游戏经历多条并行演进线,从 2D 角色控制器与平台跳跃,最终转向手机陀螺仪操控的 3D 滚球,并尝试用 ML-Agents 做强化学习实验。',
p2: '玩法经过多版本迭代:早期是键盘控制生命/能量的 2D 角色控制器,最终版改为手机陀螺仪倾斜操控的 3D 滚球,含雪花砖掉落、黄色砖弹跳等机制。'
},
subtabs: {
team: '团队 · 排行榜',
iterations: '迭代记录',
experiment: '实验'
},
team: {
label: '团队',
title: '成员'
},
leaderboard: {
label: '排行榜',
title: '玩家分数 Top 30',
colRank: '#',
colName: '玩家',
colScore: '分数',
noScores: '暂无分数记录',
error: '排行榜服务暂不可用'
},
iterations: {
label: '版本演进',
title: '迭代记录',
colControls: '操作',
colNote: '说明',
colUnity: 'Unity',
playWebgl: 'WebGL 试玩',
downloadApk: '下载 APK',
pivotText: '早期并行演进2D 线'
},
experiment: {
label: '实验',
title: 'ML-Agents 强化学习演示',
caption: '胶囊体经 ML-Agents 训练,学习保球不掉与越障。'
},
// 版本演进线与版本数据中的可翻译文案
tracks: {
'roll-a-ball': {
name: '3D 滚球(最终形态)',
desc: '项目从 2D 转向 3D 的最终形态。手机陀螺仪倾斜操控滚球,含雪花砖掉落、黄色砖弹跳等机制。'
},
'zikai-ui': {
name: 'zikai-ui 角色控制器',
desc: '迭代次数最多的线。2D 角色带生命/能量属性操作方式从鼠标、QWER/ASDF 键盘最终改为 WASD。'
},
'main': {
name: 'main 角色控制器',
desc: '2D 角色控制器,从 Unity 2020 升级到 2021新增屏幕按键与跳跃。'
},
'doby': {
name: 'DobyAdventure',
desc: '2D 横版平台跳跃,吃金币、计分、血量、触屏摇杆。'
}
},
// 各版本的操作与说明文案key = 版本 id
versions: {
'zikai-0.0.0': { control: '鼠标左键操作、右键追踪', note: '早期鼠标控制原型' },
'zikai-0.0.1': { control: '生命 Q/W/E/R、能量 A/S/D/F', note: '引入生命/能量属性系统' },
'zikai-0.0.2': { control: '同 0.0.1', note: '构建产物重命名整理' },
'zikai-0.0.3': { control: 'WASD 移动、J 跳跃、K 飞行(测试)', note: '改为 WASD 操作,本线最新版' },
'main-0.0.0': { control: '角色控制器', note: '基线版本' },
'main-0.0.1': { control: 'WASD 移动、K 跳跃', note: '升级 Unity新增屏幕按键' },
'hongXiang-0.0.0': { control: 'A/D 移动、跳跃、吃金币', note: '2D 平台跳跃' },
'ball': { control: '手机陀螺仪倾斜移动、上倾/点击跳跃', note: '最终版本,项目转向 3D 滚球' }
},
screenshots: {
caption: '项目截图 {n}'
}
}
// 注移动游戏项目mobile-game的文案由子模块 z449 自有 i18n 提供,
// mainPage 仅保留 tabs.mobileGame 页签标题。切换语言时经 :locale prop 同步给 z449。
}

View File

@@ -1,106 +0,0 @@
<script setup>
import { ref, defineAsyncComponent } from 'vue'
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
defineOptions({ name: 'mobile-game' })
import HeroSection from './components/HeroSection.vue'
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
import SubTabs from './components/SubTabs.vue'
import PageShell from '../../components/ui/PageShell.vue'
import { useLocale } from '../../composables/useLocale.js'
import { subTabs, defaultSubTab } from './config.js'
const { t } = useLocale()
// 子标签页配置转为 SubTabs 需要的 { id, labelKey } 形式
const tabs = subTabs
const activeTab = ref(defaultSubTab)
// ★ 按需加载:三个子标签页用 defineAsyncComponent 动态 import
// 被 Vite/Rollup 拆成独立 chunk只有切到对应标签页时才下载与渲染。
// 配合 v-if未激活的标签页组件根本不挂载不执行 setup、不发 AJAX
const TeamLeaderboardTab = defineAsyncComponent(() => import('./components/TeamLeaderboardTab.vue'))
const IterationsTab = defineAsyncComponent(() => import('./components/IterationsTab.vue'))
const ExperimentTab = defineAsyncComponent(() => import('./components/ExperimentTab.vue'))
</script>
<template>
<div class="mobile-game">
<HeroSection />
<!-- 内容区复用 PageShell 统一容器宽度无标题Hero 即页头 -->
<PageShell :padded="false">
<div class="mg-blocks">
<!-- 概览 + 截图轮播始终显示 -->
<section class="block">
<div class="block__grid">
<div class="block__text">
<h2 class="section-title">
<small>{{ t('mobileGame.overview.label') }}</small>
{{ t('mobileGame.overview.title') }}
</h2>
<p>{{ t('mobileGame.overview.p1') }}</p>
<p>{{ t('mobileGame.overview.p2') }}</p>
</div>
<div class="block__media">
<ScreenshotCarousel />
</div>
</div>
</section>
<!-- 子标签页区域按需加载v-if 确保未激活的不渲染 -->
<section class="block">
<SubTabs v-model="activeTab" :tabs="tabs" />
<Suspense>
<TeamLeaderboardTab v-if="activeTab === 'team'" />
<IterationsTab v-else-if="activeTab === 'iterations'" />
<ExperimentTab v-else-if="activeTab === 'experiment'" />
<template #fallback>
<div class="tab-loading">{{ t('common.loading') }}</div>
</template>
</Suspense>
</section>
</div>
</PageShell>
</div>
</template>
<style scoped>
.mobile-game {
padding-bottom: var(--space-2xl);
}
.mg-blocks {
padding: var(--space-xl) 0;
}
.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;
}
.tab-loading {
padding: var(--space-xl);
text-align: center;
color: var(--text-tertiary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.block__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup>
// ★ mobile-game 页签的 mainPage 侧包装器。
// 子模块 z449 的 App.vue 作为便携式组件被 import本包装器负责
// 1. 持有 KeepAlive 匹配用的组件名(与模块 id 一致),子模块自身不持 name
// 2. 把 mainPage 当前语言经 :locale prop 透传给子模块的独立 i18n 实例,
// 使其跟随父项目语言开关响应式切换(不共享 messages仅同步 locale 值)
// 子模块独立运行时不经过本包装器,保持纯净。
defineOptions({ name: 'mobile-game' })
import { computed } from 'vue'
import i18n from '../../i18n/index.js'
import Z449App from '../../../third_party/z449/src/App.vue'
// 透传父项目当前语言给子应用(响应式:切语言时子应用内容跟随)
const locale = computed(() => i18n.global.locale.value)
</script>
<template>
<Z449App :locale="locale" />
</template>

View File

@@ -1,43 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
import { assets } from '../config.js'
const { t } = useLocale()
</script>
<template>
<div class="tab-experiment">
<h2 class="section-title">
<small>{{ t('mobileGame.experiment.label') }}</small>
{{ t('mobileGame.experiment.title') }}
</h2>
<div class="video-wrap">
<video controls preload="none" :poster="assets.video.poster">
<source :src="assets.video.src" type="video/mp4" />
</video>
<p class="video-caption">{{ t('mobileGame.experiment.caption') }}</p>
</div>
</div>
</template>
<style scoped>
.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;
}
</style>

View File

@@ -1,64 +0,0 @@
<script setup>
import Tag from '../../../components/ui/Tag.vue'
import { useLocale } from '../../../composables/useLocale.js'
import { heroMeta } from '../config.js'
const { t } = useLocale()
</script>
<template>
<section class="hero">
<div class="container">
<div class="hero__eyebrow">
<Tag>{{ t(heroMeta.courseKey) }}</Tag>
</div>
<h1 class="hero__title">{{ t('mobileGame.hero.title') }}</h1>
<p class="hero__desc">{{ t('mobileGame.hero.desc') }}</p>
<div class="hero__meta">
<div v-for="item in heroMeta.items" :key="item.labelKey" class="hero__meta-item">
<span class="hero__meta-label">{{ t(item.labelKey) }}</span>
<span>{{ t(item.valueKey) }}</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>

View File

@@ -1,16 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
import VersionTimeline from './VersionTimeline.vue'
const { t } = useLocale()
</script>
<template>
<div class="tab-iterations">
<h2 class="section-title">
<small>{{ t('mobileGame.iterations.label') }}</small>
{{ t('mobileGame.iterations.title') }}
</h2>
<VersionTimeline />
</div>
</template>

View File

@@ -1,128 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { leaderboard } from '../config.js'
const { t } = useLocale()
const rows = ref([])
const state = ref('loading') // loading | ok | error
const errorMsg = ref('')
// 复用 449 现有后端POST endpoint {num} 取分页 JSON
async function load() {
state.value = 'loading'
try {
const all = []
for (let page = 0; page < leaderboard.pages; page++) {
const res = await fetch(leaderboard.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `num=${page * leaderboard.perPage}`
})
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 = t('mobileGame.leaderboard.noScores')
} catch (e) {
state.value = 'error'
errorMsg.value = t('mobileGame.leaderboard.error')
}
}
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>{{ t('mobileGame.leaderboard.colRank') }}</th>
<th>{{ t('mobileGame.leaderboard.colName') }}</th>
<th>{{ t('mobileGame.leaderboard.colScore') }}</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>

View File

@@ -1,131 +0,0 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { assets } from '../config.js'
const { t } = useLocale()
const images = assets.screenshots
const current = ref(0)
let timer = null
function go(i) {
current.value = (i + images.length) % images.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="(src, i) in images" :key="i" class="carousel__slide">
<img :src="src" :alt="t('mobileGame.screenshots.caption', { n: i + 1 })" 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="(src, i) in images"
: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>

View File

@@ -1,65 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
const props = defineProps({
tabs: { type: Array, required: true }, // [{ id, labelKey }]
modelValue: { type: String, required: true }
})
const emit = defineEmits(['update:modelValue'])
const { t } = useLocale()
</script>
<template>
<div class="subtabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="subtabs__btn"
:class="{ 'is-active': modelValue === tab.id }"
@click="emit('update:modelValue', tab.id)"
>
{{ t(tab.labelKey) }}
</button>
</div>
</template>
<style scoped>
.subtabs {
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-lg);
overflow-x: auto;
scrollbar-width: none;
}
.subtabs::-webkit-scrollbar {
display: none;
}
.subtabs__btn {
position: relative;
padding: var(--space-sm) var(--space-md);
font-size: 0.92rem;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
transition: color var(--transition);
}
.subtabs__btn:hover {
color: var(--text-primary);
}
.subtabs__btn.is-active {
color: var(--accent);
}
.subtabs__btn.is-active::after {
content: '';
position: absolute;
left: var(--space-md);
right: var(--space-md);
bottom: -1px;
height: 2px;
background: var(--accent);
border-radius: 2px;
}
</style>

View File

@@ -1,55 +0,0 @@
<script setup>
import { team } from '../data/team.js'
</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>
</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);
}
</style>

View File

@@ -1,40 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
import TeamCard from './TeamCard.vue'
import Leaderboard from './Leaderboard.vue'
const { t } = useLocale()
</script>
<template>
<div class="tab-team block--two">
<div>
<h2 class="section-title">
<small>{{ t('mobileGame.team.label') }}</small>
{{ t('mobileGame.team.title') }}
</h2>
<TeamCard />
</div>
<div>
<h2 class="section-title">
<small>{{ t('mobileGame.leaderboard.label') }}</small>
{{ t('mobileGame.leaderboard.title') }}
</h2>
<Leaderboard />
</div>
</div>
</template>
<style scoped>
.block--two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
align-items: start;
}
@media (max-width: 768px) {
.block--two {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,266 +0,0 @@
<script setup>
import { computed } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { versions, tracks } from '../data/versions.js'
const { t } = useLocale()
// 按 tracks 数组顺序分组3D 最终形态在前);同一线内按 order 排序
const grouped = computed(() => {
return tracks
.map((tr) => {
const items = versions
.filter((v) => v.track === tr.id)
.sort((a, b) => a.order - b.order)
return { track: tr, items }
})
.filter((g) => g.items.length > 0)
})
const is3D = (track) => track.form === '3D'
const isFeatured = (track) => track.featured
// 形态转变标记:出现在 3D 线首位之后、2D 线之前
const showPivotAfter = (gi) => gi === 0 && grouped.value.length > 1 && is3D(grouped.value[0].track)
// i18n 取值辅助track/version 的文案在 locale 的 mobileGame.tracks / mobileGame.versions 下
const trackName = (trackId) => t(`mobileGame.tracks.${trackId}.name`)
const trackDesc = (trackId) => t(`mobileGame.tracks.${trackId}.desc`)
const vControl = (versionId) => t(`mobileGame.versions.${versionId}.control`)
const vNote = (versionId) => t(`mobileGame.versions.${versionId}.note`)
</script>
<template>
<div class="timeline">
<div
v-for="(g, gi) in grouped"
:key="g.track.id"
class="timeline__track"
:class="{
'timeline__track--3d': is3D(g.track),
'timeline__track--featured': isFeatured(g.track)
}"
>
<!-- 线标题 -->
<div class="timeline__track-head">
<span class="timeline__form" :class="{ 'is-3d': is3D(g.track) }">{{ g.track.form }}</span>
<span class="timeline__track-name">{{ trackName(g.track.id) }}</span>
<span v-if="isFeatured(g.track)" class="timeline__star"></span>
</div>
<p class="timeline__track-desc">{{ trackDesc(g.track.id) }}</p>
<!-- 该线下的版本 -->
<div class="timeline__items">
<div
v-for="(v, i) in g.items"
:key="v.id"
class="timeline__item"
>
<div class="timeline__marker">
<span class="timeline__dot"></span>
<span v-if="i < g.items.length - 1" class="timeline__line"></span>
</div>
<div class="timeline__body">
<div class="timeline__head">
<span class="timeline__version">v{{ v.version }}</span>
<span class="timeline__product">{{ v.product }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ t('mobileGame.iterations.colControls') }}</span>
<span>{{ vControl(v.id) }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ t('mobileGame.iterations.colNote') }}</span>
<span>{{ vNote(v.id) }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ t('mobileGame.iterations.colUnity') }}</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">
{{ t('mobileGame.iterations.playWebgl') }}
</a>
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
{{ t('mobileGame.iterations.downloadApk') }}
</a>
</div>
</div>
</div>
</div>
<!-- 形态转变标记3D 线之后2D 线之前 -->
<div v-if="showPivotAfter(gi)" class="timeline__pivot">
<span class="timeline__pivot-line"></span>
<span class="timeline__pivot-text">{{ t('mobileGame.iterations.pivotText') }}</span>
<span class="timeline__pivot-line"></span>
</div>
</div>
</div>
</template>
<style scoped>
.timeline {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
/* 最终形态强调 */
.timeline__track--featured {
padding: var(--space-lg);
background: linear-gradient(180deg, var(--accent-weak) 0%, transparent 100%);
border: 1px solid var(--accent-weak);
border-radius: var(--radius);
}
.timeline__track--featured .timeline__track-name {
font-size: 1.2rem;
}
.timeline__track--featured .timeline__track-desc {
color: var(--text-primary);
}
.timeline__track-head {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.timeline__form {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 0.74rem;
font-weight: 600;
border-radius: 999px;
background: var(--surface-2);
color: var(--text-secondary);
letter-spacing: 0.04em;
}
.timeline__form.is-3d {
background: var(--accent);
color: #fff;
}
.timeline__track-name {
font-weight: 600;
font-size: 1.05rem;
color: var(--text-primary);
}
.timeline__star {
color: var(--accent);
font-size: 0.9rem;
}
.timeline__track-desc {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 var(--space-md);
max-width: 640px;
}
.timeline__items {
padding-left: 4px;
}
.timeline__item {
display: flex;
gap: var(--space-md);
padding-bottom: var(--space-md);
}
.timeline__marker {
position: relative;
flex: 0 0 14px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 6px;
}
.timeline__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bg);
border: 2px solid var(--accent);
flex: 0 0 10px;
}
.timeline__track--3d .timeline__dot {
background: var(--accent);
}
.timeline__line {
flex: 1;
width: 2px;
background: var(--border);
margin-top: 4px;
min-height: 20px;
}
.timeline__body {
flex: 1;
padding: var(--space-sm) var(--space-md);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
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: var(--space-xs);
}
.timeline__version {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--accent);
background: var(--accent-weak);
padding: 1px 8px;
border-radius: 6px;
font-weight: 600;
}
.timeline__product {
font-size: 0.82rem;
color: var(--text-tertiary);
}
.timeline__row {
display: flex;
gap: var(--space-sm);
font-size: 0.88rem;
color: var(--text-secondary);
padding: 1px 0;
}
.timeline__label {
flex: 0 0 52px;
color: var(--text-tertiary);
font-size: 0.8rem;
}
.timeline__mono {
font-family: var(--font-mono);
}
.timeline__links {
display: flex;
gap: var(--space-md);
margin-top: var(--space-xs);
font-size: 0.85rem;
}
/* 形态转变标记 */
.timeline__pivot {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-md) 0 0;
}
.timeline__pivot-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
}
.timeline__pivot-text {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-tertiary);
white-space: nowrap;
letter-spacing: 0.02em;
}
</style>

View File

@@ -1,46 +0,0 @@
// mobile-game 模块的静态配置(集中管理,避免散落在各组件)
// 文案走 i18n见 locales 的 mobileGame 命名空间),此处只放纯配置与资源路径。
// 站点根绝对路径的资源引用
export const assets = {
// 截图轮播图源
screenshots: [
'/448/1.jpg',
'/448/2.jpg',
'/448/3.jpg'
],
// ML-Agents 演示视频
video: {
src: '/448/ml/449ml.mp4',
poster: '/448/2.jpg'
}
}
// 排行榜后端(复用 449 现有 PHP 接口)
export const leaderboard = {
endpoint: '/449/449rest.php',
// 拉取页数 x 每页条数 = 总条数
pages: 3,
perPage: 10
}
// Hero 区元数据i18n 键,文案在 locales/mobileGame.meta 下)
export const heroMeta = {
// meta 项顺序labelKey -> valueKey
items: [
{ labelKey: 'mobileGame.meta.stack', valueKey: 'mobileGame.meta.stackValue' },
{ labelKey: 'mobileGame.meta.period', valueKey: 'mobileGame.meta.periodValue' }
],
// 顶部 eyebrow tag
courseKey: 'mobileGame.meta.course'
}
// 子标签页定义id 与 i18n 键)
export const subTabs = [
{ id: 'team', labelKey: 'mobileGame.subtabs.team' },
{ id: 'iterations', labelKey: 'mobileGame.subtabs.iterations' },
{ id: 'experiment', labelKey: 'mobileGame.subtabs.experiment' }
]
// 默认激活的子标签页
export const defaultSubTab = 'team'

View File

@@ -1,8 +0,0 @@
// 团队成员(来自 449 展示页)
export const team = [
{ name: 'Zikai Wang' },
{ name: 'Hongxiang He' },
{ name: 'Zhuoyue Sun' },
{ name: 'Xingchen Zhou' },
{ name: 'Yidong Yao' }
]

View File

@@ -1,48 +0,0 @@
// 448 版本演进数据(纯结构化数据,文案走 i18nmobileGame.tracks / mobileGame.versions
//
// 项目是多条线并行演进的且经历了「2D -> 3D 滚球」的形态转变:
// - roll-a-ball3Dfeatured项目转向 3D 的最终形态,置顶强调
// - zikai-ui2D角色控制器0.0.0 -> 0.0.3
// - main2D角色控制器0.0.0 -> 0.0.1
// - doby2D2D 平台跳跃
//
// track 字段标识所属演进线form 标识 2D/3Dorder 用于线内排序。
// name/note/control 等文案通过 i18n 键 tracks[trackId] / versions[versionId] 取。
export const tracks = [
{
id: 'roll-a-ball',
form: '3D',
featured: true
},
{
id: 'zikai-ui',
form: '2D'
},
{
id: 'main',
form: '2D'
},
{
id: 'doby',
form: '2D'
}
]
export const versions = [
// zikai-ui 线
{ id: 'zikai-0.0.0', track: 'zikai-ui', version: '0.0.0', unity: '-', product: 'My project / 1.0.2', webgl: '/448/zikai-0.0.0/', apk: null, order: 1 },
{ id: 'zikai-0.0.1', track: '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', order: 2 },
{ id: 'zikai-0.0.2', track: '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', order: 3 },
{ id: 'zikai-0.0.3', track: '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', order: 4 },
// main 线
{ id: 'main-0.0.0', track: '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', order: 2 },
{ id: 'main-0.0.1', track: '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', order: 5 },
// doby 线
{ id: 'hongXiang-0.0.0', track: 'doby', version: '0.0.0', unity: '2021.2.10f1', product: 'DobyAdventure', webgl: null, apk: '/448/hongXiang-0.0.0/2D game.apk', order: 3 },
// roll-a-ball 线3D项目形态转变
{ id: 'ball', track: 'roll-a-ball', version: '0.0.2', unity: '2021.3.12f1', product: '3dRoingBallt2 / 0.0.2', webgl: '/448/ball/', apk: '/448/203wangz199.apk', order: 6 }
]

View File

@@ -3,6 +3,6 @@ export default {
id: 'mobile-game',
tabKey: 'tabs.mobileGame',
order: 1,
// 懒加载组件 -> 独立 chunk
component: () => import('./MobileGame.vue')
// 懒加载组件 -> 独立 chunk(包装器 import 子模块 z449 的 App.vue
component: () => import('./MobileGameWrapper.vue')
}