first commmit

This commit is contained in:
jakciehan
2026-05-06 08:17:32 +08:00
commit 427a33c55b
269 changed files with 20906 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import { _decorator, Component, director } from 'cc';
import { ConfigMgr } from '../data/ConfigMgr';
import { CCJsonLoader } from './CCJsonLoader';
import { BossController } from '../logic/BossController';
import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry';
import { Color } from 'cc';
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
const { ccclass, property } = _decorator;
/**
* Boss scene entry (task 7.3 / 8.2 hookup, req 9.x + 14.x).
*
* Attach to the root node of `Boss_ShuangHuanFang.scene`. Default bossId
* matches the chapter-1 final boss.
*
* When `autoBuildUI` is enabled, two temporary debug buttons are placed on
* screen so the flow can be validated before the combat view layer lands:
* - Left side: "Hit Butterfly" → `onButterflyHit`
* - Right side: "Hit Body" → `onBodyHit`
* These will be removed once the real combat HUD is built.
*/
@ccclass('BossEntry')
export class BossEntry extends Component {
@property({ tooltip: '对应 configs/bosses.json 中的 id' })
public bossId: string = 'shuang_huan_fang';
@property({ tooltip: '是否自动创建调试按钮 (战斗 HUD 就绪前使用)' })
public autoBuildUI: boolean = true;
private ctrl: BossController | undefined;
protected async onLoad(): Promise<void> {
if (this.autoBuildUI) this.buildDefaultUI();
const cfg = new ConfigMgr(new CCJsonLoader());
await cfg.load();
this.ctrl = new BossController(cfg.boss(this.bossId));
}
/** Dev-hook: attack landed on the butterfly (req 9.2). */
public onButterflyHit(): void {
const events = this.ctrl?.onButterflyHit() ?? [];
this.processEvents(events);
}
/** Dev-hook: attack landed on boss body (req 9.3). */
public onBodyHit(): void {
const events = this.ctrl?.onBodyHit() ?? [];
this.processEvents(events);
}
private processEvents(events: ReturnType<NonNullable<BossController['onBodyHit']>>): void {
for (const ev of events) {
if (ev.kind === 'boss_killed') {
director.loadScene('Settlement');
return;
}
}
}
private buildDefaultUI(): void {
ensureCanvasSize(this.node);
createLabel(this.node, 'BOSS · 双幻坊', 0, DESIGN_HEIGHT / 2 - 60, 32, Color.WHITE);
createLabel(
this.node,
'调试:先击中蝴蝶 → 再击中本体',
0,
DESIGN_HEIGHT / 2 - 110,
18,
new Color(200, 200, 200, 255),
);
// Left / right debug buttons, 180px off center.
createButton(this.node, 'Hit Butterfly', -180, -120, 220, 70, () => this.onButterflyHit());
createButton(this.node, 'Hit Body', 180, -120, 220, 70, () => this.onBodyHit());
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1ac5856f-147f-410c-8b8b-8de267952b40",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,23 @@
import { resources, JsonAsset } from 'cc';
import { IJsonLoader } from '../data/ConfigMgr';
/**
* Cocos Creator backed JSON loader. Used by Scene Entry components to feed
* `ConfigMgr`. Production-only: unit tests inject `MapJsonLoader` instead.
*
* The path parameter matches what `ConfigMgr` requests, e.g. `configs/enemies`.
* Cocos resolves it against the `assets/resources/` root.
*/
export class CCJsonLoader implements IJsonLoader {
public load<T>(path: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
resources.load(path, JsonAsset, (err: Error | null, asset: unknown) => {
if (err || !asset) {
reject(err ?? new Error(`CCJsonLoader: asset not found at ${path}`));
return;
}
resolve((asset as JsonAsset).json as T);
});
});
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "933a1f2f-13d2-4c18-9a1d-62abd39776b2",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,84 @@
import { _decorator, Component, director, Color, Label, Node } from 'cc';
import { ConfigMgr } from '../data/ConfigMgr';
import { CCJsonLoader } from './CCJsonLoader';
import { LevelMgr } from '../logic/LevelMgr';
import { ensureCanvasSize, createLabel } from './MainMenuEntry';
import { DESIGN_HEIGHT } from '../common/Constants';
const { ccclass, property } = _decorator;
/**
* Generic Level scene entry (task 7.1-7.2 hookup).
*
* Attach to the root node of `Level_1_1` … `Level_1_5`. Configure the
* `levelId` property in the Inspector ("1-1", "1-2", ...).
*
* For MVP the scene has no real gameplay rendering yet; `autoBuildUI`
* simply draws a top-centered label with the current level id so you can
* verify scene transitions by eye.
*/
@ccclass('LevelEntry')
export class LevelEntry extends Component {
@property({ tooltip: '本关的 levelId (与 configs/levels.json 对应),如 1-1 / 1-2 / 1-3 / 1-4 / 1-5' })
public levelId: string = '1-1';
@property({ tooltip: '胜利后跳转的场景,留空则按 1-1 → 1-2 → ... → 1-5 → Boss 自动推导' })
public nextSceneName: string = '';
@property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' })
public autoBuildUI: boolean = true;
private mgr: LevelMgr | undefined;
private hudNode: Node | null = null;
protected async onLoad(): Promise<void> {
if (this.autoBuildUI) this.buildDefaultUI();
const cfg = new ConfigMgr(new CCJsonLoader());
await cfg.load();
this.mgr = new LevelMgr(cfg.level(this.levelId));
}
protected update(dt: number): void {
if (!this.mgr) return;
const status = this.mgr.tick(dt);
this.refreshHud();
if (status === 'victory') {
director.loadScene(this.nextSceneName || this.deriveNextScene());
} else if (status === 'timeout' || status === 'player_dead') {
director.loadScene('Settlement');
}
}
private deriveNextScene(): string {
const map: Record<string, string> = {
'1-1': 'Level_1_2',
'1-2': 'Level_1_3',
'1-3': 'Level_1_4',
'1-4': 'Level_1_5',
'1-5': 'Boss_ShuangHuanFang',
};
return map[this.levelId] ?? 'Settlement';
}
private buildDefaultUI(): void {
ensureCanvasSize(this.node);
createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE);
this.hudNode = createLabel(
this.node,
'Time: --',
0,
DESIGN_HEIGHT / 2 - 90,
22,
new Color(255, 220, 120, 255),
);
}
private refreshHud(): void {
if (!this.hudNode || !this.mgr) return;
const lb = this.hudNode.getComponent(Label);
if (lb) {
const r = this.mgr.result();
lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)}`;
}
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "621ccf9a-6948-463f-9e06-bbb94027a369",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,182 @@
import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics } from 'cc';
import { UIFlowMgr, ISceneEnter } from '../ui/UIFlowMgr';
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
const { ccclass, property } = _decorator;
/** Maps abstract SceneId → physical Cocos Creator scene name. */
const SCENE_MAP: Record<string, string> = {
boot: 'Boot',
story_intro: 'StoryIntro',
main_menu: 'MainMenu',
// level_select currently reuses MainMenu until a dedicated LevelSelect scene exists.
level_select: 'MainMenu',
// `gameplay` is dispatched by levelId — resolved at runtime.
gameplay: 'Level_1_1',
settlement: 'Settlement',
// Settings panel is overlayed on MainMenu for the MVP.
settings: 'MainMenu',
};
/** levelId → physical scene name mapping (chapter 1 only). */
const LEVEL_SCENE_MAP: Record<string, string> = {
'1-1': 'Level_1_1',
'1-2': 'Level_1_2',
'1-3': 'Level_1_3',
'1-4': 'Level_1_4',
'1-5': 'Level_1_5',
'1-5-boss': 'Boss_ShuangHuanFang',
};
/**
* MainMenu scene entry (task 9.2 hookup).
*
* Owns a `UIFlowMgr` instance and translates each abstract `onSceneEnter`
* callback into a concrete `director.loadScene` call. Attach this component
* to the root node of `MainMenu.scene`.
*
* When `autoBuildUI` is enabled (default), two centered buttons (Start /
* Settings) and a title label are created programmatically so the scene is
* usable out of the box even before any art pass.
*/
@ccclass('MainMenuEntry')
export class MainMenuEntry extends Component {
@property({ tooltip: '是否自动生成 Start / Settings 按钮 (方便没美术时就能跑通)' })
public autoBuildUI: boolean = true;
private flow: UIFlowMgr | undefined;
protected onLoad(): void {
this.flow = new UIFlowMgr(undefined, {
onSceneEnter: (ev) => this.handleSceneEnter(ev),
});
if (this.autoBuildUI) this.buildDefaultUI();
}
/** Bind this to the "Start" button's click event in the Inspector. */
public onPressStart(): void {
this.flow?.onPressStartGame();
}
/** Bind this to the "Settings" button's click event. */
public onPressSettings(): void {
this.flow?.onOpenSettings();
}
private handleSceneEnter(ev: ISceneEnter): void {
const payload = ev.payload ?? {};
if (ev.scene === 'gameplay' && typeof payload.levelId === 'string') {
const physical = LEVEL_SCENE_MAP[payload.levelId];
if (physical) {
director.loadScene(physical);
return;
}
}
const physical = SCENE_MAP[ev.scene];
if (physical) director.loadScene(physical);
}
// ------------------------------------------------------------------
// Auto-built UI (development affordance; art pass will replace it)
// ------------------------------------------------------------------
private buildDefaultUI(): void {
// Ensure the host node has a UITransform matching the design resolution.
ensureCanvasSize(this.node);
// Title.
createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE);
// Start button (centered, 40 above origin).
createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart());
// Settings button (centered, 40 below origin).
createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings());
// Hint line at the bottom.
createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255));
}
}
// ======================================================================
// Shared UI helpers — intentionally kept inline (no external module) so
// each Scene Entry stays self-contained and easy to remove once real UI is
// authored in the editor.
// ======================================================================
function ensureCanvasSize(host: Node): void {
let ut = host.getComponent(UITransform);
if (!ut) ut = host.addComponent(UITransform);
ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT);
host.setPosition(0, 0, 0);
}
function createLabel(parent: Node, text: string, x: number, y: number, fontSize: number, color: Color): Node {
const n = new Node('AutoLabel');
n.layer = parent.layer;
parent.addChild(n);
const ut = n.addComponent(UITransform);
ut.setContentSize(DESIGN_WIDTH, fontSize * 1.6);
const lb = n.addComponent(Label);
lb.useSystemFont = true;
lb.string = text;
lb.fontSize = fontSize;
lb.lineHeight = Math.floor(fontSize * 1.2);
lb.color = color;
lb.horizontalAlign = 1; // CENTER
lb.verticalAlign = 1; // CENTER
n.setPosition(new Vec3(x, y, 0));
return n;
}
function createButton(
parent: Node,
text: string,
x: number,
y: number,
w: number,
h: number,
onClick: () => void
): Node {
const n = new Node(`Btn_${text}`);
n.layer = parent.layer;
parent.addChild(n);
const ut = n.addComponent(UITransform);
ut.setContentSize(w, h);
// Background drawn via Graphics — avoids needing any texture asset.
const g = n.addComponent(Graphics);
g.fillColor = new Color(40, 40, 60, 230);
g.rect(-w / 2, -h / 2, w, h);
g.fill();
g.strokeColor = new Color(200, 200, 220, 255);
g.lineWidth = 2;
g.rect(-w / 2, -h / 2, w, h);
g.stroke();
// Label child for the text.
const labelNode = new Node('Label');
labelNode.layer = parent.layer;
n.addChild(labelNode);
const lut = labelNode.addComponent(UITransform);
lut.setContentSize(w, h);
const lb = labelNode.addComponent(Label);
lb.useSystemFont = true;
lb.string = text;
lb.fontSize = 24;
lb.lineHeight = 28;
lb.color = Color.WHITE;
lb.horizontalAlign = 1;
lb.verticalAlign = 1;
const btn = n.addComponent(Button);
btn.transition = Button.Transition.SCALE;
btn.target = n;
btn.zoomScale = 0.95;
n.on(Node.EventType.TOUCH_END, onClick, n);
n.setPosition(new Vec3(x, y, 0));
return n;
}
// Re-export helpers so sibling Scene Entries can reuse them.
export { ensureCanvasSize, createLabel, createButton };
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "3dc13e83-a51d-4530-ae5d-1ebceb69550f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,69 @@
import { _decorator, Component, director, Label, Node, Color } from 'cc';
import { ChapterSettlement, ISettlementStats } from '../logic/ChapterSettlement';
import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry';
import { DESIGN_HEIGHT } from '../common/Constants';
const { ccclass, property } = _decorator;
/**
* Settlement scene entry (task 8.2 hookup, req 14.x).
*
* Attach to the root of `Settlement.scene`. All UI is auto-built by default
* (title, score, closing line, back-to-menu button). If `autoBuildUI` is
* disabled and you wire up `scoreLabelNode` / `closingLabelNode` manually,
* those wins.
*/
@ccclass('SettlementEntry')
export class SettlementEntry extends Component {
@property({ type: Node, tooltip: '得分 Label 节点 (可留空自动创建)' })
public scoreLabelNode: Node | null = null;
@property({ type: Node, tooltip: '结局旁白 Label 节点 (可留空自动创建)' })
public closingLabelNode: Node | null = null;
@property({ tooltip: '是否自动创建结算界面 (Label + Back 按钮)' })
public autoBuildUI: boolean = true;
protected onLoad(): void {
if (this.autoBuildUI) this.buildDefaultUI();
const defaultStats: ISettlementStats = {
totalScore: 0,
stageScore: 0,
comboCount: 0,
flawless: true,
remainingTimeSec: 0,
};
const settlement = new ChapterSettlement(defaultStats);
const result = settlement.build();
this.setLabel(this.scoreLabelNode, `Stage Score: ${result.stats.stageScore}`);
this.setLabel(this.closingLabelNode, result.closingLine);
}
/** Bind to a "Back to Menu" button. */
public onReturnToMenu(): void {
director.loadScene('MainMenu');
}
private setLabel(node: Node | null, text: string): void {
if (!node) return;
const label = node.getComponent(Label);
if (label) label.string = text;
}
private buildDefaultUI(): void {
ensureCanvasSize(this.node);
// Title.
createLabel(this.node, '章 节 结 算', 0, DESIGN_HEIGHT / 2 - 70, 40, Color.WHITE);
// Score label (created only if inspector did not supply one).
if (!this.scoreLabelNode) {
this.scoreLabelNode = createLabel(this.node, '', 0, 40, 30, new Color(255, 220, 120, 255));
}
// Closing line label.
if (!this.closingLabelNode) {
this.closingLabelNode = createLabel(this.node, '', 0, -30, 24, new Color(200, 200, 200, 255));
}
// Bottom "Back to Menu" button.
createButton(this.node, 'Back to Menu', 0, -DESIGN_HEIGHT / 2 + 80, 240, 60, () => this.onReturnToMenu());
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cab3e1e4-d2db-4b2f-bb14-179cfb6243eb",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,90 @@
import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform } from 'cc';
import { ConfigMgr } from '../data/ConfigMgr';
import { CCJsonLoader } from './CCJsonLoader';
import { StorySceneCtrl } from '../ui/StorySceneCtrl';
import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry';
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
const { ccclass, property } = _decorator;
/**
* StoryIntro scene entry (task 9.1 hookup, req 19.x).
*
* Attach to the root of `StoryIntro.scene`. Inspector:
* - labelNode: optional — if left empty and `autoBuildUI` is true, a
* centered Label and a bottom-right "Skip" button are auto-created.
* - storyId: story id in `configs/stories.json` (default `chapter_1_intro`).
*
* Tap anywhere → accelerate / advance (req 19.3).
* "Skip" button → finish immediately (req 19.4).
*/
@ccclass('StorySceneEntry')
export class StorySceneEntry extends Component {
@property({ type: Node, tooltip: '打字机 Label 节点 (可留空,自动创建)' })
public labelNode: Node | null = null;
@property({ tooltip: '对应 configs/stories.json 中的 id' })
public storyId: string = 'chapter_1_intro';
@property({ tooltip: '是否自动创建 Label / Skip 按钮 / 背景遮罩' })
public autoBuildUI: boolean = true;
private ctrl: StorySceneCtrl | undefined;
protected async onLoad(): Promise<void> {
if (this.autoBuildUI && !this.labelNode) {
this.buildDefaultUI();
}
// Full-screen tap accelerator: listen on the host node itself.
this.node.on(Node.EventType.TOUCH_END, () => this.onTap(), this);
const cfg = await this.loadStoryConfig();
this.ctrl = new StorySceneCtrl(cfg, undefined, {
onTextChanged: (text) => this.updateLabel(text),
onFinished: () => director.loadScene('Level_1_1'),
});
const outcome = this.ctrl.start();
if (outcome === 'already_seen') {
director.loadScene('Level_1_1');
}
}
protected update(dt: number): void {
this.ctrl?.tick(dt);
}
/** Tap handler (called by auto-built full-screen listener or external). */
public onTap(): void {
this.ctrl?.onTap();
}
/** Bound to the "Skip" button. */
public onSkip(): void {
this.ctrl?.onSkip();
}
private updateLabel(text: string): void {
if (!this.labelNode) return;
const label = this.labelNode.getComponent(Label);
if (label) label.string = text;
}
private async loadStoryConfig() {
const mgr = new ConfigMgr(new CCJsonLoader());
await mgr.load();
return mgr.story(this.storyId);
}
private buildDefaultUI(): void {
ensureCanvasSize(this.node);
// Ensure the root node can receive touch (size = design resolution).
// Central typewriter label.
this.labelNode = createLabel(this.node, '', 0, 0, 28, Color.WHITE);
const ut = this.labelNode.getComponent(UITransform);
if (ut) ut.setContentSize(DESIGN_WIDTH - 80, DESIGN_HEIGHT - 120);
// Skip button at bottom-right.
const skipX = DESIGN_WIDTH / 2 - 90;
const skipY = -DESIGN_HEIGHT / 2 + 50;
createButton(this.node, 'Skip >>', skipX, skipY, 140, 50, () => this.onSkip());
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2c3b5227-420d-4421-a500-36519776ea9d",
"files": [],
"subMetas": {},
"userData": {}
}