first commmit
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
IChapter1ConfigBundle,
|
||||
IEnemyConfig,
|
||||
IItemConfig,
|
||||
IWeaponConfig,
|
||||
ILevelConfig,
|
||||
IBossConfig,
|
||||
IStorySceneConfig,
|
||||
EnemyType,
|
||||
ItemType,
|
||||
WeaponType,
|
||||
ONLY_DIFFICULTY,
|
||||
} from './Interfaces';
|
||||
|
||||
/**
|
||||
* Abstraction over how JSON is actually fetched. In Cocos Creator 3.8 this
|
||||
* will be backed by `resources.load('configs/enemies', JsonAsset, cb)`. In
|
||||
* Jest we inject an in-memory `MapLoader` so tests stay fast and offline.
|
||||
*/
|
||||
export interface IJsonLoader {
|
||||
load<T>(path: string): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory loader, fed with a plain key→JSON map. Used by unit
|
||||
* tests and by any non-Cocos runtime (e.g. a future web leaderboard tool).
|
||||
*/
|
||||
export class MapJsonLoader implements IJsonLoader {
|
||||
constructor(private readonly map: Record<string, unknown>) {}
|
||||
public async load<T>(path: string): Promise<T> {
|
||||
if (!(path in this.map)) {
|
||||
throw new Error(`MapJsonLoader: missing path "${path}"`);
|
||||
}
|
||||
return this.map[path] as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the chapter-1 config bundle and validates it against the interface
|
||||
* contract. Any missing required field, unknown enum value, or any reference
|
||||
* to a removed difficulty mode causes `load()` to reject with a descriptive
|
||||
* error (requirement 13.6 — no casual/普通 mode may ever load).
|
||||
*/
|
||||
export class ConfigMgr {
|
||||
private _bundle: IChapter1ConfigBundle | undefined;
|
||||
|
||||
constructor(private readonly loader: IJsonLoader) {}
|
||||
|
||||
public get bundle(): IChapter1ConfigBundle {
|
||||
if (!this._bundle) {
|
||||
throw new Error('ConfigMgr: load() must be awaited before accessing bundle');
|
||||
}
|
||||
return this._bundle;
|
||||
}
|
||||
|
||||
public async load(): Promise<IChapter1ConfigBundle> {
|
||||
const [enemies, items, weapons, levels, bosses, stories] = await Promise.all([
|
||||
this.loader.load<IEnemyConfig[]>('configs/enemies'),
|
||||
this.loader.load<IItemConfig[]>('configs/items'),
|
||||
this.loader.load<IWeaponConfig[]>('configs/weapons'),
|
||||
this.loader.load<ILevelConfig[]>('configs/levels'),
|
||||
this.loader.load<IBossConfig[]>('configs/bosses'),
|
||||
this.loader.load<IStorySceneConfig[]>('configs/stories'),
|
||||
]);
|
||||
const bundle: IChapter1ConfigBundle = { enemies, items, weapons, levels, bosses, stories };
|
||||
this.validate(bundle);
|
||||
this._bundle = bundle;
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** Look up an enemy config, throwing if it's missing. */
|
||||
public enemy(id: EnemyType): IEnemyConfig {
|
||||
const e = this.bundle.enemies.find((x) => x.id === id);
|
||||
if (!e) throw new Error(`ConfigMgr: enemy "${id}" not found`);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Look up an item config, throwing if it's missing. */
|
||||
public item(id: ItemType): IItemConfig {
|
||||
const it = this.bundle.items.find((x) => x.id === id);
|
||||
if (!it) throw new Error(`ConfigMgr: item "${id}" not found`);
|
||||
return it;
|
||||
}
|
||||
|
||||
/** Look up a weapon config, throwing if it's missing. */
|
||||
public weapon(id: WeaponType): IWeaponConfig {
|
||||
const w = this.bundle.weapons.find((x) => x.id === id);
|
||||
if (!w) throw new Error(`ConfigMgr: weapon "${id}" not found`);
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Look up a level config, throwing if it's missing. */
|
||||
public level(id: string): ILevelConfig {
|
||||
const lv = this.bundle.levels.find((x) => x.id === id);
|
||||
if (!lv) throw new Error(`ConfigMgr: level "${id}" not found`);
|
||||
return lv;
|
||||
}
|
||||
|
||||
/** Look up a boss config, throwing if it's missing. */
|
||||
public boss(id: string): IBossConfig {
|
||||
const b = this.bundle.bosses.find((x) => x.id === id);
|
||||
if (!b) throw new Error(`ConfigMgr: boss "${id}" not found`);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Look up a story scene, throwing if it's missing. */
|
||||
public story(id: string): IStorySceneConfig {
|
||||
const s = this.bundle.stories.find((x) => x.id === id);
|
||||
if (!s) throw new Error(`ConfigMgr: story "${id}" not found`);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- validation ----------
|
||||
|
||||
private validate(b: IChapter1ConfigBundle): void {
|
||||
// Guard against the D-4 decision — casual mode must never load.
|
||||
const stringified = JSON.stringify(b).toLowerCase();
|
||||
if (stringified.includes('"casual"') || stringified.includes('"normal_mode"')) {
|
||||
throw new Error(
|
||||
`ConfigMgr: detected forbidden casual/normal_mode token — only difficulty "${ONLY_DIFFICULTY}" is permitted`
|
||||
);
|
||||
}
|
||||
this.validateEnemies(b.enemies);
|
||||
this.validateItems(b.items);
|
||||
this.validateWeapons(b.weapons);
|
||||
this.validateLevels(b);
|
||||
this.validateBosses(b.bosses);
|
||||
this.validateStories(b.stories);
|
||||
}
|
||||
|
||||
private validateEnemies(list: IEnemyConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: enemies list is empty');
|
||||
const required: Array<keyof IEnemyConfig> = ['id', 'displayName', 'size', 'moveSpeed', 'attackIntervalSec', 'attacks', 'hp'];
|
||||
for (const e of list) {
|
||||
for (const key of required) {
|
||||
if (e[key] === undefined || e[key] === null) {
|
||||
throw new Error(`ConfigMgr: enemy "${e.id ?? '?'}" missing field "${String(key)}"`);
|
||||
}
|
||||
}
|
||||
if (!Object.values(EnemyType).includes(e.id as EnemyType)) {
|
||||
throw new Error(`ConfigMgr: enemy id "${e.id}" is not a known EnemyType`);
|
||||
}
|
||||
if (e.attacks.length === 0) throw new Error(`ConfigMgr: enemy "${e.id}" must declare at least one attack`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateItems(list: IItemConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: items list is empty');
|
||||
for (const it of list) {
|
||||
if (!it.id || !it.displayName || !it.icon || typeof it.lifetimeSec !== 'number') {
|
||||
throw new Error(`ConfigMgr: item "${it.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (!Object.values(ItemType).includes(it.id as ItemType)) {
|
||||
throw new Error(`ConfigMgr: item id "${it.id}" is not a known ItemType`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateWeapons(list: IWeaponConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: weapons list is empty');
|
||||
for (const w of list) {
|
||||
if (!w.id || !w.displayName || typeof w.baseIntervalSec !== 'number' || typeof w.damage !== 'number') {
|
||||
throw new Error(`ConfigMgr: weapon "${w.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (!Object.values(WeaponType).includes(w.id as WeaponType)) {
|
||||
throw new Error(`ConfigMgr: weapon id "${w.id}" is not a known WeaponType`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateLevels(b: IChapter1ConfigBundle): void {
|
||||
if (b.levels.length === 0) throw new Error('ConfigMgr: levels list is empty');
|
||||
const enemyIds = new Set(b.enemies.map((e) => e.id));
|
||||
const bossIds = new Set(b.bosses.map((x) => x.id));
|
||||
for (const lv of b.levels) {
|
||||
if (!lv.id || !lv.displayName || !lv.sceneTheme || !lv.scrollDirection || !lv.objective) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (lv.timeLimitSec <= 0) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" must have positive timeLimitSec`);
|
||||
}
|
||||
if (lv.objective.kind === 'kill_count') {
|
||||
if (!lv.objective.enemy || !lv.objective.count) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" kill_count objective missing enemy/count`);
|
||||
}
|
||||
if (!enemyIds.has(lv.objective.enemy)) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" references unknown enemy "${lv.objective.enemy}"`);
|
||||
}
|
||||
}
|
||||
if (lv.objective.kind === 'defeat_boss') {
|
||||
if (!lv.objective.bossId || !bossIds.has(lv.objective.bossId)) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" references unknown boss "${lv.objective.bossId}"`);
|
||||
}
|
||||
}
|
||||
for (const sp of lv.enemySpawns) {
|
||||
if (!enemyIds.has(sp.type)) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" spawn references unknown enemy "${sp.type}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateBosses(list: IBossConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: bosses list is empty');
|
||||
for (const bo of list) {
|
||||
if (!bo.id || !bo.displayName || typeof bo.hp !== 'number' || !Array.isArray(bo.phases)) {
|
||||
throw new Error(`ConfigMgr: boss "${bo.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (bo.phases.length === 0) {
|
||||
throw new Error(`ConfigMgr: boss "${bo.id}" must have at least one phase`);
|
||||
}
|
||||
let prev = Number.POSITIVE_INFINITY;
|
||||
for (const p of bo.phases) {
|
||||
if (p.hpThreshold > prev) {
|
||||
throw new Error(`ConfigMgr: boss "${bo.id}" phases must be ordered by descending hpThreshold`);
|
||||
}
|
||||
prev = p.hpThreshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStories(list: IStorySceneConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: stories list is empty');
|
||||
for (const s of list) {
|
||||
if (!s.id || !s.bgm || !Array.isArray(s.pages) || s.pages.length < 3) {
|
||||
throw new Error(`ConfigMgr: story "${s.id ?? '?'}" must include id, bgm, and ≥3 pages (req 19.2)`);
|
||||
}
|
||||
if (s.maxDurationSec > 30) {
|
||||
throw new Error(`ConfigMgr: story "${s.id}" maxDurationSec exceeds 30s budget (req 19.1)`);
|
||||
}
|
||||
const indices = s.pages.map((p) => p.index).sort((a, b) => a - b);
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
if (indices[i] !== i + 1) {
|
||||
throw new Error(`ConfigMgr: story "${s.id}" page indices must be contiguous starting from 1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9877a4f9-e13f-412f-a572-dfe501faba39",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Data-driven configuration interfaces for《影之传说》MVP.
|
||||
*
|
||||
* Every numeric default in here traces directly back to a requirement in
|
||||
* `.codebuddy/plan/kage_legend_mvp/requirements.md`. When you change a field,
|
||||
* keep the inline `req` comment in sync so QA can rebuild the traceability
|
||||
* matrix.
|
||||
*
|
||||
* NOTE: This module is platform-agnostic and MUST NOT depend on `cc`.
|
||||
*/
|
||||
|
||||
import { PlayerColorState } from '../common/Constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enemies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Enum covers all enemy types required by MVP Chapter 1 (req 6.1-6.7). */
|
||||
export enum EnemyType {
|
||||
/** Green ninja — shuriken + sword, 2s interval (req 6.1). */
|
||||
QingRen = 'qing_ren',
|
||||
/** Red ninja — 120px/s + smoke bomb, proactive intercept jump (req 6.2-6.3). */
|
||||
ChiRen = 'chi_ren',
|
||||
/** Black ninja — drops magic flute scroll on castle stages (req 6.5). */
|
||||
HeiRen = 'hei_ren',
|
||||
/** Monster priest — straight-line fireball, 3.0s interval (req 6.6). */
|
||||
YaoFang = 'yao_fang',
|
||||
}
|
||||
|
||||
/** Allowed damage types (req 3.7, 3.8, 10.4-10.5). */
|
||||
export type AttackType = 'shuriken' | 'sword' | 'fireball' | 'smoke_bomb';
|
||||
|
||||
export interface IEnemyConfig {
|
||||
id: EnemyType;
|
||||
displayName: string;
|
||||
/** Pixel sprite size (width x height). */
|
||||
size: { w: number; h: number };
|
||||
/** Horizontal movement speed (px/s). 0 means stationary. */
|
||||
moveSpeed: number;
|
||||
/** Attack interval in seconds. */
|
||||
attackIntervalSec: number;
|
||||
/** Possible attack types. */
|
||||
attacks: AttackType[];
|
||||
/** Hit points (1 means dies to any successful hit). */
|
||||
hp: number;
|
||||
/** How many enemies of this type must be killed for chapter objective (optional). */
|
||||
killObjective?: number;
|
||||
/** Drop rules specific to this enemy type (see `IItemDropRule`). */
|
||||
drops?: IItemDropRule[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Item IDs used by MVP Chapter 1 (req 7.1-7.6, 5.1-5.6). */
|
||||
export enum ItemType {
|
||||
/** 水晶玉 — auto-upgrades red → green → yellow (req 5.1-5.2). */
|
||||
CrystalJade = 'crystal_jade',
|
||||
/** 点丸 — +50% attack power, 30s (req 7.3). */
|
||||
DianWan = 'dian_wan',
|
||||
/** 术丸 — +30% move speed, 20s (req 7.3). */
|
||||
ShuWan = 'shu_wan',
|
||||
/** 魔笛 — screen-wipe one-shot kill (req 7.4). */
|
||||
MoDi = 'mo_di',
|
||||
/** 增丸 — +1 permanent life (req 7.5). */
|
||||
ZengWan = 'zeng_wan',
|
||||
}
|
||||
|
||||
export interface IItemConfig {
|
||||
id: ItemType;
|
||||
displayName: string;
|
||||
/** Icon asset path under `assets/resources/textures/items`. */
|
||||
icon: string;
|
||||
/** Duration in seconds for timed buffs (0 / omitted for instant items). */
|
||||
durationSec?: number;
|
||||
/**
|
||||
* Relative strength of the effect (interpretation is per item type;
|
||||
* see `IItemEffectApplier` in logic layer for the actual math).
|
||||
*/
|
||||
magnitude?: number;
|
||||
/** Lifetime in seconds after spawning on the map (req 7.2). */
|
||||
lifetimeSec: number;
|
||||
}
|
||||
|
||||
/** Drop rule attached to an enemy type. Evaluated on enemy death. */
|
||||
export interface IItemDropRule {
|
||||
item: ItemType;
|
||||
/** Required consecutive kills of this enemy type before drop can happen. */
|
||||
afterKills?: number;
|
||||
/** Probability 0~1 once `afterKills` condition is satisfied. */
|
||||
probability: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum WeaponType {
|
||||
Shuriken = 'shuriken',
|
||||
NinjaSword = 'ninja_sword',
|
||||
}
|
||||
|
||||
export interface IWeaponConfig {
|
||||
id: WeaponType;
|
||||
displayName: string;
|
||||
/** Base attack interval (s). Yellow state may override for shuriken. */
|
||||
baseIntervalSec: number;
|
||||
/** Upgraded (green/yellow) interval (s). */
|
||||
upgradedIntervalSec?: number;
|
||||
/** Damage applied to standard enemies on hit. */
|
||||
damage: number;
|
||||
/** Supports parry (req 3.7 — only sword). */
|
||||
canParry: boolean;
|
||||
/** When long-pressed, max shots in a burst (req 3.5 — shuriken only). */
|
||||
burstMax?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Levels (Chapter 1 only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Scroll direction for a level (req 8.1-8.5). */
|
||||
export type ScrollDirection = 'horizontal' | 'horizontal_bi' | 'vertical';
|
||||
|
||||
export interface ILevelObjective {
|
||||
/** e.g. 'kill_yao_fang', 'reach_top', 'boss_defeated'. */
|
||||
kind: 'kill_count' | 'reach_top' | 'defeat_boss';
|
||||
/** For kill_count objectives — which enemy type, and how many. */
|
||||
enemy?: EnemyType;
|
||||
/** Required count for kill_count objectives. */
|
||||
count?: number;
|
||||
/** For defeat_boss objectives — boss ID. */
|
||||
bossId?: string;
|
||||
}
|
||||
|
||||
export interface ILevelConfig {
|
||||
id: string; // e.g. '1-1'
|
||||
chapter: 1 | 2 | 3;
|
||||
displayName: string;
|
||||
/** One of 'forest' / 'cave' / 'castle_wall' / 'demon_castle'. */
|
||||
sceneTheme: string;
|
||||
scrollDirection: ScrollDirection;
|
||||
/** Time limit in seconds (req 8.1-8.4). */
|
||||
timeLimitSec: number;
|
||||
objective: ILevelObjective;
|
||||
/** Scene length in pixels (landscape 16:9 baseline, req 8.8). */
|
||||
levelLengthPx: number;
|
||||
/** BGM bundle key under `assets/resources/audio`. */
|
||||
bgm: string;
|
||||
/** Enemy spawn list evaluated by the LevelMgr. */
|
||||
enemySpawns: Array<{ type: EnemyType; atPx: number; count?: number }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bosses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IBossAttackPhase {
|
||||
/** HP threshold at which this phase activates (1.0, 0.66, 0.33, ...). */
|
||||
hpThreshold: number;
|
||||
/** Human-readable mode id (e.g. 'pair_pincer', 'fireball_spread', 'clone_confuse'). */
|
||||
mode: string;
|
||||
/** Interval between actions in this phase (s). */
|
||||
actionIntervalSec: number;
|
||||
}
|
||||
|
||||
export interface IBossConfig {
|
||||
id: string;
|
||||
displayName: string;
|
||||
hp: number;
|
||||
/** A non-zero value means "butterfly appearance required before damage". */
|
||||
butterflyReveal: boolean;
|
||||
/** Ordered from highest hpThreshold to lowest. */
|
||||
phases: IBossAttackPhase[];
|
||||
/** Cutscene trigger (req 8.6 / 14.1): play short "princess taken" at hp<=X. */
|
||||
princessCutsceneAtHpRatio?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Story intro (req 19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IStoryPageConfig {
|
||||
/** 1-based page index. */
|
||||
index: number;
|
||||
/** Texture path under `assets/resources/textures/story`. */
|
||||
illustration: string;
|
||||
/** Pixel typewriter text to display. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IStorySceneConfig {
|
||||
id: string; // e.g. 'chapter_1_intro'
|
||||
bgm: string; // e.g. 'bgm_story'
|
||||
/** Max total duration (seconds); UI should auto-advance if exceeded. */
|
||||
maxDurationSec: number;
|
||||
pages: IStoryPageConfig[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregate configuration table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IChapter1ConfigBundle {
|
||||
enemies: IEnemyConfig[];
|
||||
items: IItemConfig[];
|
||||
weapons: IWeaponConfig[];
|
||||
levels: ILevelConfig[];
|
||||
bosses: IBossConfig[];
|
||||
stories: IStorySceneConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes which player-state the JSON config applies to. We explicitly
|
||||
* leave **no room** for a 'casual' mode string so that future edits cannot
|
||||
* silently reintroduce the removed difficulty (decision D-4, req 13.1-13.6).
|
||||
*/
|
||||
export type DifficultyProfile = 'hardcore';
|
||||
|
||||
export const ONLY_DIFFICULTY: DifficultyProfile = 'hardcore';
|
||||
|
||||
/** Convenience map for looking up the red/green/yellow state that unlocks each move-speed bucket. */
|
||||
export const COLOR_STATE_MOVE_BUCKET: Record<PlayerColorState, number> = {
|
||||
[PlayerColorState.Red]: 100,
|
||||
[PlayerColorState.Green]: 100,
|
||||
[PlayerColorState.Yellow]: 150,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f26544af-4281-4c60-b85e-1cf89aadda32",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Data layer — TypeScript interfaces, data-driven JSON loaders, and schema
|
||||
* validators. See `Interfaces.ts` for the contract and `ConfigMgr.ts` for
|
||||
* the runtime loader + validator.
|
||||
*/
|
||||
|
||||
export * from './Interfaces';
|
||||
export * from './ConfigMgr';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d907f3f3-211b-46eb-be38-01e62ae11409",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user