refactor: 高内聚低耦合 - 配置集中化与 i18n 统一
结构优化:
- 新增 mobile-game/config.js:集中模块静态配置(资源路径、排行榜端点、
hero meta、子标签页定义),消除散落在各组件的硬编码
- 新增 composables/useLocale.js:共享语言辅助(t/isZh/pick),
消除 6 个组件各自重复定义的 L() helper
- 文案全部迁入 i18n locale(mobileGame 命名空间),消除模板内联 L('中','英')
- 数据文件 versions.js 去除 {zh,en} 对象,文案改由 i18n 键(tracks/versions)取
- 各组件改为读 config + t(),不再直接 useI18n,降低与 i18n 实现的耦合
headless 验证:中文(hero/meta/team/timeline/3D置顶) + 英文切换 + 切回中文 全通过
This commit is contained in:
@@ -1,26 +1,21 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import { assets } from '../config.js'
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
|
||||
// 站点根绝对路径,用绑定避免被 Vite 当作构建期静态资源解析
|
||||
const videoPoster = '/448/2.jpg'
|
||||
const videoSrc = '/448/ml/449ml.mp4'
|
||||
const { t } = useLocale()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-experiment">
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('实验', 'Experiment') }}</small>
|
||||
{{ L('ML-Agents 强化学习演示', 'ML-Agents Reinforcement Learning Demo') }}
|
||||
<small>{{ t('mobileGame.experiment.label') }}</small>
|
||||
{{ t('mobileGame.experiment.title') }}
|
||||
</h2>
|
||||
<div class="video-wrap">
|
||||
<video controls preload="none" :poster="videoPoster">
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
<video controls preload="none" :poster="assets.video.poster">
|
||||
<source :src="assets.video.src" type="video/mp4" />
|
||||
</video>
|
||||
<p class="video-caption">
|
||||
{{ L('胶囊体经 ML-Agents 训练,学习保球不掉与越障。', 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.') }}
|
||||
</p>
|
||||
<p class="video-caption">{{ t('mobileGame.experiment.caption') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
<script setup>
|
||||
import Tag from '../../../components/ui/Tag.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import { heroMeta } from '../config.js'
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
|
||||
const meta = {
|
||||
course: { zh: '迈阿密大学 · 毕设 448 / 449', en: 'Miami University · Capstone 448 / 449' },
|
||||
stack: { zh: 'Unity · C# · WebGL · Android · PHP', en: 'Unity · C# · WebGL · Android · PHP' },
|
||||
period: '2022'
|
||||
}
|
||||
const { t } = useLocale()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero__eyebrow">
|
||||
<Tag>{{ locale === 'zh-CN' ? meta.course.zh : meta.course.en }}</Tag>
|
||||
<Tag>{{ t(heroMeta.courseKey) }}</Tag>
|
||||
</div>
|
||||
<h1 class="hero__title">
|
||||
{{ $t('tabs.mobileGame') }}
|
||||
</h1>
|
||||
<p class="hero__desc">
|
||||
{{ locale === 'zh-CN'
|
||||
? '一个跨学期的 Unity 移动游戏毕设项目:448 开发游戏本体(WebGL + Android APK),449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
|
||||
: 'A cross-semester Unity mobile-game capstone: 448 built the game (WebGL + Android APK), 449 built the showcase site and player leaderboard. Iterated across versions, from a character controller to a gyroscope-controlled 3D rolling-ball game, with ML-Agents reinforcement-learning experiments.' }}
|
||||
</p>
|
||||
<h1 class="hero__title">{{ t('mobileGame.hero.title') }}</h1>
|
||||
<p class="hero__desc">{{ t('mobileGame.hero.desc') }}</p>
|
||||
<div class="hero__meta">
|
||||
<div class="hero__meta-item">
|
||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '技术栈' : 'Stack' }}</span>
|
||||
<span>{{ locale === 'zh-CN' ? meta.stack.zh : meta.stack.en }}</span>
|
||||
</div>
|
||||
<div class="hero__meta-item">
|
||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '时间' : 'Period' }}</span>
|
||||
<span>{{ meta.period }}</span>
|
||||
<div 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>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import VersionTimeline from './VersionTimeline.vue'
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
|
||||
const { t } = useLocale()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-iterations">
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('版本演进', 'Version History') }}</small>
|
||||
{{ L('迭代记录', 'Iteration Record') }}
|
||||
<small>{{ t('mobileGame.iterations.label') }}</small>
|
||||
{{ t('mobileGame.iterations.title') }}
|
||||
</h2>
|
||||
<VersionTimeline />
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import { leaderboard } from '../config.js'
|
||||
|
||||
const { locale, t } = useI18n({ useScope: 'global' })
|
||||
const { t } = useLocale()
|
||||
|
||||
const rows = ref([])
|
||||
const state = ref('loading') // loading | ok | error
|
||||
const errorMsg = ref('')
|
||||
|
||||
// 复用 449 现有后端:POST /449/449rest.php {num} 取分页 JSON
|
||||
// 复用 449 现有后端:POST endpoint {num} 取分页 JSON
|
||||
async function load() {
|
||||
state.value = 'loading'
|
||||
try {
|
||||
const all = []
|
||||
// 拉取 3 页(每页 10 条,共 top30)
|
||||
for (let page = 0; page < 3; page++) {
|
||||
const res = await fetch('/449/449rest.php', {
|
||||
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 * 10}`
|
||||
body: `num=${page * leaderboard.perPage}`
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
@@ -30,12 +30,10 @@ async function load() {
|
||||
}
|
||||
rows.value = all
|
||||
state.value = all.length ? 'ok' : 'error'
|
||||
if (!all.length) errorMsg.value = locale.value === 'zh-CN' ? '暂无分数记录' : 'No scores yet'
|
||||
if (!all.length) errorMsg.value = t('mobileGame.leaderboard.noScores')
|
||||
} catch (e) {
|
||||
state.value = 'error'
|
||||
errorMsg.value = locale.value === 'zh-CN'
|
||||
? '排行榜服务暂不可用'
|
||||
: 'Leaderboard service unavailable'
|
||||
errorMsg.value = t('mobileGame.leaderboard.error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +43,7 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="leaderboard">
|
||||
<div v-if="state === 'loading'" class="leaderboard__state">
|
||||
{{ $t('common.loading') }}
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="state === 'error'" class="leaderboard__state leaderboard__state--error">
|
||||
@@ -55,9 +53,9 @@ onMounted(load)
|
||||
<table v-else class="leaderboard__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ locale === 'zh-CN' ? '玩家' : 'Name' }}</th>
|
||||
<th>{{ locale === 'zh-CN' ? '分数' : 'Score' }}</th>
|
||||
<th>{{ t('mobileGame.leaderboard.colRank') }}</th>
|
||||
<th>{{ t('mobileGame.leaderboard.colName') }}</th>
|
||||
<th>{{ t('mobileGame.leaderboard.colScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { screenshots } from '../data/versions.js'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import { assets } from '../config.js'
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
const { t } = useLocale()
|
||||
const images = assets.screenshots
|
||||
const current = ref(0)
|
||||
let timer = null
|
||||
|
||||
function go(i) {
|
||||
current.value = (i + screenshots.length) % screenshots.length
|
||||
current.value = (i + images.length) % images.length
|
||||
}
|
||||
function next() {
|
||||
go(current.value + 1)
|
||||
@@ -31,8 +32,8 @@ onUnmounted(pause)
|
||||
<div class="carousel" @mouseenter="pause" @mouseleave="resume">
|
||||
<div class="carousel__viewport">
|
||||
<div class="carousel__track" :style="{ transform: `translateX(-${current * 100}%)` }">
|
||||
<figure v-for="(s, i) in screenshots" :key="i" class="carousel__slide">
|
||||
<img :src="s.src" :alt="locale === 'zh-CN' ? s.caption.zh : s.caption.en" loading="lazy" />
|
||||
<figure 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>
|
||||
@@ -42,7 +43,7 @@ onUnmounted(pause)
|
||||
|
||||
<div class="carousel__dots">
|
||||
<button
|
||||
v-for="(s, i) in screenshots"
|
||||
v-for="(src, i) in images"
|
||||
:key="i"
|
||||
class="carousel__dot"
|
||||
:class="{ 'is-active': i === current }"
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
|
||||
const props = defineProps({
|
||||
tabs: { type: Array, required: true }, // [{ id, label: {zh,en} }]
|
||||
tabs: { type: Array, required: true }, // [{ id, labelKey }]
|
||||
modelValue: { type: String, required: true }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
||||
const { t } = useLocale()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="subtabs">
|
||||
<button
|
||||
v-for="t in tabs"
|
||||
:key="t.id"
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="subtabs__btn"
|
||||
:class="{ 'is-active': modelValue === t.id }"
|
||||
@click="emit('update:modelValue', t.id)"
|
||||
:class="{ 'is-active': modelValue === tab.id }"
|
||||
@click="emit('update:modelValue', tab.id)"
|
||||
>
|
||||
{{ L(t.label) }}
|
||||
{{ t(tab.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import TeamCard from './TeamCard.vue'
|
||||
import Leaderboard from './Leaderboard.vue'
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
|
||||
const { t } = useLocale()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tab-team block--two">
|
||||
<div>
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('团队', 'Team') }}</small>
|
||||
{{ L('成员', 'Members') }}
|
||||
<small>{{ t('mobileGame.team.label') }}</small>
|
||||
{{ t('mobileGame.team.title') }}
|
||||
</h2>
|
||||
<TeamCard />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="section-title">
|
||||
<small>{{ L('排行榜', 'Leaderboard') }}</small>
|
||||
{{ L('玩家分数 Top 30', 'Top 30 Scores') }}
|
||||
<small>{{ t('mobileGame.leaderboard.label') }}</small>
|
||||
{{ t('mobileGame.leaderboard.title') }}
|
||||
</h2>
|
||||
<Leaderboard />
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLocale } from '../../../composables/useLocale.js'
|
||||
import { versions, tracks } from '../data/versions.js'
|
||||
|
||||
const { locale } = useI18n({ useScope: 'global' })
|
||||
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
||||
const { t } = useLocale()
|
||||
|
||||
// 按 tracks 数组顺序分组(3D 最终形态在前);同一线内按 order 排序
|
||||
const grouped = computed(() => {
|
||||
return tracks
|
||||
.map((t) => {
|
||||
.map((tr) => {
|
||||
const items = versions
|
||||
.filter((v) => v.track === t.id)
|
||||
.filter((v) => v.track === tr.id)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
return { track: t, items }
|
||||
return { track: tr, items }
|
||||
})
|
||||
.filter((g) => g.items.length > 0)
|
||||
})
|
||||
@@ -23,6 +22,12 @@ 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>
|
||||
@@ -39,10 +44,10 @@ const showPivotAfter = (gi) => gi === 0 && grouped.value.length > 1 && is3D(grou
|
||||
<!-- 线标题 -->
|
||||
<div class="timeline__track-head">
|
||||
<span class="timeline__form" :class="{ 'is-3d': is3D(g.track) }">{{ g.track.form }}</span>
|
||||
<span class="timeline__track-name">{{ L(g.track.name) }}</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">{{ L(g.track.desc) }}</p>
|
||||
<p class="timeline__track-desc">{{ trackDesc(g.track.id) }}</p>
|
||||
|
||||
<!-- 该线下的版本 -->
|
||||
<div class="timeline__items">
|
||||
@@ -61,23 +66,23 @@ const showPivotAfter = (gi) => gi === 0 && grouped.value.length > 1 && is3D(grou
|
||||
<span class="timeline__product">{{ v.product }}</span>
|
||||
</div>
|
||||
<div class="timeline__row">
|
||||
<span class="timeline__label">{{ locale === 'zh-CN' ? '操作' : 'Controls' }}</span>
|
||||
<span>{{ L(v.control) }}</span>
|
||||
<span class="timeline__label">{{ t('mobileGame.iterations.colControls') }}</span>
|
||||
<span>{{ vControl(v.id) }}</span>
|
||||
</div>
|
||||
<div class="timeline__row">
|
||||
<span class="timeline__label">{{ locale === 'zh-CN' ? '说明' : 'Note' }}</span>
|
||||
<span>{{ L(v.note) }}</span>
|
||||
<span class="timeline__label">{{ t('mobileGame.iterations.colNote') }}</span>
|
||||
<span>{{ vNote(v.id) }}</span>
|
||||
</div>
|
||||
<div class="timeline__row">
|
||||
<span class="timeline__label">Unity</span>
|
||||
<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">
|
||||
{{ locale === 'zh-CN' ? 'WebGL 试玩' : 'Play WebGL' }} ↗
|
||||
{{ t('mobileGame.iterations.playWebgl') }} ↗
|
||||
</a>
|
||||
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
|
||||
{{ locale === 'zh-CN' ? '下载 APK' : 'Download APK' }} ↗
|
||||
{{ t('mobileGame.iterations.downloadApk') }} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,9 +92,7 @@ const showPivotAfter = (gi) => gi === 0 && grouped.value.length > 1 && is3D(grou
|
||||
<!-- 形态转变标记:3D 线之后、2D 线之前 -->
|
||||
<div v-if="showPivotAfter(gi)" class="timeline__pivot">
|
||||
<span class="timeline__pivot-line"></span>
|
||||
<span class="timeline__pivot-text">
|
||||
{{ locale === 'zh-CN' ? '早期并行演进:2D 线' : 'Earlier parallel lines: 2D' }}
|
||||
</span>
|
||||
<span class="timeline__pivot-text">{{ t('mobileGame.iterations.pivotText') }}</span>
|
||||
<span class="timeline__pivot-line"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user