From 2dd689f69421d89fc36c2f5cae801bc5a8c644eb Mon Sep 17 00:00:00 2001 From: congsh <2452821485@qq.com> Date: Wed, 13 May 2026 04:25:55 +0000 Subject: [PATCH] feat: add game tick loop integrating all engines --- src/engine/tickLoop.js | 110 ++++++++++++++++++ test/tickLoop.test.js | 245 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 src/engine/tickLoop.js create mode 100644 test/tickLoop.test.js diff --git a/src/engine/tickLoop.js b/src/engine/tickLoop.js new file mode 100644 index 0000000..9b0b4b5 --- /dev/null +++ b/src/engine/tickLoop.js @@ -0,0 +1,110 @@ +// ==================== 游戏主循环 ==================== + +import { applyArtifactEffects } from './shopEngine.js'; +import { checkUnlocks, calcDailyIncome, calcDailyExp, checkLevelUp } from './careerEngine.js'; +import { applyDailyIncome } from './moneySystem.js'; +import { checkAgeEvents, pickRandomEvent, executeEvent } from './eventEngine.js'; +import { checkDeath } from './deathEngine.js'; +import { applyEffect } from './effectApplier.js'; + +let _intervalId = null; + +export function tick(state, days, careerConfig, eventConfig, shopConfig) { + if (state.alive === false || state.paused === true) { + return; + } + + for (let i = 0; i < days; i++) { + // 1. 天数推进 + state.day++; + + // 2. 年龄增长 + if (state.day % 365 === 0) { + state.year++; + state.age++; + } + + // 3. 应用神器效果 + applyArtifactEffects(shopConfig, state); + + // 4. 检查职业解锁 + checkUnlocks(careerConfig, state); + + // 5. 银两结算 + const income = calcDailyIncome(careerConfig, state); + applyDailyIncome(state, income); + + // 6. 经验结算 + if (state.careers) { + for (const careerId of Object.keys(state.careers)) { + const exp = calcDailyExp(careerConfig, state, careerId); + state.careers[careerId].exp += exp; + } + } + + // 7. 检查升级 + checkLevelUp(careerConfig, state); + + // 8. 检查年龄触发事件 + const ageEvents = checkAgeEvents(eventConfig, state); + for (const event of ageEvents) { + if (event.isChoice) { + state.paused = true; + state.currentEvent = event; + return; + } else { + executeEvent(event, state, applyEffect); + } + } + + // 9. 随机事件(30%概率) + if (Math.random() < 0.3) { + const randomEvent = pickRandomEvent(eventConfig, state, 'normal'); + if (randomEvent && !randomEvent.isChoice) { + executeEvent(randomEvent, state, applyEffect); + } else if (randomEvent && randomEvent.isChoice) { + state.paused = true; + state.currentEvent = randomEvent; + return; + } + } + + // 10. 检查死亡 + checkDeath(state); + if (state.alive === false) { + return; + } + } +} + +export function startGameLoop(state, careerConfig, eventConfig, shopConfig, onTick) { + stopGameLoop(); + + const speedMap = { + 0: 1000, + 1: 100, + 2: 10, + 3: 1, + }; + + const interval = speedMap[state.speed] ?? 1000; + + _intervalId = setInterval(() => { + tick(state, 1, careerConfig, eventConfig, shopConfig); + if (onTick) { + onTick(); + } + }, interval); +} + +export function stopGameLoop() { + if (_intervalId !== null) { + clearInterval(_intervalId); + _intervalId = null; + } +} + +export function setSpeed(state, level, careerConfig, eventConfig, shopConfig, onTick) { + state.speed = level; + startGameLoop(state, careerConfig, eventConfig, shopConfig, onTick); +} diff --git a/test/tickLoop.test.js b/test/tickLoop.test.js new file mode 100644 index 0000000..08adbd3 --- /dev/null +++ b/test/tickLoop.test.js @@ -0,0 +1,245 @@ +import { + tick, + startGameLoop, + stopGameLoop, + setSpeed, +} from '../src/engine/tickLoop.js'; + +// ==================== 测试配置 ==================== + +const careerConfig = { + careers: [ + { + id: 'student', + name: '学童', + type: 'scholar', + unlockConditions: { type: 'age', op: '>=', value: 6 }, + dailyIncome: 1, + expCurve: { base: 10, factor: 1.15 }, + }, + { + id: 'soldier', + name: '士兵', + type: 'military', + unlockConditions: { + type: 'and', + conditions: [ + { type: 'age', op: '>=', value: 14 }, + { type: 'stat', op: '>=', stat: 'body', value: 10 }, + ], + }, + dailyIncome: 2, + expCurve: { base: 20, factor: 1.18 }, + }, + ], +}; + +const eventConfig = { + events: [ + { + id: 'invasion_military_40', + name: '蛮族入侵', + trigger: { type: 'age', value: 40 }, + isChoice: true, + choices: [ + { + id: 'resist', + text: '率军抵抗', + requirements: { + type: 'careerLevel', + careerId: 'soldier', + op: '>=', + value: 50, + }, + effects: [{ type: 'addLog', text: '你率领大军击退了蛮族入侵!' }], + }, + { + id: 'flee', + text: '弃城逃跑', + effects: [{ type: 'addLog', text: '你选择了弃城逃跑。' }], + }, + ], + }, + { + id: 'daily_training', + name: '日常修炼', + trigger: { type: 'random', pool: 'normal' }, + weight: 50, + effects: [{ type: 'addStat', stat: 'body', value: 1 }], + }, + ], +}; + +const shopConfig = { + artifacts: [], +}; + +// ==================== 测试辅助函数 ==================== + +function assertEqual(actual, expected, message) { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + throw new Error( + `${message}\n 期望: ${expectedStr}\n 实际: ${actualStr}` + ); + } +} + +function assertTrue(value, message) { + if (!value) { + throw new Error(message || `期望为 true,实际为 ${value}`); + } +} + +// ==================== 测试用例 ==================== + +function testTickAdvancesDay() { + const state = { + alive: true, + paused: false, + day: 0, + year: 0, + age: 5, + stats: {}, + careers: {}, + money: 0, + worldFlags: {}, + }; + + tick(state, 1, careerConfig, eventConfig, shopConfig); + assertEqual(state.day, 1, 'day should advance by 1'); +} + +function testTickUnlocksCareer() { + const state = { + alive: true, + paused: false, + day: 0, + year: 0, + age: 5, + stats: {}, + careers: {}, + money: 0, + worldFlags: {}, + }; + + // Age 5 -> 6 after 365 days + tick(state, 365, careerConfig, eventConfig, shopConfig); + assertEqual(state.age, 6, 'age should be 6 after 365 days'); + assertTrue(state.careers.student !== undefined, 'student should unlock at age 6'); +} + +function testTickIncome() { + const state = { + alive: true, + paused: false, + day: 0, + year: 0, + age: 10, + stats: {}, + careers: { student: { level: 0, exp: 0 } }, + money: 0, + worldFlags: {}, + }; + + tick(state, 10, careerConfig, eventConfig, shopConfig); + assertEqual(state.money, 10, 'money should increase by 10 (1 per day from student)'); +} + +function testTickExp() { + const state = { + alive: true, + paused: false, + day: 0, + year: 0, + age: 10, + stats: { wisdom: 0 }, + careers: { student: { level: 0, exp: 0 } }, + money: 0, + metaExp: {}, + worldFlags: {}, + }; + + tick(state, 1, careerConfig, eventConfig, shopConfig); + assertEqual(state.careers.student.level, 1, 'student should level up to 1 after 1 day'); + assertEqual(state.careers.student.exp, 0, 'remaining exp should be 0 after level up'); + + // Test level up: need 10 exp for level 1, 11.5 for level 2 + const state2 = { + alive: true, + paused: false, + day: 0, + year: 0, + age: 10, + stats: { wisdom: 0 }, + careers: { student: { level: 0, exp: 0 } }, + money: 0, + metaExp: {}, + worldFlags: {}, + }; + + tick(state2, 2, careerConfig, eventConfig, shopConfig); + assertEqual(state2.careers.student.level, 1, 'student should level up to 1'); + assertEqual(state2.careers.student.exp, 10, 'remaining exp should be 10 (20 - 10)'); +} + +function testAgeEventTriggers() { + const state = { + alive: true, + paused: false, + day: 0, + year: 0, + age: 39, + stats: {}, + careers: {}, + money: 0, + worldFlags: {}, + triggeredEvents: new Set(), + }; + + // 365 ticks to go from age 39 to 40 + tick(state, 365, careerConfig, eventConfig, shopConfig); + assertEqual(state.age, 40, 'age should be 40'); + assertEqual(state.paused, true, 'game should pause on choice event'); + assertTrue(state.currentEvent !== undefined, 'currentEvent should be set'); + assertEqual(state.currentEvent.id, 'invasion_military_40', 'should trigger invasion event at 40'); +} + +// ==================== 运行测试 ==================== + +function runTests() { + const tests = [ + { name: 'testTickAdvancesDay', fn: testTickAdvancesDay }, + { name: 'testTickUnlocksCareer', fn: testTickUnlocksCareer }, + { name: 'testTickIncome', fn: testTickIncome }, + { name: 'testTickExp', fn: testTickExp }, + { name: 'testAgeEventTriggers', fn: testAgeEventTriggers }, + ]; + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + test.fn(); + console.log(` PASS: ${test.name}`); + passed++; + } catch (err) { + console.log(` FAIL: ${test.name}`); + console.log(` ${err.message}`); + failed++; + } + } + + console.log(`\n${passed} passed, ${failed} failed`); + + if (failed === 0) { + console.log('All tickLoop tests PASS'); + process.exit(0); + } else { + process.exit(1); + } +} + +runTests();