init: 移动游戏项目独立化(便携式 App.vue + 独立 i18n,可嵌入 mainPage)
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# z449 - 移动游戏项目展示页
|
||||||
|
|
||||||
|
跨学期 Unity 移动游戏毕设(448/449)的展示页:项目概览、团队、玩家排行榜、版本迭代记录、ML-Agents 实验。
|
||||||
|
|
||||||
|
纯前端 Vue 3 应用,独立可渲染与维护,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块(git submodule)构建期集成展示。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- Hero 区:项目元数据(技术栈 / 时间 / 课程)
|
||||||
|
- 概览 + 截图轮播(自动播放)
|
||||||
|
- 团队成员卡片
|
||||||
|
- 玩家分数排行榜(复用 449 现有 PHP 接口 `/449/449rest.php`)
|
||||||
|
- 版本演进时间线(2D -> 3D 形态转变,多线并行)
|
||||||
|
- ML-Agents 强化学习演示视频
|
||||||
|
- 中英双语(中文默认,英文懒加载)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
z449/
|
||||||
|
├── index.html Vite 入口
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js 含 /448 /449 开发代理
|
||||||
|
├── .npmrc npmmirror 镜像加速
|
||||||
|
├── src/
|
||||||
|
│ ├── main.js 独立 app 挂载(i18n + 全局样式)
|
||||||
|
│ ├── App.vue ★ 便携式根组件:接 :locale prop,嵌入与独立两用
|
||||||
|
│ ├── config.js 静态配置(资源路径 / 排行榜接口 / 子标签)
|
||||||
|
│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale(mobileGame.* 文案单一来源)
|
||||||
|
│ ├── components/ Hero / 轮播 / 子标签 / 排行榜 / 时间线 / 实验
|
||||||
|
│ │ └── ui/ PageShell / Tag(自洽 UI 基元)
|
||||||
|
│ ├── data/ 团队成员 / 版本演进数据
|
||||||
|
│ └── styles/ variables.css(令牌)+ base.css(重置,仅独立 app 加载)
|
||||||
|
└── docs/ 设计文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 嵌入 mainPage(i18n 桥接)
|
||||||
|
|
||||||
|
`App.vue` 是便携式组件:
|
||||||
|
|
||||||
|
- **嵌入时**:mainPage 侧包装器 `import` 本项目 `src/App.vue`,把父项目当前语言经 `:locale` prop 传入。`App.vue` 内 `watch` 该 prop,同步到本项目独立的 `vue-i18n` 实例 `locale`,从而跟随父项目语言开关响应式切换。文案来自本项目自有 locale 文件,不共享父项目的 messages。
|
||||||
|
- **独立运行时**:不接收 prop,默认中文;可自管语言切换。
|
||||||
|
|
||||||
|
样式隔离:子组件均为 scoped;全局重置(`variables.css` / `base.css`)仅在独立 app 的 `main.js` 加载,嵌入时不加载,复用宿主 `:root` 令牌级联。
|
||||||
|
|
||||||
|
## Ubuntu 从零安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装 Node.js 18+
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# 2. 克隆并安装
|
||||||
|
git clone https://git.zikai.wang/zikai/z449.git
|
||||||
|
cd z449
|
||||||
|
npm install # 已配 npmmirror 镜像加速
|
||||||
|
|
||||||
|
# 3. 构建生产版本
|
||||||
|
npm run build # 产物输出到 dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
开发模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # http://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
> 排行榜与游戏资源依赖站点根的 `/448` `/449` 静态资源与 `/449/449rest.php` 接口。开发时 `vite.config.js` 已配代理到 `localhost:8080`;生产由 Apache 静态 / PHP 服务提供。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 项 | 选择 |
|
||||||
|
|----|------|
|
||||||
|
| 框架 | Vue 3.4(SFC,`<script setup>`) |
|
||||||
|
| 构建 | Vite 5 |
|
||||||
|
| 国际化 | vue-i18n 9(中文同步,英文懒加载) |
|
||||||
|
| 包源 | npmmirror.com(中国镜像加速) |
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>移动游戏项目</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1194
package-lock.json
generated
Normal file
1194
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "z449",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "移动游戏项目(448/449 毕设)展示页 -- 独立可渲染,亦可作为 mainPage 子模块嵌入",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-i18n": "^9.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/App.vue
Normal file
122
src/App.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup>
|
||||||
|
// ★ 便携式根组件:嵌入 mainPage 与独立运行两用。
|
||||||
|
// i18n:自带独立 vue-i18n 实例(文案在 ./i18n/locales),不共享宿主 messages。
|
||||||
|
// 语言切换:嵌入时父项目经 :locale prop 传入当前语言,watch 同步到本实例;
|
||||||
|
// 独立运行时由本组件内的语言开关 + loadLocaleAsync 自管。
|
||||||
|
import { ref, watch, defineAsyncComponent } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import i18n, { loadLocaleAsync } from './i18n/index.js'
|
||||||
|
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 { subTabs, defaultSubTab } from './config.js'
|
||||||
|
|
||||||
|
// 嵌入时父项目传当前语言;独立运行时默认 'zh-CN'
|
||||||
|
const props = defineProps({
|
||||||
|
locale: { type: String, default: 'zh-CN' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 同步父项目语言 -> 本实例 locale(响应式:父切语言,本组件内容跟随)
|
||||||
|
watch(
|
||||||
|
() => props.locale,
|
||||||
|
async (l) => {
|
||||||
|
if (l && i18n.global.locale.value !== l) {
|
||||||
|
await loadLocaleAsync(l)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
|
||||||
|
const tabs = subTabs
|
||||||
|
const activeTab = ref(defaultSubTab)
|
||||||
|
|
||||||
|
// 按需加载:三个子标签页用 defineAsyncComponent 动态 import,独立 chunk
|
||||||
|
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>
|
||||||
43
src/components/ExperimentTab.vue
Normal file
43
src/components/ExperimentTab.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { assets } from '../config.js'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
</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>
|
||||||
64
src/components/HeroSection.vue
Normal file
64
src/components/HeroSection.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Tag from './ui/Tag.vue'
|
||||||
|
import { heroMeta } from '../config.js'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
</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>
|
||||||
16
src/components/IterationsTab.vue
Normal file
16
src/components/IterationsTab.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import VersionTimeline from './VersionTimeline.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-iterations">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<small>{{ t('mobileGame.iterations.label') }}</small>
|
||||||
|
{{ t('mobileGame.iterations.title') }}
|
||||||
|
</h2>
|
||||||
|
<VersionTimeline />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
128
src/components/Leaderboard.vue
Normal file
128
src/components/Leaderboard.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { leaderboard } from '../config.js'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
|
||||||
|
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>
|
||||||
131
src/components/ScreenshotCarousel.vue
Normal file
131
src/components/ScreenshotCarousel.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { assets } from '../config.js'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
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>
|
||||||
65
src/components/SubTabs.vue
Normal file
65
src/components/SubTabs.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tabs: { type: Array, required: true }, // [{ id, labelKey }]
|
||||||
|
modelValue: { type: String, required: true }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
</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>
|
||||||
55
src/components/TeamCard.vue
Normal file
55
src/components/TeamCard.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<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>
|
||||||
40
src/components/TeamLeaderboardTab.vue
Normal file
40
src/components/TeamLeaderboardTab.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import TeamCard from './TeamCard.vue'
|
||||||
|
import Leaderboard from './Leaderboard.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
</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>
|
||||||
266
src/components/VersionTimeline.vue
Normal file
266
src/components/VersionTimeline.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { versions, tracks } from '../data/versions.js'
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
|
||||||
|
// 按 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>
|
||||||
70
src/components/ui/PageShell.vue
Normal file
70
src/components/ui/PageShell.vue
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 可复用页面外壳 -- 统一页面的容器宽度、垂直节奏与标题。
|
||||||
|
// Props:
|
||||||
|
// titleKey: i18n 键,渲染为页面主标题;不传则无标题
|
||||||
|
// fluid: true 时突破 1080px 限制(全宽 + 左右 padding)
|
||||||
|
// padded: false 时去掉默认上下间距
|
||||||
|
const props = defineProps({
|
||||||
|
titleKey: { type: String, default: '' },
|
||||||
|
fluid: { type: Boolean, default: false },
|
||||||
|
padded: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
const title = computed(() => (props.titleKey ? t(props.titleKey) : ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell" :class="{ 'page-shell--padded': padded }">
|
||||||
|
<div :class="fluid ? 'page-shell__fluid' : 'container page-shell__inner'">
|
||||||
|
<div v-if="title || $slots.actions" class="page-shell__head">
|
||||||
|
<h1 v-if="title" class="page-shell__title">{{ title }}</h1>
|
||||||
|
<div v-if="$slots.actions" class="page-shell__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-shell--padded {
|
||||||
|
padding: var(--space-xl) 0 var(--space-2xl);
|
||||||
|
}
|
||||||
|
.page-shell__inner {
|
||||||
|
/* 宽度由 .container 控制 */
|
||||||
|
}
|
||||||
|
.page-shell__fluid {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.page-shell__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
.page-shell__title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-shell__actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-shell__head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
src/components/ui/Tag.vue
Normal file
25
src/components/ui/Tag.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
tag: { type: String, default: 'span' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="tag" class="tag">
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-weak);
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
src/config.js
Normal file
46
src/config.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// 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'
|
||||||
8
src/data/team.js
Normal file
8
src/data/team.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// 团队成员(来自 449 展示页)
|
||||||
|
export const team = [
|
||||||
|
{ name: 'Zikai Wang' },
|
||||||
|
{ name: 'Hongxiang He' },
|
||||||
|
{ name: 'Zhuoyue Sun' },
|
||||||
|
{ name: 'Xingchen Zhou' },
|
||||||
|
{ name: 'Yidong Yao' }
|
||||||
|
]
|
||||||
48
src/data/versions.js
Normal file
48
src/data/versions.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// 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 }
|
||||||
|
]
|
||||||
46
src/i18n/index.js
Normal file
46
src/i18n/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN.js'
|
||||||
|
|
||||||
|
// ★ 独立 i18n 实例。中文同步注入(默认语言);英文通过动态 import() 懒加载。
|
||||||
|
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入,App.vue 内 watch
|
||||||
|
// 同步到本实例的 locale,从而跟随父项目语言开关切换(不共享父的 messages,
|
||||||
|
// 仅同步 locale 值,文案来自本子项目自有 locale 文件)。
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadedLocales = new Set(['zh-CN'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
|
||||||
|
* @param {string} locale
|
||||||
|
*/
|
||||||
|
export async function loadLocaleAsync(locale) {
|
||||||
|
if (i18n.global.locale.value === locale) return
|
||||||
|
if (loadedLocales.has(locale)) {
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages
|
||||||
|
if (locale === 'en') {
|
||||||
|
messages = (await import('./locales/en.js')).default
|
||||||
|
} else {
|
||||||
|
i18n.global.locale.value = 'zh-CN'
|
||||||
|
document.documentElement.setAttribute('lang', 'zh-CN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.global.mergeLocaleMessage(locale, messages)
|
||||||
|
loadedLocales.add(locale)
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n
|
||||||
93
src/i18n/locales/en.js
Normal file
93
src/i18n/locales/en.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk,
|
||||||
|
// 只看中文的访客不会下载此文件。
|
||||||
|
export default {
|
||||||
|
tabs: {
|
||||||
|
mobileGame: 'Mobile Game Project'
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: 'Loading…'
|
||||||
|
},
|
||||||
|
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}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/i18n/locales/zh-CN.js
Normal file
94
src/i18n/locales/zh-CN.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// 中文语言包(默认语言,同步注入主 chunk,随首屏下发)
|
||||||
|
export default {
|
||||||
|
tabs: {
|
||||||
|
mobileGame: '移动游戏项目'
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: '加载中…'
|
||||||
|
},
|
||||||
|
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}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main.js
Normal file
12
src/main.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp, computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import App from './App.vue'
|
||||||
|
import i18n, { loadLocaleAsync } from './i18n/index.js'
|
||||||
|
import './styles/variables.css'
|
||||||
|
import './styles/base.css'
|
||||||
|
|
||||||
|
// 独立运行入口:挂载独立 i18n 实例与全局样式。
|
||||||
|
// 嵌入 mainPage 时不经过本文件 -- 由 mainPage 侧包装器 import App.vue 作为组件。
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(i18n)
|
||||||
|
app.mount('#app')
|
||||||
91
src/styles/base.css
Normal file
91
src/styles/base.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/* 重置 + 排版基础(仅独立 app 的 main.js 加载;嵌入 mainPage 时不加载,复用宿主样式) */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 15px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 1.9rem; letter-spacing: -0.01em; }
|
||||||
|
h2 { font-size: 1.4rem; letter-spacing: -0.01em; }
|
||||||
|
h3 { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
p { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
|
||||||
|
/* 容器 */
|
||||||
|
.container {
|
||||||
|
max-width: var(--content-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块标题 */
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
.section-title small {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 减弱动效偏好 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/styles/variables.css
Normal file
50
src/styles/variables.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* 浅色极简主题令牌(与 mainPage 对齐,独立运行时由本文件提供;嵌入时复用宿主 :root) */
|
||||||
|
:root {
|
||||||
|
/* 背景/表面 */
|
||||||
|
--bg: #ffffff;
|
||||||
|
--surface: #f7f8fa;
|
||||||
|
--surface-2: #f0f2f5;
|
||||||
|
|
||||||
|
/* 文字 */
|
||||||
|
--text-primary: #1f2329;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--text-tertiary: #9ca3af;
|
||||||
|
|
||||||
|
/* 强调色(单一靛蓝) */
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-weak: #eef2ff;
|
||||||
|
--accent-hover: #4338ca;
|
||||||
|
|
||||||
|
/* 线/边 */
|
||||||
|
--border: #eceef1;
|
||||||
|
--border-strong: #e0e2e6;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
|
||||||
|
/* 柔和阴影 */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||||
|
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||||
|
--shadow-hover: 0 6px 16px rgba(17, 24, 39, 0.08), 0 2px 6px rgba(17, 24, 39, 0.05);
|
||||||
|
|
||||||
|
/* 间距尺度 */
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-2xl: 64px;
|
||||||
|
|
||||||
|
/* 内容宽度 */
|
||||||
|
--content-max: 1080px;
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition: 0.18s ease;
|
||||||
|
}
|
||||||
22
vite.config.js
Normal file
22
vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
// 开发时把站点根的静态资源(448 游戏/截图、449 排行榜接口)代理到本地 8080,
|
||||||
|
// 与 mainPage 同款配置;生产由 Apache 静态/PHP 服务。
|
||||||
|
proxy: {
|
||||||
|
'/448': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/449': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user