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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,8 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
# 子模块 timeTableFix 的依赖(node_modules 已被上一条覆盖,此条仅作显式说明)
|
||||
# 子模块的依赖(node_modules 已被上一条覆盖,以下仅作显式说明)
|
||||
third_party/timeTableFix/node_modules/
|
||||
third_party/z449/node_modules/
|
||||
|
||||
# 构建产物(输出到 /root/html,不入库)
|
||||
dist/
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +1,6 @@
|
||||
[submodule "third_party/timeTableFix"]
|
||||
path = third_party/timeTableFix
|
||||
url = https://git.zikai.wang/zikai/timeTableFix.git
|
||||
[submodule "third_party/z449"]
|
||||
path = third_party/z449
|
||||
url = https://git.zikai.wang/zikai/z449.git
|
||||
|
||||
11
README.md
11
README.md
@@ -12,14 +12,15 @@ Zikai 的作品集主页 -- Vue 3 模块化多项目展示站。浅色极简、
|
||||
```
|
||||
mainPage/
|
||||
├── index.html
|
||||
├── package.json # 含 ical.js(timeTableFix 依赖)
|
||||
├── package.json # 含 ical.js(timeTableFix 依赖)、vue-i18n(z449 嵌入时共享)
|
||||
├── vite.config.js # 构建输出到 /root/html(emptyOutDir=false,不清空既有文件)
|
||||
├── .gitmodules # git submodule:third_party/timeTableFix
|
||||
├── README.md
|
||||
├── docs/ # 架构 / 新增页签 / 子模块管理文档
|
||||
├── public/favicon.svg
|
||||
├── third_party/
|
||||
│ └── timeTableFix/ # ★ git submodule:ICS 日程整理器,独立仓库
|
||||
│ ├── timeTableFix/ # ★ git submodule:ICS 日程整理器,独立仓库
|
||||
│ └── z449/ # ★ git submodule:移动游戏项目展示页,独立仓库
|
||||
└── src/
|
||||
├── main.js # 应用入口(挂载 i18n / router / 全局样式)
|
||||
├── App.vue # 根布局:顶栏 + 标签导航 + <KeepAlive router-view> + 页脚
|
||||
@@ -48,6 +49,7 @@ mainPage/
|
||||
|
||||
> 共享记事本页签 iframe 嵌入外部服务(`https://f.zikai.wang/wb/share`),URL 在 `src/config/app.config.js` 配置。
|
||||
> 日程整理页签(timeTableFix)经 git submodule 构建期集成(非 iframe),详见 [`docs/submodule-timeTableFix.md`](./docs/submodule-timeTableFix.md)。
|
||||
> 移动游戏项目页签(z449)同样经 git submodule 构建期集成,子模块自带独立 i18n,经 `:locale` prop 同步父项目语言,详见 [`docs/submodule-z449.md`](./docs/submodule-z449.md)。
|
||||
|
||||
## Ubuntu 从 0 安装
|
||||
|
||||
@@ -56,11 +58,13 @@ mainPage/
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# 2. 取代码(含子模块 timeTableFix)
|
||||
# 2. 取代码(含子模块 timeTableFix / z449)
|
||||
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||
cd mainPage
|
||||
# 若已 clone 但未带子模块:git submodule update --init --recursive
|
||||
npm install
|
||||
# 子模块 z449 需各自安装依赖(构建期编译其 .vue)
|
||||
(cd third_party/z449 && npm install)
|
||||
|
||||
# 3. 构建(产物输出到 /root/html,不清空既有文件)
|
||||
npm run build
|
||||
@@ -96,3 +100,4 @@ npm run preview # 本地预览构建产物
|
||||
- [架构(模块注册 / PageShell / KeepAlive / 存活校验)](./docs/architecture.md)
|
||||
- [如何新增一个项目页签](./docs/add-module.md)
|
||||
- [子项目 timeTableFix 管理(submodule 升级 / 移除)](./docs/submodule-timeTableFix.md)
|
||||
- [子项目 z449 管理(submodule + i18n prop 桥接)](./docs/submodule-z449.md)
|
||||
|
||||
@@ -97,7 +97,10 @@ export default { id: 'my-project', tabKey: 'tabs.myProject', order: 4, param: 'i
|
||||
|
||||
### 集成独立子项目(git submodule)
|
||||
|
||||
参照 `timetable` 模块:建 mainPage 侧包装器组件 import 子项目 `App.vue`,套 `PageShell`,包装器持有 `defineOptions({ name })`。子项目保持纯净。详见 [submodule-timeTableFix.md](./submodule-timeTableFix.md)。
|
||||
参照 `timetable` 模块(无 i18n 子项目)或 `mobile-game` 模块(带独立 i18n 的子项目):建 mainPage 侧包装器组件 import 子项目 `App.vue`,包装器持有 `defineOptions({ name })`。子项目保持纯净。
|
||||
|
||||
- **无 i18n 的子项目**(如 timeTableFix):包装器直接 import App.vue 即可。
|
||||
- **带独立 i18n 的子项目**(如 z449):子项目 App.vue 接 `:locale` prop,包装器把 mainPage 当前语言透传(`<Z449App :locale="locale" />`),子项目 `watch` 同步到自己的 i18n 实例。详见 [submodule-z449.md](./submodule-z449.md)。
|
||||
|
||||
## 元数据字段速查
|
||||
|
||||
|
||||
@@ -52,8 +52,15 @@ export default {
|
||||
- `keepInclude` 是模块 id 列表,默认包含所有模块。
|
||||
- 声明了 `requiresAlive` 的模块,当存活探测为 `down` 时移出名单 -> 组件卸载;恢复 `up` 后重新纳入。
|
||||
- **匹配约定**:KeepAlive 按组件 `name` 匹配,因此每个模块组件需 `defineOptions({ name: <模块 id> })`。
|
||||
- 内置模块(mobile-game/whiteboard/calendar)直接在组件内声明。
|
||||
- 子模块集成(timetable)由 mainPage 侧包装器 `Timetable.vue` 持有 name,子项目自身不持 name(保持纯净)。
|
||||
- 内置模块(whiteboard/calendar)直接在组件内声明。
|
||||
- 子模块集成(timetable/mobile-game)由 mainPage 侧包装器持有 name,子项目自身不持 name(保持纯净)。
|
||||
|
||||
## 子模块集成(git submodule + 构建期组件)
|
||||
|
||||
部分页签由独立仓库的子项目经 git submodule 集成,mainPage 侧用包装器 import 子项目 `App.vue` 作为路由组件(非 iframe),共享构建与 KeepAlive 缓存:
|
||||
|
||||
- **timeTableFix**(`third_party/timeTableFix`,日程整理页签):无 i18n,子项目硬编码中文。详见 [submodule-timeTableFix.md](./submodule-timeTableFix.md)。
|
||||
- **z449**(`third_party/z449`,移动游戏页签):子项目自带独立 `vue-i18n` 实例与 locale 文案。mainPage 包装器把当前语言经 `:locale` prop 透传,子项目 `App.vue` `watch` 该 prop 同步到自己的 i18n 实例,从而跟随父项目语言开关响应式切换(不共享 messages,仅同步 locale 值)。详见 [submodule-z449.md](./submodule-z449.md)。
|
||||
|
||||
## 存活校验(requiresAlive)
|
||||
|
||||
|
||||
69
docs/submodule-z449.md
Normal file
69
docs/submodule-z449.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 子项目 z449(git submodule + i18n 桥接)
|
||||
|
||||
z449(移动游戏项目展示页)作为 **git submodule** 被 mainPage 引入,位于 `third_party/z449`,独立仓库为 https://git.zikai.wang/zikai/z449.git 。
|
||||
|
||||
## 集成方式
|
||||
|
||||
mainPage 侧的 `src/modules/mobile-game/MobileGameWrapper.vue` 包装器 import 子模块的 `src/App.vue` 作为组件,并持有 KeepAlive 匹配名 `defineOptions({ name: 'mobile-game' })`。
|
||||
|
||||
- 子模块 `App.vue` 是**便携式组件**:嵌入与独立运行两用。它自带独立 `vue-i18n` 实例与自己的 locale 文案(`mobileGame.*`),不共享 mainPage 的 messages。
|
||||
- **i18n 桥接**:mainPage 把当前语言经 `:locale` prop 透传给子模块的 `App.vue`;后者 `watch` 该 prop,同步到自己的 i18n 实例 `locale`。这样切换语言时,移动游戏页内容跟随父项目响应式切换,文案来自 z449 自有 locale。
|
||||
- 子模块独立运行时不接收 prop,默认中文。
|
||||
- 构建期集成(非 iframe),共享 mainPage 的构建与 KeepAlive 缓存。
|
||||
- 子模块样式已隔离(scoped),全局重置/令牌仅在独立 app 的 `main.js` 加载,嵌入时不加载,复用宿主 `:root` 令牌级联。
|
||||
|
||||
## 克隆(含子模块)
|
||||
|
||||
```bash
|
||||
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||
```
|
||||
|
||||
若已 clone 但未带子模块:
|
||||
|
||||
```bash
|
||||
cd mainPage
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
> 构建前需在子模块目录内 `npm install`(mainPage 的 Vite 编译子模块 `.vue`,但子模块依赖如 `vue-i18n` 须各自安装):
|
||||
|
||||
```bash
|
||||
cd third_party/z449 && npm install
|
||||
```
|
||||
|
||||
## 升级子模块到最新版本
|
||||
|
||||
```bash
|
||||
cd mainPage
|
||||
git submodule update --remote third_party/z449 # 拉取最新
|
||||
cd third_party/z449 && git checkout master # 固定到目标分支/commit
|
||||
cd ../..
|
||||
git add third_party/z449 # 更新 gitlink 指针
|
||||
git commit -m "chore: 升级 z449 子模块"
|
||||
npm run build # 重新构建 mainPage
|
||||
git push # 推送 mainPage(gitlink)
|
||||
```
|
||||
|
||||
> 推送顺序:先确保子模块的 commit 已推到 z449.git,再推 mainPage 的 gitlink,否则别人 clone mainPage 时拉不到子模块对应 commit。
|
||||
|
||||
## 移除子模块集成(仅移除 mainPage 页签,不影响 z449 本身)
|
||||
|
||||
```bash
|
||||
cd mainPage
|
||||
git submodule deinit -f third_party/z449
|
||||
git rm third_party/z449
|
||||
git commit -m "chore: 移除 z449 子模块集成"
|
||||
```
|
||||
|
||||
同时删 `src/modules/mobile-game/` 文件夹即移除页签。z449 独立仓库不受影响。
|
||||
|
||||
> z449 仅作为 submodule 被 mainPage 构建期集成,无独立线上入口。独立开发/测试用其自身的 `npm run dev` / `npm run build`。
|
||||
|
||||
## 与 timeTableFix 子模块的差异
|
||||
|
||||
| 维度 | timeTableFix | z449 |
|
||||
|------|--------------|------|
|
||||
| i18n | 无(硬编码中文) | 自带独立 vue-i18n,中英双语 |
|
||||
| 语言同步 | 无需 | 经 `:locale` prop 桥接父项目语言 |
|
||||
| 文案来源 | 组件内硬编码 | z449 自有 locale 文件(单一来源) |
|
||||
| mainPage 侧文案 | 无 | 仅 `tabs.mobileGame` 页签标题 |
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -18,90 +18,7 @@ export default {
|
||||
openExternal: '在新标签页打开',
|
||||
boardId: '白板 ID',
|
||||
switch: '切换'
|
||||
},
|
||||
mobileGame: {
|
||||
hero: {
|
||||
title: '移动游戏项目',
|
||||
desc: '一个跨学期的 Unity 移动游戏毕设项目:448 开发游戏本体(WebGL + Android APK),449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 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 APK;449 搭建展示网站与基于 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。
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
20
src/modules/mobile-game/MobileGameWrapper.vue
Normal file
20
src/modules/mobile-game/MobileGameWrapper.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -1,8 +0,0 @@
|
||||
// 团队成员(来自 449 展示页)
|
||||
export const team = [
|
||||
{ name: 'Zikai Wang' },
|
||||
{ name: 'Hongxiang He' },
|
||||
{ name: 'Zhuoyue Sun' },
|
||||
{ name: 'Xingchen Zhou' },
|
||||
{ name: 'Yidong Yao' }
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
// 448 版本演进数据(纯结构化数据,文案走 i18n:mobileGame.tracks / mobileGame.versions)
|
||||
//
|
||||
// 项目是多条线并行演进的,且经历了「2D -> 3D 滚球」的形态转变:
|
||||
// - roll-a-ball(3D,featured):项目转向 3D 的最终形态,置顶强调
|
||||
// - zikai-ui(2D):角色控制器,0.0.0 -> 0.0.3
|
||||
// - main(2D):角色控制器,0.0.0 -> 0.0.1
|
||||
// - doby(2D):2D 平台跳跃
|
||||
//
|
||||
// track 字段标识所属演进线;form 标识 2D/3D;order 用于线内排序。
|
||||
// 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 }
|
||||
]
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
1
third_party/z449
vendored
Submodule
1
third_party/z449
vendored
Submodule
Submodule third_party/z449 added at a5d4c05392
Reference in New Issue
Block a user