[verified] feat: launch Star Seed Vue game

This commit is contained in:
kimsongwu
2026-07-28 20:42:25 +08:00
commit 78260e3053
22 changed files with 3868 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
coverage/
.vite/
*.log
.DS_Store
.env
.env.*
!.env.example
# dist/ is intentionally committed: dist/index.html is the standalone release.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kimsongwu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82
View File
@@ -0,0 +1,82 @@
# 星种:长夜之前
一款基于 Vue 3 的单机文明演化增量游戏。从无名潮池中的复制分子出发,跨越陆冠、炉城与星种四个纪元,在暮阳风暴抵达前完成生命档案、磁场天幕与星种方舟。
![游戏画面](docs/star-seed-game.png)
## 直接游玩
无需安装、无需后端、无需联网:
1. 下载仓库;
2. 直接双击 `dist/index.html`
3. 游戏会在浏览器中运行。
`dist/index.html` 是 Vite 编译后的单文件发行版,JavaScript 与 CSS 均已内联。它已通过 `file://` 方式实际加载和点击验证。
## 核心内容
- 四个线性纪元:潮池、陆冠、炉城、星种;
- 12 个科学与文明里程碑;
- 点击采集与自动增长组成的增量循环;
- 本地自动存档与最多四小时离线收益;
- 生态平衡、生命档案、磁场天幕三项结局指标;
- 唯一正式结局“ 两片海的黎明 ”与新世界播种循环;
- 桌面端与移动端自适应界面;
- 无图片、字体、接口等运行时网络依赖。
## Vue、Bit 与动画组件
项目参考 [Bit 的 Vue 组件开发说明](https://bit.dev/docs/vue-intro),将界面拆分为职责单一、输入输出明确的 Vue 单文件组件:
- `EvolutionScene.vue`:行星、轨道、星空与主点击交互;
- `MilestoneRail.vue`:纪元里程碑、资源门槛与完成状态;
- `SignalLog.vue`:文明叙事信号流;
- `EndingOverlay.vue`:最终发射结局。
动画层使用 `motion-v`,为组件提供弹簧入场、手势按压、阶段切换和结局转场;背景呼吸、极光、扫描、城市灯光与发射轨迹使用本地 CSS 动画。组件边界及 Bit 跟踪建议见 `docs/bit-components.md`
## 本地开发
要求 Node.js 20.19+ 或 22.12+。
```bash
npm install
npm run dev
```
质量检查与发行构建:
```bash
npm test
npm run build
npm run verify:dist
npm audit
```
构建会重新生成可直接打开的 `dist/index.html`
## 项目结构
```text
star-seed/
├── dist/index.html # 已编译、自包含、可直接运行的游戏入口
├── docs/ # 组件说明与游戏截图
├── src/components/ # Bit 风格的独立 Vue 动画组件
├── src/game/gameState.js # 纯函数游戏状态与规则
├── src/App.vue # 应用编排、存档与游戏循环
├── src/styles.css # 科幻视觉系统与响应式布局
└── src/**/*.test.js # 状态与界面测试
```
## 技术栈
- Vue 3.5
- Vite 8
- Motion for Vue (`motion-v`)
- Vitest + Vue Test Utils
- Vite SingleFile
## 许可
MIT
+25
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
# Bit-ready Vue 动画组件
本项目按照 Bit 文档所强调的“独立、可组合组件”方式组织视觉层,同时保留标准 Vite 构建,以确保下载后可以直接运行。
## 组件边界
### EvolutionScene
- 输入:当前纪元、资源数、点击强度、自动产率、发射状态;
- 输出:`pulse` 事件;
- 动画:Motion 弹簧入场和按压反馈;CSS 行星呼吸、城市灯光、星种发射轨迹;
- 不读取存档,不直接修改游戏状态。
### MilestoneRail
- 输入:纪元配置、资源数、已完成里程碑;
- 输出:`purchase(id)` 事件;
- 动画:Motion 交错入场和悬停位移;
- 所有购买合法性仍由 `gameState.js` 判定。
### SignalLog
- 输入:叙事信号数组;
- 动画:新信号淡入;
- 无全局状态依赖。
### EndingOverlay
- 输入:结局等级;
- 输出:`restart` 事件;
- 动画:全屏结局转场;
- 不负责创建下一轮状态。
## 为什么保持 Vite 主构建
Bit 可用于后续独立跟踪、预览和发布这些组件;游戏发行仍使用 Vite SingleFile,把全部组件、动画和样式内联到 `dist/index.html`。因此玩家不需要 Bit CLI、Node.js 或网络连接。
若团队后续采用 Bit,可在现有项目执行官方文档中的 `bit init`,再将 `src/components` 下的组件按各自目录边界跟踪。游戏规则层与发行构建无需改变。
Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#03070b" />
<meta name="description" content="《星种:长夜之前》— 从无名之海到星际方舟的文明演化增量游戏。" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%2303070b'/%3E%3Cpath d='M32 7l6 19 19 6-19 6-6 19-6-19-19-6 19-6z' fill='%2358f3c4'/%3E%3C/svg%3E" />
<title>星种:长夜之前</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2220
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "star-seed",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"test": "vitest run",
"test:watch": "vitest",
"verify:dist": "node tests/verify-dist.mjs"
},
"dependencies": {
"motion-v": "^2.3.0",
"vue": "^3.5.40"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.2.7",
"jsdom": "^30.0.0",
"vite": "^8.1.5",
"vite-plugin-singlefile": "^2.3.3",
"vitest": "^4.1.10"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import App from './App.vue'
describe('星种游戏界面', () => {
it('展示当前纪元,并允许玩家采集第一份生命质', async () => {
const wrapper = mount(App, {
global: {
stubs: {
AnimatePresence: { template: '<div><slot /></div>' },
},
},
})
expect(wrapper.text()).toContain('星种:长夜之前')
expect(wrapper.text()).toContain('潮池纪元')
expect(wrapper.text()).toContain('消耗 12')
expect(wrapper.get('.epoch-button').text()).toContain('还需 3 项')
expect(wrapper.get('[data-test="essence"]').text()).toContain('0')
await wrapper.get('[data-test="pulse"]').trigger('click')
expect(wrapper.get('[data-test="essence"]').text()).toContain('1')
wrapper.unmount()
})
})
+322
View File
@@ -0,0 +1,322 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AnimatePresence, motion } from 'motion-v'
import EndingOverlay from './components/EndingOverlay.vue'
import EvolutionScene from './components/EvolutionScene.vue'
import MilestoneRail from './components/MilestoneRail.vue'
import SignalLog from './components/SignalLog.vue'
import {
PHASES,
advancePhase,
buyMilestone,
canAdvance,
collectPulse,
createInitialState,
elapsedSeconds,
isReadyToLaunch,
launchSeed,
restoreGame,
serializeGame,
tick,
} from './game/gameState.js'
const STORAGE_KEY = 'star-seed-save-v1'
const game = ref(createInitialState())
const transitionMessage = ref('')
const saveStatus = ref('本地自动存档')
const offlineNotice = ref('')
let loopTimer
let autosaveTimer
let queuedSaveTimer
let transitionTimer
let noticeTimer
let lastTick = Date.now()
const phase = computed(() => PHASES[game.value.phase])
const completedInPhase = computed(() => phase.value.milestones.filter((item) => game.value.completedMilestones.includes(item.id)).length)
const overallProgress = computed(() => {
const completed = game.value.completedMilestones.length
return Math.round((completed / PHASES.flatMap((item) => item.milestones).length) * 100)
})
const canEnterNext = computed(() => canAdvance(game.value))
const canLaunch = computed(() => isReadyToLaunch(game.value))
const accentStyle = computed(() => ({
'--accent': phase.value.accent,
'--phase-progress': `${overallProgress.value}%`,
}))
const essenceLabel = computed(() => Math.floor(game.value.essence).toLocaleString('zh-CN'))
function queueSave() {
if (queuedSaveTimer) return
saveStatus.value = '等待本地存档'
queuedSaveTimer = window.setTimeout(() => {
queuedSaveTimer = undefined
save()
}, 800)
}
function replaceState(next, persist = true) {
if (next === game.value) return
game.value = next
if (persist) queueSave()
}
function pulse() {
replaceState(collectPulse(game.value))
}
function purchase(id) {
replaceState(buyMilestone(game.value, id))
}
function enterNextPhase() {
const next = advancePhase(game.value)
if (next === game.value) return
replaceState(next)
transitionMessage.value = PHASES[next.phase].name
window.clearTimeout(transitionTimer)
transitionTimer = window.setTimeout(() => { transitionMessage.value = '' }, 1500)
}
function launch() {
replaceState(launchSeed(game.value))
}
function restartWorld() {
const previous = game.value
const next = createInitialState()
next.seedCount = previous.seedCount
next.insight = previous.insight
next.clickPower = 1 + previous.seedCount * 2
next.autoRate = previous.seedCount * 0.5
next.storyLog = ['星种抵达新潮。生命记忆正在一片新的海里展开。']
replaceState(next)
}
function hardReset() {
if (typeof window !== 'undefined' && !window.confirm('确认清除这颗世界的全部进度?')) return
try { localStorage.removeItem(STORAGE_KEY) } catch { /* storage may be unavailable */ }
replaceState(createInitialState())
}
function save() {
try {
localStorage.setItem(STORAGE_KEY, serializeGame(game.value))
saveStatus.value = '已保存于本机'
} catch {
saveStatus.value = '存档不可用'
}
}
function load() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return
game.value = restoreGame(stored)
if (game.value.offlineSeconds >= 5 && game.value.autoRate > 0) {
offlineNotice.value = `离线 ${Math.floor(game.value.offlineSeconds)} 秒,潮声为你积累了 ${Math.floor(game.value.offlineSeconds * game.value.autoRate).toLocaleString('zh-CN')} ${phase.value.resourceName}`
noticeTimer = window.setTimeout(() => { offlineNotice.value = '' }, 6000)
}
} catch {
saveStatus.value = '存档读取失败'
}
}
function completeCurrentPhaseForTest() {
let next = game.value
for (const milestone of PHASES[next.phase].milestones) {
next = buyMilestone({ ...next, essence: milestone.cost }, milestone.id)
}
game.value = next
}
function saveWhenHidden() {
if (document.visibilityState === 'hidden') save()
}
onMounted(() => {
load()
lastTick = Date.now()
loopTimer = window.setInterval(() => {
const now = Date.now()
const elapsed = elapsedSeconds(lastTick, now)
lastTick = now
replaceState(tick(game.value, elapsed, now), false)
}, 250)
autosaveTimer = window.setInterval(save, 5000)
document.addEventListener('visibilitychange', saveWhenHidden)
if (import.meta.env.DEV) {
window.__STAR_SEED_TEST__ = {
getState: () => JSON.parse(JSON.stringify(game.value)),
grant: (amount = 10000) => { game.value = { ...game.value, essence: amount } },
completeCurrentPhase: completeCurrentPhaseForTest,
advance: enterNextPhase,
launch,
completeJourney: () => {
while (game.value.phase < PHASES.length - 1) {
completeCurrentPhaseForTest()
game.value = advancePhase(game.value)
}
completeCurrentPhaseForTest()
launch()
},
}
}
})
onBeforeUnmount(() => {
window.clearInterval(loopTimer)
window.clearInterval(autosaveTimer)
window.clearTimeout(queuedSaveTimer)
window.clearTimeout(transitionTimer)
window.clearTimeout(noticeTimer)
document.removeEventListener('visibilitychange', saveWhenHidden)
save()
if (import.meta.env.DEV && typeof window !== 'undefined') delete window.__STAR_SEED_TEST__
})
</script>
<template>
<main class="game-shell" :style="accentStyle">
<div class="aurora aurora--one" aria-hidden="true" />
<div class="aurora aurora--two" aria-hidden="true" />
<div class="noise" aria-hidden="true" />
<header class="mission-header">
<a class="wordmark" href="#game" aria-label="星种首页">
<span class="wordmark__mark"></span>
<span><strong>星种</strong><small>STAR / SEED</small></span>
</a>
<div class="mission-title">
<span>{{ phase.chapter }}</span>
<h1>星种长夜之前</h1>
</div>
<div class="mission-meta">
<span class="save-indicator"><i />{{ saveStatus }}</span>
<button type="button" class="text-button" @click="hardReset">重置世界</button>
</div>
</header>
<div class="mission-progress" aria-label="总进度">
<span :style="{ width: `${overallProgress}%` }" />
</div>
<section id="game" class="game-grid">
<div class="game-grid__scene">
<motion.div
class="chapter-copy"
:key="phase.id"
:initial="{ opacity: 0, y: 16 }"
:animate="{ opacity: 1, y: 0 }"
:transition="{ duration: 0.65 }"
>
<span class="micro-label">{{ phase.chapter }} · {{ phase.name }}</span>
<h2>{{ phase.headline }}</h2>
<p>{{ phase.story }}</p>
</motion.div>
<EvolutionScene
:phase="phase"
:phase-index="game.phase"
:essence="game.essence"
:click-power="game.clickPower"
:auto-rate="game.autoRate"
:launched="game.launched"
@pulse="pulse"
/>
</div>
<aside class="command-panel">
<div class="resource-strip">
<div>
<span>{{ phase.resourceName }}</span>
<strong data-test="essence">{{ essenceLabel }}</strong>
</div>
<div>
<span>文明洞见</span>
<strong>{{ game.insight }}</strong>
</div>
<div>
<span>生态平衡</span>
<strong>{{ Math.round(game.balance) }}%</strong>
</div>
</div>
<MilestoneRail
:key="phase.id"
:phase="phase"
:essence="game.essence"
:completed="game.completedMilestones"
@purchase="purchase"
/>
<div v-if="game.phase === 3" class="final-meters">
<div><span>生命档案</span><i><b :style="{ width: `${game.archive}%` }" /></i><strong>{{ game.archive }}%</strong></div>
<div><span>磁场天幕</span><i><b :style="{ width: `${game.shield}%` }" /></i><strong>{{ game.shield }}%</strong></div>
</div>
<motion.button
v-if="game.phase < PHASES.length - 1"
class="epoch-button"
type="button"
:disabled="!canEnterNext"
:while-hover="canEnterNext ? { scale: 1.01 } : {}"
:while-press="canEnterNext ? { scale: 0.98 } : {}"
@click="enterNextPhase"
>
<span class="epoch-button__copy">
<b>进入下一纪元</b>
<small v-if="!canEnterNext">还需 {{ phase.milestones.length - completedInPhase }} 项里程碑</small>
</span>
<strong></strong>
</motion.button>
<motion.button
v-else
class="epoch-button epoch-button--launch"
type="button"
:disabled="!canLaunch"
:while-hover="canLaunch ? { scale: 1.01 } : {}"
:while-press="canLaunch ? { scale: 0.98 } : {}"
@click="launch"
>
<span>发射星种方舟</span><strong></strong>
</motion.button>
<SignalLog :log="game.storyLog" />
</aside>
</section>
<footer class="mission-footer">
<span>XIYUAN BIOSPHERE / BUILD 1.0</span>
<span>{{ completedInPhase }} / {{ phase.milestones.length }} 当前纪元节点</span>
<span>进化总进度 {{ overallProgress }}%</span>
</footer>
<AnimatePresence>
<motion.div
v-if="transitionMessage"
key="transition"
class="phase-transition"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
>
<motion.div :initial="{ scaleX: 0 }" :animate="{ scaleX: 1 }" :transition="{ duration: 0.7 }" />
<span>NEW EPOCH</span>
<strong>{{ transitionMessage }}</strong>
</motion.div>
</AnimatePresence>
<AnimatePresence>
<EndingOverlay v-if="game.launched" key="ending" :grade="game.endingGrade" @restart="restartWorld" />
</AnimatePresence>
<motion.div
v-if="offlineNotice"
class="offline-notice"
:initial="{ opacity: 0, y: 20 }"
:animate="{ opacity: 1, y: 0 }"
>{{ offlineNotice }}</motion.div>
</main>
</template>
+19
View File
@@ -0,0 +1,19 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import { describe, expect, it } from 'vitest'
import EndingOverlay from './EndingOverlay.vue'
describe('结局对话框', () => {
it('出现时把键盘焦点移到唯一操作,并在 Tab 时保持焦点', async () => {
const wrapper = mount(EndingOverlay, { props: { grade: '双生花园' }, attachTo: document.body })
await nextTick()
const restart = wrapper.get('button').element
expect(document.activeElement).toBe(restart)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(document.activeElement).toBe(restart)
wrapper.unmount()
})
})
+49
View File
@@ -0,0 +1,49 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { motion } from 'motion-v'
const props = defineProps({
grade: { type: String, default: '' },
})
const emit = defineEmits(['restart'])
const restartButton = ref(null)
let previousFocus
function keepDialogFocus(event) {
if (event.key !== 'Tab') return
event.preventDefault()
restartButton.value?.focus()
}
onMounted(async () => {
previousFocus = document.activeElement
await nextTick()
restartButton.value?.focus()
document.addEventListener('keydown', keepDialogFocus)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', keepDialogFocus)
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus()
})
</script>
<template>
<div class="ending" role="dialog" aria-modal="true" aria-labelledby="ending-title">
<div class="ending__stars" aria-hidden="true" />
<motion.div
class="ending__content"
:initial="{ opacity: 0, y: 40 }"
:animate="{ opacity: 1, y: 0 }"
:transition="{ duration: 1.1 }"
>
<span class="micro-label">MISSION COMPLETE · {{ props.grade }}</span>
<h2 id="ending-title">两片海的黎明</h2>
<p>在另一片没有名字的海里有东西第一次记住了自己</p>
<div class="ending__line" />
<p class="ending__epilogue">跨越群星之后你又听见了潮声汐原仍在风暴中呼吸而新潮的第一枚细胞正在分裂</p>
<button ref="restartButton" type="button" class="ghost-button" @click="emit('restart')">播种下一颗世界</button>
</motion.div>
</div>
</template>
+82
View File
@@ -0,0 +1,82 @@
<script setup>
import { computed } from 'vue'
import { motion } from 'motion-v'
const props = defineProps({
phase: { type: Object, required: true },
phaseIndex: { type: Number, required: true },
essence: { type: Number, required: true },
clickPower: { type: Number, required: true },
autoRate: { type: Number, required: true },
launched: { type: Boolean, default: false },
})
const emit = defineEmits(['pulse'])
const displayEssence = computed(() => Math.floor(props.essence).toLocaleString('zh-CN'))
const starPoints = Array.from({ length: 34 }, (_, index) => ({
left: `${(index * 47 + 11) % 100}%`,
top: `${(index * 31 + 7) % 92}%`,
size: `${1 + (index % 3) * 0.6}px`,
delay: `${(index % 9) * -0.7}s`,
}))
</script>
<template>
<section class="evolution-scene" :aria-label="`${phase.name} 行星视图`">
<div class="star-field" aria-hidden="true">
<i
v-for="(star, index) in starPoints"
:key="index"
:style="{ left: star.left, top: star.top, width: star.size, height: star.size, animationDelay: star.delay }"
/>
</div>
<div class="orbit orbit--wide" aria-hidden="true"><span /></div>
<div class="orbit orbit--tight" aria-hidden="true"><span /></div>
<div class="scan-line" aria-hidden="true" />
<motion.div
class="planet-stage"
:initial="{ opacity: 0, scale: 0.72 }"
:animate="{ opacity: 1, scale: 1, rotate: phaseIndex * 4 }"
:transition="{ type: 'spring', stiffness: 75, damping: 18 }"
>
<div class="planet-glow" />
<div class="planet" :class="`planet--${phase.id}`">
<div class="planet-atmosphere" />
<div class="planet-grid" />
<div class="planet-continent continent-a" />
<div class="planet-continent continent-b" />
<div v-if="phaseIndex >= 2" class="city-lights" />
<div v-if="phaseIndex === 3" class="launch-trail" />
</div>
</motion.div>
<div class="scene-label scene-label--left">
<span>BIOSPHERE STATUS</span>
<strong>{{ phaseIndex === 0 ? 'CHEMICAL DAWN' : phaseIndex === 1 ? 'LAND AWAKENED' : phaseIndex === 2 ? 'CIVIL SIGNAL' : 'LAUNCH WINDOW' }}</strong>
</div>
<div class="scene-label scene-label--right">
<span>AUTONOMOUS YIELD</span>
<strong>+{{ autoRate.toFixed(1) }} / S</strong>
</div>
<motion.button
class="pulse-core"
data-test="pulse"
type="button"
:aria-label="`采集${phase.resourceName}`"
:disabled="launched"
:while-hover="{ scale: 1.04 }"
:while-press="{ scale: 0.91 }"
@click="emit('pulse')"
>
<span class="pulse-core__halo" aria-hidden="true" />
<span class="pulse-core__eyebrow">{{ phase.resourceName }}</span>
<strong>{{ displayEssence }}</strong>
<span class="pulse-core__action">点击汇聚 +{{ clickPower }}</span>
</motion.button>
<div class="scene-coordinate" aria-hidden="true">31°14 N · 121°28 E / 汐原观测面</div>
</section>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup>
import { computed } from 'vue'
import { motion } from 'motion-v'
const props = defineProps({
phase: { type: Object, required: true },
essence: { type: Number, required: true },
completed: { type: Array, required: true },
})
const emit = defineEmits(['purchase'])
const completedCount = computed(() => props.phase.milestones.filter((item) => props.completed.includes(item.id)).length)
const progress = computed(() => `${(completedCount.value / props.phase.milestones.length) * 100}%`)
</script>
<template>
<section class="milestone-rail" aria-labelledby="milestone-title">
<header class="rail-header">
<div>
<span class="micro-label">EVOLUTION SEQUENCE</span>
<h2 id="milestone-title">纪元里程碑</h2>
</div>
<span class="rail-count">0{{ completedCount }} / 0{{ phase.milestones.length }}</span>
</header>
<div class="rail-progress" aria-hidden="true"><span :style="{ width: progress }" /></div>
<div class="milestone-list">
<motion.button
v-for="(milestone, index) in phase.milestones"
:key="milestone.id"
class="milestone"
:class="{ 'is-complete': completed.includes(milestone.id) }"
type="button"
:disabled="completed.includes(milestone.id) || essence < milestone.cost"
:data-test="`milestone-${milestone.id}`"
:initial="{ opacity: 0, x: 24 }"
:animate="{ opacity: 1, x: 0 }"
:transition="{ delay: index * 0.09, duration: 0.45 }"
:while-hover="completed.includes(milestone.id) ? {} : { x: -5 }"
@click="emit('purchase', milestone.id)"
>
<span class="milestone__index">0{{ index + 1 }}</span>
<span class="milestone__copy">
<strong>{{ milestone.name }}</strong>
<small>{{ milestone.description }}</small>
</span>
<span class="milestone__cost">
<template v-if="completed.includes(milestone.id)">已完成</template>
<template v-else>消耗 {{ milestone.cost.toLocaleString('zh-CN') }}</template>
</span>
</motion.button>
</div>
</section>
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
import { motion } from 'motion-v'
defineProps({
log: { type: Array, required: true },
})
</script>
<template>
<section class="signal-log" aria-labelledby="signal-title">
<div class="signal-log__head">
<span class="signal-dot" aria-hidden="true" />
<span id="signal-title">生命信号 / LIVE FEED</span>
</div>
<div class="signal-log__entries">
<motion.p
v-for="(entry, index) in log.slice(-3)"
:key="`${entry}-${index}`"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: index === log.slice(-3).length - 1 ? 1 : 0.45, y: 0 }"
>
<span>{{ String(index + 1).padStart(2, '0') }}</span>{{ entry }}
</motion.p>
</div>
</section>
</template>
+245
View File
@@ -0,0 +1,245 @@
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
export const PHASES = [
{
id: 'tidepool',
name: '潮池纪元',
chapter: 'CHAPTER 01',
resourceName: '生命质',
glyph: '◌',
accent: '#58f3c4',
headline: '在无名之海,学会记住自己',
story: '雷暴越过汐原的海面。某些分子开始复制,而你在复制之间醒来。',
transition: '陆冠纪元开始:生命第一次离开海洋。',
milestones: [
{ id: 'membrane', name: '脂质膜', description: '为脆弱的化学反应围起第一道边界。', cost: 12, effect: { auto: 0.45, insight: 1 }, signal: '边界诞生,内与外第一次有了意义。' },
{ id: 'photosynthesis', name: '光合作用', description: '把暮阳的光转译成稳定能量。', cost: 38, effect: { click: 2, auto: 0.8, balance: 5, insight: 1 }, signal: '海水泛起绿色,天空开始改变颜色。' },
{ id: 'nucleus', name: '遗传核心', description: '保存更长的记忆,允许生命变得复杂。', cost: 90, effect: { auto: 2.2, insight: 2 }, signal: '无数偶然被写成可以继承的秩序。' },
],
},
{
id: 'landcrown',
name: '陆冠纪元',
chapter: 'CHAPTER 02',
resourceName: '生境力',
glyph: '⌁',
accent: '#b8f36b',
headline: '让海的记忆覆盖大陆',
story: '风吹过尚未命名的大陆。生命带着海盐,缓慢爬向干燥的光。',
transition: '炉城纪元开始:智慧生命点燃了第一簇火。',
milestones: [
{ id: 'roots', name: '根系网络', description: '植物固定土壤,为整个陆地生态供能。', cost: 70, effect: { auto: 3.5, balance: 8, insight: 2 }, signal: '绿色沿河谷蔓延,大陆拥有了呼吸。' },
{ id: 'symmetry', name: '灵巧肢体', description: '感知、移动与协作带来新的生存方式。', cost: 180, effect: { click: 4, auto: 4.5, insight: 2 }, signal: '生命不再只等待环境,而开始寻找环境。' },
{ id: 'sentience', name: '群体心智', description: '记忆跨越个体,语言跨越世代。', cost: 420, effect: { auto: 7, balance: 4, insight: 4 }, signal: '它们第一次抬头观察星空,并提出了为什么。' },
],
},
{
id: 'forgecity',
name: '炉城纪元',
chapter: 'CHAPTER 03',
resourceName: '文明火种',
glyph: '⌬',
accent: '#ffb45b',
headline: '火焰照亮城市,也照见代价',
story: '城市沿海岸升起。天文学家同时发现:暮阳正在变得不再稳定。',
transition: '星种纪元开始:整个文明把目光投向大气层之外。',
milestones: [
{ id: 'agriculture', name: '共生农业', description: '让聚落增长,而不是透支脚下的土壤。', cost: 280, effect: { auto: 15, balance: 12, insight: 4 }, signal: '文明学会收获,也学会留下下一季的种子。' },
{ id: 'observatory', name: '暮阳观测阵列', description: '确认超级耀斑将在八百年内剥离大气。', cost: 700, effect: { click: 10, auto: 20, insight: 6 }, signal: '长夜被算出了日期,世界第一次拥有共同的倒计时。' },
{ id: 'covenant', name: '行星公约', description: '守园者与远航者共同签署星种计划。', cost: 1500, effect: { auto: 35, balance: 10, insight: 8 }, signal: '延续生命与守护故乡,不再是两条相反的道路。' },
],
},
{
id: 'starseed',
name: '星种纪元',
chapter: 'CHAPTER 04',
resourceName: '星航能量',
glyph: '✦',
accent: '#77b8ff',
headline: '在长夜之前,把潮声送往群星',
story: '轨道船坞进入最后施工期。暮阳的第一轮风暴已经抵达外层行星。',
transition: '',
milestones: [
{ id: 'archive', name: '生命档案', description: '保存微生物、种子、胚胎与文明记忆。', cost: 1100, effect: { auto: 55, archive: 45, insight: 8 }, signal: '一整颗行星的生命,被浓缩成可以远航的花园。' },
{ id: 'canopy', name: '磁场天幕', description: '为汐原抵挡恒星风,给故乡留下未来。', cost: 2500, effect: { auto: 90, shield: 65, balance: 15, insight: 10 }, signal: '人造极光覆盖天空,故乡在风暴中继续呼吸。' },
{ id: 'ark', name: '星种方舟', description: '完成驶向新潮的最后一艘生态方舟。', cost: 5200, effect: { auto: 140, archive: 55, shield: 35, insight: 14 }, signal: '倒计时开始。另一片海正在航线尽头等待。' },
],
},
]
const ALL_MILESTONE_IDS = new Set(PHASES.flatMap((phase) => phase.milestones.map((milestone) => milestone.id)))
function safeNumber(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER, integer = false) {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
const normalized = integer ? Math.floor(value) : value
return clamp(normalized, min, max)
}
export function createInitialState(now = Date.now()) {
return {
version: 1,
phase: 0,
essence: 0,
clickPower: 1,
autoRate: 0,
insight: 0,
balance: 50,
shield: 0,
archive: 0,
completedMilestones: [],
storyLog: ['潮声第一次被听见。'],
launched: false,
seedCount: 0,
lastUpdated: now,
offlineSeconds: 0,
}
}
export function collectPulse(state) {
if (state.launched) return state
return {
...state,
essence: state.essence + state.clickPower,
}
}
export function buyMilestone(state, milestoneId) {
const milestone = PHASES[state.phase]?.milestones.find((item) => item.id === milestoneId)
if (!milestone || state.completedMilestones.includes(milestoneId) || state.essence < milestone.cost) {
return state
}
const effect = milestone.effect
return {
...state,
essence: state.essence - milestone.cost,
clickPower: state.clickPower + (effect.click ?? 0),
autoRate: state.autoRate + (effect.auto ?? 0),
insight: state.insight + (effect.insight ?? 0),
balance: clamp(state.balance + (effect.balance ?? 0), 0, 100),
shield: clamp(state.shield + (effect.shield ?? 0), 0, 100),
archive: clamp(state.archive + (effect.archive ?? 0), 0, 100),
completedMilestones: [...state.completedMilestones, milestoneId],
storyLog: [...state.storyLog, milestone.signal].slice(-8),
}
}
export function canAdvance(state) {
const phase = PHASES[state.phase]
return Boolean(
phase
&& state.phase < PHASES.length - 1
&& phase.milestones.every((milestone) => state.completedMilestones.includes(milestone.id)),
)
}
export function advancePhase(state) {
if (!canAdvance(state)) return state
const current = PHASES[state.phase]
return {
...state,
phase: state.phase + 1,
essence: 0,
storyLog: [...state.storyLog, current.transition].slice(-8),
}
}
export function elapsedSeconds(from, to) {
if (!Number.isFinite(from) || !Number.isFinite(to)) return 0
return clamp((to - from) / 1000, 0, 14_400)
}
export function tick(state, seconds, now = Date.now()) {
const elapsed = Math.max(0, Number.isFinite(seconds) ? seconds : 0)
if (state.launched) return state
const essence = safeNumber(state.essence, 0)
const autoRate = safeNumber(state.autoRate, 0)
return {
...state,
essence: essence + autoRate * elapsed,
lastUpdated: now,
}
}
export function applyOfflineProgress(state, now = Date.now()) {
if (state.launched) return state
const elapsed = elapsedSeconds(state.lastUpdated, now)
return {
...tick(state, elapsed, now),
offlineSeconds: elapsed,
}
}
export function isReadyToLaunch(state) {
const finalPhase = PHASES.at(-1)
return Boolean(
state.phase === PHASES.length - 1
&& !state.launched
&& finalPhase.milestones.every((milestone) => state.completedMilestones.includes(milestone.id)),
)
}
export function launchSeed(state) {
if (!isReadyToLaunch(state)) return state
const endingGrade = state.balance >= 80 && state.shield === 100 && state.archive === 100
? '双生花园'
: state.shield >= 80
? '故乡守望'
: '远航火种'
return {
...state,
launched: true,
seedCount: state.seedCount + 1,
endingTitle: '两片海的黎明',
endingGrade,
storyLog: [...state.storyLog, '另一片没有名字的海里,有东西第一次记住了自己。'].slice(-8),
}
}
export function serializeGame(state) {
return JSON.stringify({ ...state, lastUpdated: Date.now() })
}
export function restoreGame(serialized, now = Date.now()) {
try {
const parsed = JSON.parse(serialized)
if (!parsed || parsed.version !== 1 || !Number.isInteger(parsed.phase)) {
return createInitialState(now)
}
const initial = createInitialState(now)
const completedMilestones = Array.isArray(parsed.completedMilestones)
? [...new Set(parsed.completedMilestones.filter((id) => typeof id === 'string' && ALL_MILESTONE_IDS.has(id)))]
: []
const storyLog = Array.isArray(parsed.storyLog)
? parsed.storyLog.filter((entry) => typeof entry === 'string' && entry.trim()).slice(-8)
: []
const phase = clamp(parsed.phase, 0, PHASES.length - 1)
const launched = parsed.launched === true
&& phase === PHASES.length - 1
&& PHASES.at(-1).milestones.every((milestone) => completedMilestones.includes(milestone.id))
const normalized = {
...initial,
phase,
essence: safeNumber(parsed.essence, initial.essence),
clickPower: safeNumber(parsed.clickPower, initial.clickPower, 1),
autoRate: safeNumber(parsed.autoRate, initial.autoRate),
insight: safeNumber(parsed.insight, initial.insight, 0, Number.MAX_SAFE_INTEGER, true),
balance: safeNumber(parsed.balance, initial.balance, 0, 100),
shield: safeNumber(parsed.shield, initial.shield, 0, 100),
archive: safeNumber(parsed.archive, initial.archive, 0, 100),
seedCount: safeNumber(parsed.seedCount, initial.seedCount, 0, Number.MAX_SAFE_INTEGER, true),
completedMilestones,
storyLog: storyLog.length ? storyLog : initial.storyLog,
launched,
endingTitle: launched && typeof parsed.endingTitle === 'string' ? parsed.endingTitle : undefined,
endingGrade: launched && typeof parsed.endingGrade === 'string' ? parsed.endingGrade : undefined,
lastUpdated: safeNumber(parsed.lastUpdated, now, 0, now),
offlineSeconds: 0,
}
return applyOfflineProgress(normalized, now)
} catch {
return createInitialState(now)
}
}
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import {
PHASES,
advancePhase,
applyOfflineProgress,
buyMilestone,
canAdvance,
collectPulse,
createInitialState,
elapsedSeconds,
isReadyToLaunch,
launchSeed,
restoreGame,
serializeGame,
tick,
} from './gameState.js'
describe('生命脉冲', () => {
it('从潮池纪元开始,并按点击强度采集生命质', () => {
const initial = createInitialState(1000)
const next = collectPulse(initial)
expect(initial.phase).toBe(0)
expect(initial.essence).toBe(0)
expect(next.essence).toBe(1)
expect(next).not.toBe(initial)
})
it('只有资源充足时才能购买里程碑,且同一里程碑不能重复购买', () => {
const initial = createInitialState(1000)
const milestone = PHASES[0].milestones[0]
expect(buyMilestone(initial, milestone.id)).toBe(initial)
const funded = { ...initial, essence: milestone.cost }
const purchased = buyMilestone(funded, milestone.id)
const duplicate = buyMilestone(purchased, milestone.id)
expect(purchased.essence).toBe(0)
expect(purchased.completedMilestones).toContain(milestone.id)
expect(purchased.autoRate).toBeGreaterThan(0)
expect(duplicate).toBe(purchased)
})
it('完成当前纪元全部里程碑后才能进入下一纪元', () => {
let state = createInitialState(1000)
expect(canAdvance(state)).toBe(false)
expect(advancePhase(state)).toBe(state)
for (const milestone of PHASES[0].milestones) {
state = buyMilestone({ ...state, essence: milestone.cost }, milestone.id)
}
expect(canAdvance(state)).toBe(true)
const advanced = advancePhase(state)
expect(advanced.phase).toBe(1)
expect(advanced.essence).toBe(0)
expect(advanced.storyLog.at(-1)).toContain('陆冠')
})
it('按秒产生资源,并把离线收益限制在四小时', () => {
const productive = { ...createInitialState(1000), autoRate: 2 }
const afterTenSeconds = tick(productive, 10, 11000)
const afterLongAbsence = applyOfflineProgress(productive, 20_001_000)
expect(afterTenSeconds.essence).toBe(20)
expect(afterTenSeconds.lastUpdated).toBe(11000)
expect(afterLongAbsence.essence).toBe(28_800)
expect(afterLongAbsence.offlineSeconds).toBe(14_400)
})
it('标签页被节流后补足完整间隔,并仍遵守四小时上限', () => {
expect(elapsedSeconds(1_000, 61_000)).toBe(60)
expect(elapsedSeconds(1_000, 18_001_000)).toBe(14_400)
expect(elapsedSeconds(61_000, 1_000)).toBe(0)
})
it('完成星种纪元后才能发射,并生成唯一的正式结局', () => {
let state = { ...createInitialState(1000), phase: 3 }
expect(isReadyToLaunch(state)).toBe(false)
expect(launchSeed(state)).toBe(state)
for (const milestone of PHASES[3].milestones) {
state = buyMilestone({ ...state, essence: milestone.cost }, milestone.id)
}
expect(isReadyToLaunch(state)).toBe(true)
const launched = launchSeed(state)
expect(launched.launched).toBe(true)
expect(launched.seedCount).toBe(1)
expect(launched.endingTitle).toBe('两片海的黎明')
expect(launchSeed(launched)).toBe(launched)
})
it('存档往返保留状态,损坏存档安全回到初始世界', () => {
const state = { ...createInitialState(1000), essence: 42, insight: 7 }
const restored = restoreGame(serializeGame(state), 1000)
const fallback = restoreGame('{bad json', 5000)
expect(restored.essence).toBe(42)
expect(restored.insight).toBe(7)
expect(fallback).toEqual(createInitialState(5000))
})
it('语义损坏的本地存档会被归一化,不会让界面崩溃', () => {
const corrupted = JSON.stringify({
...createInitialState(1000),
essence: -10,
clickPower: null,
autoRate: 'oops',
insight: -2,
balance: 999,
shield: -4,
archive: 'full',
seedCount: 1.7,
completedMilestones: ['membrane', 'unknown', 42],
storyLog: [{ unsafe: true }, '仍可读取的潮声'],
})
const restored = restoreGame(corrupted, 1000)
expect(restored.essence).toBe(0)
expect(restored.clickPower).toBe(1)
expect(restored.autoRate).toBe(0)
expect(restored.insight).toBe(0)
expect(restored.balance).toBe(100)
expect(restored.shield).toBe(0)
expect(restored.archive).toBe(0)
expect(restored.seedCount).toBe(1)
expect(restored.completedMilestones).toEqual(['membrane'])
expect(restored.storyLog).toEqual(['仍可读取的潮声'])
})
})
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
createApp(App).mount('#app')
+437
View File
@@ -0,0 +1,437 @@
@layer reset, base, layout, components, responsive;
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
html { color-scheme: dark; background: #03070b; scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow-x: hidden; }
button, input { font: inherit; }
button { color: inherit; }
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent, #58f3c4); outline-offset: 4px; }
::selection { color: #02100c; background: var(--accent, #58f3c4); }
}
@layer base {
:root {
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #eef8f7;
background: #03070b;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
.game-shell {
--accent: #58f3c4;
--line: rgba(216, 247, 240, 0.14);
--line-strong: rgba(216, 247, 240, 0.28);
--muted: #94a6a3;
--surface: rgba(5, 13, 17, 0.78);
position: relative;
isolation: isolate;
min-height: 100vh;
overflow: hidden;
background:
radial-gradient(circle at 38% 45%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 34%),
linear-gradient(135deg, #020509 0%, #061016 52%, #02070b 100%);
}
.game-shell::before {
position: fixed;
inset: 0;
z-index: -3;
content: "";
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
background-size: 72px 72px;
mask-image: linear-gradient(to bottom, black, transparent 85%);
}
.micro-label {
display: block;
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: .18em;
line-height: 1.2;
text-transform: uppercase;
}
.aurora {
position: fixed;
z-index: -2;
width: 42vw;
height: 42vw;
border-radius: 50%;
pointer-events: none;
opacity: .14;
filter: blur(95px);
background: var(--accent);
transition: background 1.2s ease;
}
.aurora--one { top: -24vw; left: 20vw; animation: drift-one 16s ease-in-out infinite alternate; }
.aurora--two { right: -30vw; bottom: -26vw; opacity: .08; animation: drift-two 20s ease-in-out infinite alternate; }
.noise {
position: fixed;
inset: 0;
z-index: 20;
pointer-events: none;
opacity: .02;
background-image: repeating-radial-gradient(circle at 30% 40%, #fff 0 0.45px, transparent .7px 3px);
background-size: 5px 5px;
mix-blend-mode: screen;
}
@keyframes drift-one { to { transform: translate3d(12vw, 9vw, 0) scale(1.12); } }
@keyframes drift-two { to { transform: translate3d(-8vw, -7vw, 0) scale(.92); } }
}
@layer layout {
.mission-header {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-height: 82px;
padding: 0 34px;
border-bottom: 1px solid var(--line);
background: rgba(2, 7, 10, .72);
backdrop-filter: blur(18px);
}
.wordmark {
display: inline-flex;
align-items: center;
width: max-content;
color: #f0f8f7;
text-decoration: none;
}
.wordmark__mark {
display: grid;
width: 39px;
height: 39px;
margin-right: 12px;
place-items: center;
border: 1px solid var(--accent);
color: var(--accent);
font-size: 17px;
transform: rotate(45deg);
transition: border-color .8s ease, color .8s ease;
}
.wordmark__mark::first-letter { transform: rotate(-45deg); }
.wordmark span:last-child { display: grid; gap: 2px; }
.wordmark strong { font-size: 15px; letter-spacing: .26em; }
.wordmark small { color: var(--muted); font: 8px/1 ui-monospace, monospace; letter-spacing: .18em; }
.mission-title { text-align: center; }
.mission-title span { color: var(--accent); font: 9px/1 ui-monospace, monospace; letter-spacing: .2em; }
.mission-title h1 { margin: 6px 0 0; font-size: 14px; font-weight: 500; letter-spacing: .18em; }
.mission-meta { display: flex; align-items: center; justify-content: flex-end; gap: 20px; }
.save-indicator { color: var(--muted); font: 10px/1 ui-monospace, monospace; letter-spacing: .08em; }
.save-indicator i {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 12px var(--accent);
animation: blink 2s ease-in-out infinite;
}
.text-button { padding: 8px 0; border: 0; color: #dbe9e7; background: none; cursor: pointer; font-size: 10px; letter-spacing: .12em; }
.text-button:hover { color: var(--accent); }
.mission-progress { position: relative; z-index: 11; height: 2px; background: rgba(255,255,255,.04); }
.mission-progress span { display: block; width: 0; height: 100%; background: var(--accent); box-shadow: 0 0 18px var(--accent); transition: width .7s ease, background .8s ease; }
.game-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(355px, .8fr);
min-height: calc(100vh - 119px);
}
.game-grid__scene { position: relative; min-width: 0; padding: 48px 3vw 20px; overflow: hidden; }
.command-panel {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
padding: 28px 28px 22px;
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(6,14,18,.78), rgba(2,8,11,.94));
backdrop-filter: blur(24px);
}
.mission-footer {
position: relative;
z-index: 12;
display: flex;
justify-content: space-between;
padding: 13px 34px;
border-top: 1px solid var(--line);
color: #667876;
background: #02070a;
font: 8px/1 ui-monospace, monospace;
letter-spacing: .14em;
}
}
@layer components {
.chapter-copy { position: relative; z-index: 4; max-width: 680px; }
.chapter-copy h2 {
max-width: 660px;
margin: 14px 0 12px;
font-size: clamp(30px, 3.4vw, 58px);
font-weight: 600;
letter-spacing: -.045em;
line-height: .98;
}
.chapter-copy p { max-width: 600px; margin: 0; color: #92a3a0; font-size: 13px; line-height: 1.7; }
.evolution-scene { position: relative; height: min(650px, 68vh); min-height: 500px; margin-top: -8px; overflow: hidden; }
.star-field { position: absolute; inset: 0; }
.star-field i { position: absolute; border-radius: 50%; background: #dffefa; box-shadow: 0 0 6px #fff; animation: twinkle 4.5s ease-in-out infinite; }
@keyframes twinkle { 50% { opacity: .2; transform: scale(.5); } }
.planet-stage {
position: absolute;
top: 44%;
left: 50%;
width: clamp(270px, 31vw, 500px);
aspect-ratio: 1;
translate: -50% -50%;
}
.planet-glow {
position: absolute;
inset: -16%;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in srgb, var(--accent) 22%, transparent), transparent 68%);
filter: blur(18px);
animation: breathe 5s ease-in-out infinite;
}
@keyframes breathe { 50% { opacity: .55; transform: scale(1.08); } }
.planet {
position: absolute;
inset: 8%;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 48%, #fff 10%);
border-radius: 50%;
background:
radial-gradient(circle at 34% 29%, rgba(255,255,255,.24), transparent 5%),
radial-gradient(circle at 65% 72%, rgba(0,0,0,.72), transparent 47%),
linear-gradient(135deg, #0a5570, #07293c 44%, #05121c 84%);
box-shadow:
inset -45px -28px 65px rgba(0,0,0,.7),
inset 14px 10px 36px rgba(255,255,255,.08),
0 0 0 13px rgba(255,255,255,.015),
0 0 85px color-mix(in srgb, var(--accent) 18%, transparent);
transition: background 1s ease, border-color .8s ease;
}
.planet--tidepool { background: radial-gradient(circle at 34% 27%, #38b7c6, transparent 6%), radial-gradient(circle at 65% 72%, #020911, transparent 46%), repeating-radial-gradient(ellipse at 30% 20%, #0e6d82 0 10px, #084157 11px 26px, #061b2b 27px 45px); }
.planet--landcrown { background: radial-gradient(circle at 34% 27%, #b5e989, transparent 5%), radial-gradient(circle at 70% 75%, #020b08, transparent 48%), linear-gradient(135deg, #3a7c56, #0e4e45 38%, #08202d 74%); }
.planet--forgecity { background: radial-gradient(circle at 35% 26%, #f5c17d, transparent 5%), radial-gradient(circle at 69% 73%, #070400, transparent 48%), linear-gradient(135deg, #875730, #273e40 39%, #08121c 76%); }
.planet--starseed { background: radial-gradient(circle at 35% 26%, #b7dcff, transparent 5%), radial-gradient(circle at 69% 73%, #01040b, transparent 48%), linear-gradient(135deg, #355f91, #172f5d 40%, #070d20 78%); }
.planet-atmosphere { position: absolute; inset: -2%; border: 5px solid color-mix(in srgb, var(--accent) 24%, transparent); border-radius: 50%; filter: blur(7px); }
.planet-grid { position: absolute; inset: 0; opacity: .11; background-image: linear-gradient(90deg, transparent 49.5%, #fff 50%, transparent 50.5%), linear-gradient(transparent 49.5%, #fff 50%, transparent 50.5%); background-size: 31px 31px; transform: rotate(-18deg) scale(1.3); }
.planet-continent { position: absolute; border-radius: 48% 52% 36% 64% / 58% 31% 69% 42%; background: color-mix(in srgb, var(--accent) 48%, #0a2d24); opacity: .55; filter: blur(1px); box-shadow: inset -12px -8px 18px rgba(0,0,0,.35); }
.continent-a { top: 22%; left: 15%; width: 43%; height: 27%; transform: rotate(23deg); }
.continent-b { right: 12%; bottom: 18%; width: 39%; height: 32%; transform: rotate(-29deg); }
.city-lights { position: absolute; inset: 12%; border-radius: 50%; opacity: .8; background-image: radial-gradient(circle, #ffe29a 0 1px, transparent 1.7px); background-size: 23px 19px; mask-image: radial-gradient(ellipse at 45% 43%, black, transparent 62%); animation: city-flicker 3s steps(4) infinite; }
@keyframes city-flicker { 50% { opacity: .52; } }
.launch-trail { position: absolute; top: 16%; right: 17%; width: 2px; height: 32%; background: linear-gradient(#fff, var(--accent), transparent); box-shadow: 0 0 12px var(--accent); transform: rotate(38deg); animation: launch 3.8s ease-in-out infinite; }
@keyframes launch { 0%, 30% { opacity: 0; transform: translate(-25px, 35px) rotate(38deg) scaleY(.3); } 55% { opacity: 1; } 100% { opacity: 0; transform: translate(45px, -65px) rotate(38deg) scaleY(1.3); } }
.orbit { position: absolute; top: 50%; left: 50%; border: 1px solid rgba(213,247,240,.13); border-radius: 50%; transform: translate(-50%, -50%) rotate(-13deg); }
.orbit--wide { width: 78%; height: 37%; }
.orbit--tight { width: 57%; height: 25%; transform: translate(-50%, -50%) rotate(64deg); }
.orbit span { position: absolute; top: 50%; left: -4px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 16px var(--accent); animation: satellite 8s linear infinite; transform-origin: calc(50% + 4px) 0; }
.orbit--tight span { animation-duration: 5.5s; animation-direction: reverse; }
@keyframes satellite { to { offset-distance: 100%; } }
.scan-line { position: absolute; top: 50%; left: 50%; width: 54%; height: 54%; border-radius: 50%; background: conic-gradient(from 190deg, transparent 0 73%, color-mix(in srgb, var(--accent) 13%, transparent) 88%, transparent 96%); transform: translate(-50%, -50%); animation: scan 9s linear infinite; }
@keyframes scan { to { transform: translate(-50%, -50%) rotate(360deg); } }
.pulse-core {
position: absolute;
bottom: 38px;
left: 50%;
z-index: 6;
display: grid;
width: 170px;
min-height: 112px;
place-content: center;
border: 1px solid color-mix(in srgb, var(--accent) 48%, transparent);
border-radius: 50%;
color: #effffc;
background: rgba(3, 12, 15, .78);
box-shadow: inset 0 0 35px color-mix(in srgb, var(--accent) 9%, transparent), 0 0 30px rgba(0,0,0,.45);
cursor: pointer;
translate: -50% 0;
backdrop-filter: blur(10px);
}
.pulse-core:disabled { cursor: default; opacity: .55; }
.pulse-core__halo { position: absolute; inset: -9px; border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); border-radius: 50%; animation: pulse-ring 2.6s ease-out infinite; }
@keyframes pulse-ring { 70%,100% { opacity: 0; transform: scale(1.18); } }
.pulse-core__eyebrow { color: var(--accent); font: 9px/1 ui-monospace, monospace; letter-spacing: .16em; }
.pulse-core strong { margin: 7px 0; font-size: 25px; font-weight: 500; letter-spacing: -.02em; }
.pulse-core__action { color: #8fa5a1; font-size: 9px; letter-spacing: .08em; }
.scene-label { position: absolute; top: 45%; display: grid; gap: 5px; color: #8b9d99; font: 9px/1 ui-monospace, monospace; letter-spacing: .12em; }
.scene-label strong { color: #e1efed; font-size: 10px; }
.scene-label--left { left: 2%; }
.scene-label--right { right: 2%; text-align: right; }
.scene-coordinate { position: absolute; right: 2%; bottom: 21px; color: #50635f; font: 8px/1 ui-monospace, monospace; letter-spacing: .08em; }
.resource-strip { display: grid; grid-template-columns: 1.15fr .85fr .85fr; padding: 2px 0 24px; border-bottom: 1px solid var(--line); }
.resource-strip div { padding: 0 14px; border-left: 1px solid var(--line); }
.resource-strip div:first-child { padding-left: 0; border-left: 0; }
.resource-strip span { display: block; margin-bottom: 8px; color: #697b78; font-size: 9px; letter-spacing: .08em; }
.resource-strip strong { font: 500 17px/1 ui-monospace, monospace; }
.resource-strip div:first-child strong { color: var(--accent); font-size: 22px; text-shadow: 0 0 20px color-mix(in srgb, var(--accent) 40%, transparent); }
.milestone-rail { flex: 1; padding-top: 25px; }
.rail-header { display: flex; align-items: flex-end; justify-content: space-between; }
.rail-header h2 { margin: 7px 0 0; font-size: 19px; font-weight: 500; letter-spacing: .02em; }
.rail-count { color: var(--muted); font: 10px/1 ui-monospace, monospace; letter-spacing: .12em; }
.rail-progress { height: 2px; margin: 18px 0 9px; background: rgba(255,255,255,.06); }
.rail-progress span { display: block; height: 100%; background: var(--accent); box-shadow: 0 0 12px var(--accent); transition: width .6s ease; }
.milestone-list { display: grid; }
.milestone {
display: grid;
grid-template-columns: 30px 1fr auto;
gap: 12px;
align-items: center;
width: 100%;
min-height: 87px;
padding: 13px 2px;
border: 0;
border-bottom: 1px solid var(--line);
text-align: left;
background: transparent;
cursor: pointer;
transition: color .25s ease, opacity .25s ease, border-color .25s ease;
}
.milestone:not(:disabled):hover { border-color: color-mix(in srgb, var(--accent) 55%, transparent); }
.milestone:disabled:not(.is-complete) { cursor: not-allowed; opacity: .38; }
.milestone.is-complete { opacity: .62; }
.milestone__index { color: var(--accent); font: 9px/1 ui-monospace, monospace; }
.milestone__copy { display: grid; gap: 6px; }
.milestone__copy strong { font-size: 13px; font-weight: 500; letter-spacing: .04em; }
.milestone__copy small { max-width: 285px; color: #92a3a0; font-size: 11px; line-height: 1.5; }
.milestone__cost { min-width: 74px; padding: 7px 9px; border: 1px solid var(--line); color: #bdcfcc; font: 9px/1 ui-monospace, monospace; text-align: center; }
.milestone:not(.is-complete) .milestone__cost::after { content: " ◇"; color: var(--accent); }
.milestone.is-complete .milestone__cost { border-color: color-mix(in srgb, var(--accent) 38%, transparent); color: var(--accent); }
.final-meters { display: grid; gap: 10px; margin: 18px 0 0; }
.final-meters > div { display: grid; grid-template-columns: 70px 1fr 38px; gap: 10px; align-items: center; color: #81928f; font-size: 9px; }
.final-meters i { height: 3px; background: rgba(255,255,255,.07); }
.final-meters b { display: block; height: 100%; background: var(--accent); box-shadow: 0 0 9px var(--accent); transition: width .6s ease; }
.final-meters strong { color: #d9e9e7; font: 9px/1 ui-monospace, monospace; text-align: right; }
.epoch-button {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 52px;
margin-top: 18px;
padding: 0 18px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
color: #07110f;
background: var(--accent);
cursor: pointer;
font-size: 11px;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
box-shadow: 0 0 28px color-mix(in srgb, var(--accent) 12%, transparent);
}
.epoch-button strong { font-size: 18px; }
.epoch-button__copy { display: grid; gap: 4px; text-align: left; }
.epoch-button__copy b { font-size: 11px; letter-spacing: .14em; }
.epoch-button__copy small { color: #81918f; font-size: 8px; font-weight: 500; letter-spacing: .08em; text-transform: none; }
.epoch-button:disabled { border-color: var(--line); color: #62716f; background: rgba(255,255,255,.035); box-shadow: none; cursor: not-allowed; }
.epoch-button--launch:not(:disabled) { color: #edfaff; background: linear-gradient(90deg, #2868a3, var(--accent)); }
.signal-log { margin-top: 18px; padding-top: 15px; border-top: 1px solid var(--line); }
.signal-log__head { display: flex; align-items: center; color: #6d807c; font: 8px/1 ui-monospace, monospace; letter-spacing: .13em; }
.signal-dot { width: 5px; height: 5px; margin-right: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 9px var(--accent); }
.signal-log__entries { min-height: 74px; margin-top: 10px; }
.signal-log p { margin: 5px 0; color: #9cadaa; font-size: 9px; line-height: 1.45; }
.signal-log p span { margin-right: 9px; color: var(--accent); font-family: ui-monospace, monospace; }
.phase-transition { position: fixed; inset: 0; z-index: 80; display: grid; place-content: center; text-align: center; background: rgba(1, 5, 8, .94); backdrop-filter: blur(22px); }
.phase-transition > div { width: min(330px, 60vw); height: 1px; margin-bottom: 28px; background: var(--accent); box-shadow: 0 0 25px var(--accent); }
.phase-transition span { color: var(--accent); font: 10px/1 ui-monospace, monospace; letter-spacing: .4em; }
.phase-transition strong { margin-top: 14px; font-size: clamp(34px, 6vw, 72px); font-weight: 400; letter-spacing: .12em; }
.ending { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; overflow: hidden; background: radial-gradient(circle at 50% 90%, #123e55, transparent 38%), #010408; }
.ending::before { position: absolute; bottom: -38vw; left: 50%; width: min(90vw, 1050px); aspect-ratio: 1; content: ""; border: 2px solid rgba(114,213,255,.55); border-radius: 50%; background: radial-gradient(circle at 48% 35%, #4e9caa, #0d354a 35%, #030a11 70%); box-shadow: 0 -20px 90px rgba(83,192,230,.24); transform: translateX(-50%); }
.ending__stars { position: absolute; inset: 0; opacity: .7; background-image: radial-gradient(circle, #fff 0 1px, transparent 1.5px); background-size: 79px 73px; }
.ending__content { position: relative; z-index: 2; width: min(720px, calc(100vw - 42px)); padding-bottom: 8vh; text-align: center; }
.ending h2 { margin: 20px 0 18px; font-size: clamp(46px, 8vw, 104px); font-weight: 300; letter-spacing: -.06em; line-height: .9; }
.ending p { margin: 0 auto; color: #b4c7c6; font-size: 14px; line-height: 1.8; }
.ending__epilogue { max-width: 580px; }
.ending__line { width: 90px; height: 1px; margin: 28px auto; background: var(--accent); box-shadow: 0 0 16px var(--accent); }
.ghost-button { margin-top: 32px; padding: 15px 24px; border: 1px solid rgba(240,250,250,.35); border-radius: 999px; color: #edf8f7; background: rgba(240,250,250,.08); cursor: pointer; font-size: 11px; letter-spacing: .12em; }
.ghost-button:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.offline-notice { position: fixed; right: 24px; bottom: 24px; z-index: 70; max-width: min(420px, calc(100vw - 48px)); padding: 14px 18px; border: 1px solid color-mix(in srgb, var(--accent) 42%, transparent); color: #cfe1de; background: rgba(4,14,17,.94); box-shadow: 0 12px 40px rgba(0,0,0,.45); font-size: 10px; line-height: 1.5; }
@keyframes blink { 50% { opacity: .25; } }
}
@layer responsive {
@media (max-width: 1080px) {
.mission-header { grid-template-columns: 1fr auto; }
.mission-title { display: none; }
.game-grid { grid-template-columns: minmax(0, 1.35fr) minmax(340px, .85fr); }
.game-grid__scene { padding-left: 24px; padding-right: 24px; }
.evolution-scene { height: 610px; }
.scene-label { display: none; }
}
@media (max-width: 820px) {
body { overflow: auto; }
.mission-header { position: sticky; top: 0; min-height: 68px; padding: 0 18px; }
.save-indicator { display: none; }
.game-grid { display: block; min-height: 0; }
.game-grid__scene { min-height: 680px; padding: 36px 18px 0; }
.chapter-copy h2 { font-size: clamp(34px, 10vw, 54px); }
.evolution-scene { height: 550px; min-height: 500px; }
.planet-stage { width: clamp(280px, 65vw, 430px); }
.command-panel { padding: 28px 20px 30px; border-top: 1px solid var(--line); border-left: 0; }
.mission-footer { padding: 11px 18px; }
.mission-footer span:first-child { display: none; }
}
@media (max-width: 520px) {
.wordmark strong { font-size: 13px; }
.wordmark__mark { width: 33px; height: 33px; }
.text-button { font-size: 9px; }
.game-grid__scene { min-height: 620px; }
.chapter-copy p { font-size: 12px; }
.evolution-scene { height: 470px; min-height: 450px; margin-top: 0; }
.planet-stage { top: 46%; width: 300px; }
.orbit--wide { width: 110%; }
.orbit--tight { width: 84%; }
.pulse-core { bottom: 20px; width: 148px; min-height: 96px; }
.pulse-core strong { font-size: 21px; }
.scene-coordinate { display: none; }
.resource-strip { grid-template-columns: 1.15fr 1fr 1fr; }
.resource-strip div { padding: 0 8px; }
.resource-strip strong { font-size: 14px; }
.resource-strip div:first-child strong { font-size: 18px; }
.milestone { grid-template-columns: 24px 1fr auto; gap: 8px; }
.milestone__copy small { max-width: 190px; }
.mission-footer span:nth-child(2) { display: none; }
.ending::before { bottom: -30vw; width: 130vw; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
}
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import { readdir, readFile } from 'node:fs/promises'
const files = await readdir(new URL('../dist/', import.meta.url))
assert.deepEqual(files.sort(), ['index.html'], 'dist 必须只包含一个可直接运行的入口')
const html = await readFile(new URL('../dist/index.html', import.meta.url), 'utf8')
assert.match(html, /^<!doctype html>/i, '缺少 HTML 文档入口')
assert.match(html, /<script type="module"[^>]*>[^<]+/s, 'JavaScript ')
assert.match(html, /<style[^>]*>[^<]+/s, 'CSS ')
assert.doesNotMatch(html, /<script[^>]+src=/i, '发行版仍依赖外部脚本')
assert.doesNotMatch(html, /__STAR_SEED_TEST__/, '发行版包含调试后门')
assert.doesNotMatch(html, /(?:password|passwd|token|secret)\s*[:=]\s*["'][^"']{6,}["']/i, '发行版包含疑似敏感凭据')
console.log(`DIST_OK ${html.length} bytes, one self-contained index.html`)
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteSingleFile } from 'vite-plugin-singlefile'
export default defineConfig({
base: './',
plugins: [vue(), viteSingleFile()],
test: {
environment: 'jsdom',
},
build: {
target: 'es2020',
cssCodeSplit: false,
assetsInlineLimit: 100000000,
},
})