feat: add game tick loop integrating all engines

This commit is contained in:
2026-05-13 04:25:55 +00:00
parent c9a3b4a981
commit 2dd689f694
2 changed files with 355 additions and 0 deletions
+110
View File
@@ -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);
}
+245
View File
@@ -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();