Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -12,8 +12,8 @@ import {
createEmptyEquipmentLoadout,
getEquipmentBonuses,
} from '../src/data/equipmentEffects.ts';
import { createSceneHostileNpcsFromIds } from '../src/data/hostileNpcs.ts';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../src/data/inventoryEffects.ts';
import { createSceneMonstersFromIds } from '../src/data/monsters.ts';
import { buildInitialNpcState, buildInitialPlayerInventory, buildNpcEncounterStoryMoment, checkTradeItem, createNpcBattleMonster } from '../src/data/npcInteractions.ts';
import {
acceptQuest,
@@ -26,7 +26,7 @@ import {
import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts';
import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts';
import { buildSceneObserveSignsStoryText } from '../src/data/sceneObservation.ts';
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts';
import { AnimationState, GameState, WorldType } from '../src/types.ts';
@@ -55,7 +55,7 @@ function createBaseState(worldType: WorldType, sceneId?: string): GameState {
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset,
sceneMonsters: [],
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
@@ -92,10 +92,10 @@ function smokeScenePreviews() {
const preview = createSceneEncounterPreview(createBaseState(worldType, scene.id));
assert(preview.currentEncounter?.kind !== 'treasure', `[preview] treasure encounter should be disabled for ${worldType}`);
assert(preview.currentEncounter || preview.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
assert(preview.currentEncounter || preview.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} produced no preview entity`);
const ensured = ensureSceneEncounterPreview(createBaseState(worldType, scene.id));
assert(ensured.currentEncounter || ensured.sceneMonsters.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
assert(ensured.currentEncounter || ensured.sceneHostileNpcs.length > 0 || scene.treasureHints.length === 0, `[preview] ${scene.id} failed ensureSceneEncounterPreview`);
}
}
@@ -163,20 +163,21 @@ function smokeTreasureStories() {
function smokeMonsterCreation() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length > 0);
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length > 0);
assert(sceneWithMonster, `[monster] missing monster scene for ${worldType}`);
const monsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
const monsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
assert(monsters.length > 0, `[monster] ${sceneWithMonster.id} failed to create scene monsters`);
assert(
monsters.length === Math.min(sceneWithMonster.monsterIds.length, 3),
monsters.length === Math.min(hostileNpcPresetIds.length, 3),
`[monster] ${sceneWithMonster.id} should keep the full configured encounter group`,
);
const resolvedState = createBaseState(worldType, sceneWithMonster.id);
resolvedState.sceneMonsters = monsters;
resolvedState.sceneHostileNpcs = monsters;
resolvedState.inBattle = true;
assert(
resolvedState.sceneMonsters.length === monsters.length,
resolvedState.sceneHostileNpcs.length === monsters.length,
`[monster] ${sceneWithMonster.id} multi-enemy battle state lost monsters`,
);
}
@@ -206,7 +207,7 @@ function smokeObserveAndCallOut() {
const baseState = createBaseState(worldType, scene.id);
const callOutResult = createSceneCallOutEncounter(baseState);
assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`);
assert(callOutResult.currentEncounter || callOutResult.sceneMonsters.length > 0 || scene.monsterIds.length === 0, `[idle] call_out failed for ${scene.id}`);
assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`);
const observeText = buildSceneObserveSignsStoryText(worldType, scene.id);
assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`);
@@ -281,19 +282,20 @@ function smokeTradeEconomyLoop() {
function smokeEncounterTransitionLoop() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => scene.monsterIds.length >= 2);
const sceneWithMonster = getScenePresetsByWorld(worldType).find(scene => getSceneHostileNpcPresetIds(scene).length >= 2);
assert(sceneWithMonster, `[transition] missing multi-monster scene for ${worldType}`);
const finalMonsters = createSceneMonstersFromIds(worldType, sceneWithMonster.monsterIds, 0);
const hostileNpcPresetIds = getSceneHostileNpcPresetIds(sceneWithMonster);
const finalMonsters = createSceneHostileNpcsFromIds(worldType, hostileNpcPresetIds, 0);
const finalState = {
...createBaseState(worldType, sceneWithMonster.id),
inBattle: true,
sceneMonsters: finalMonsters,
sceneHostileNpcs: finalMonsters,
};
const previewState = {
...finalState,
inBattle: false,
sceneMonsters: finalMonsters.map((monster, index) => ({
sceneHostileNpcs: finalMonsters.map((monster, index) => ({
...monster,
xMeters: 12 + (index * 1.8),
})),
@@ -301,15 +303,15 @@ function smokeEncounterTransitionLoop() {
const transitionState = buildEncounterTransitionState(finalState, previewState);
assert(
transitionState.sceneMonsters[1]?.xMeters === previewState.sceneMonsters[1]?.xMeters,
transitionState.sceneHostileNpcs[1]?.xMeters === previewState.sceneHostileNpcs[1]?.xMeters,
`[transition] second monster should keep its preview x during transition for ${worldType}`,
);
const halfwayState = interpolateEncounterTransitionState(transitionState, finalState, 0.5);
assert(
halfwayState.sceneMonsters.every((monster, index) => {
const startX = transitionState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
const endX = finalState.sceneMonsters[index]?.xMeters ?? monster.xMeters;
halfwayState.sceneHostileNpcs.every((monster, index) => {
const startX = transitionState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
const endX = finalState.sceneHostileNpcs[index]?.xMeters ?? monster.xMeters;
return monster.xMeters !== startX && monster.xMeters !== endX;
}),
`[transition] all monsters should interpolate instead of only the first one for ${worldType}`,
@@ -317,7 +319,7 @@ function smokeEncounterTransitionLoop() {
const offscreenState = buildEncounterEntryState(finalState, 18);
assert(
offscreenState.sceneMonsters.every(monster => monster.xMeters >= 18),
offscreenState.sceneHostileNpcs.every(monster => monster.xMeters >= 18),
`[transition] offscreen entry should place the entire encounter group offscreen for ${worldType}`,
);
}
@@ -361,7 +363,7 @@ function smokeRosterLoop() {
function smokeQuestLoop() {
for (const worldType of [WorldType.WUXIA, WorldType.XIANXIA]) {
const sceneWithNpcAndMonster = getScenePresetsByWorld(worldType).find(
scene => scene.npcs.length > 0 && scene.monsterIds.length > 0,
scene => scene.npcs.length > 0 && getSceneHostileNpcPresetIds(scene).length > 0,
);
assert(sceneWithNpcAndMonster, `[quest] missing npc+monster scene for ${worldType}`);