Files
star-seed/src/game/gameState.js
T

246 lines
10 KiB
JavaScript

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)
}
}