246 lines
10 KiB
JavaScript
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)
|
|
}
|
|
}
|