[verified] feat: launch Star Seed Vue game
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 星种:长夜之前
|
||||
|
||||
一款基于 Vue 3 的单机文明演化增量游戏。从无名潮池中的复制分子出发,跨越陆冠、炉城与星种四个纪元,在暮阳风暴抵达前完成生命档案、磁场天幕与星种方舟。
|
||||
|
||||

|
||||
|
||||
## 直接游玩
|
||||
|
||||
无需安装、无需后端、无需联网:
|
||||
|
||||
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
|
||||
Vendored
+25
File diff suppressed because one or more lines are too long
@@ -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
@@ -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>
|
||||
Generated
+2220
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(['仍可读取的潮声'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
+437
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user