Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -1,19 +1,48 @@
import {
_decorator, Component, Node, Canvas, Camera, UITransform, view, Color,
director, Label, find,
_decorator, Component, Node, Camera, Canvas, UITransform, view, Color,
director, find, Layers,
} from 'cc';
import { GameController } from './GameController';
import { GameManager } from './manager/GameManager';
import { VisualAssets } from './visual/VisualAssets';
import { ViewController } from './controller/ViewController';
import { LineGridRenderer } from './gameplay/LineGridRenderer';
import { UIMain } from './ui/UIMain';
import { loadLevelDatabase, refreshLevelIdBounds, LEVEL_ID_BASE } from './level/LevelRegistry';
import { loadThemeDatabase } from './theme/ThemeRegistry';
import { ensureResourcesBundle } from './core/ResourcesBundle';
import { loadTileDisplayMeta } from './visual/TileDisplayMeta';
import { ThemeBackground } from './theme/ThemeBackground';
import { GridSnapHelper } from './level/GridSnapHelper';
import { GameAudio } from './audio/GameAudio';
import { GameplayDebugBar } from './ui/GameplayDebugBar';
import {
CELL_PIXEL, CAMERA_ORTHO_HALF, DESIGN_WIDTH, DESIGN_HEIGHT,
} from './core/GridConstants';
import { applyEmbeddedDesignResolution } from './core/EmbeddedView';
export {
CELL_PIXEL, CAMERA_ORTHO_HALF, DESIGN_WIDTH, DESIGN_HEIGHT,
} from './core/GridConstants';
const { ccclass, executionOrder } = _decorator;
/** 每格像素尺寸UI 坐标) */
export const CELL_PIXEL = 56;
/** HUD 专用层:与关卡 UI_2D 分离,不受主相机缩放/拖拽影响 */
const HUD_LAYER = Layers.Enum.UI_3D;
/** 背景专用层BgCamera 固定正交,主相机缩放/平移时不跟随 */
const BG_LAYER = Layers.Enum.DEFAULT;
@ccclass('AppBootstrap')
@executionOrder(-100)
export class AppBootstrap extends Component {
private readonly onCanvasResize = () => {
const sync = (globalThis as { __tfrhSyncEmbeddedCanvas?: () => void }).__tfrhSyncEmbeddedCanvas;
if (typeof sync === 'function') sync();
else applyEmbeddedDesignResolution();
};
onDestroy() {
view.off('canvas-resize', this.onCanvasResize, this);
}
async onLoad() {
try {
await this.bootstrap();
@@ -24,94 +53,200 @@ export class AppBootstrap extends Component {
private async bootstrap() {
console.log('[AppBootstrap] 开始初始化…');
await VisualAssets.preload();
applyEmbeddedDesignResolution();
view.on('canvas-resize', this.onCanvasResize, this);
await ensureResourcesBundle();
await loadThemeDatabase();
await loadTileDisplayMeta();
await loadLevelDatabase();
refreshLevelIdBounds();
await GameAudio.preload();
const scene = director.getScene()!;
find('UICanvas', scene)?.destroy();
let mainCam = find('Main Camera', scene)?.getComponent(Camera) ?? null;
if (!mainCam) {
const camNode = new Node('Main Camera');
camNode.parent = scene;
mainCam = camNode.addComponent(Camera);
} else if (mainCam.node.parent !== scene) {
mainCam.node.parent = scene;
}
this.setupCamera(mainCam);
mainCam.node.getComponent(ViewController) ?? mainCam.node.addComponent(ViewController);
const light = find('Main Light', scene);
if (light) light.active = false;
let canvasNode = find('Canvas', scene);
if (!canvasNode) {
canvasNode = new Node('Canvas');
canvasNode.parent = scene;
canvasNode.addComponent(Canvas);
}
const canvas = canvasNode.getComponent(Canvas)!;
canvas.cameraComponent = mainCam;
let canvasUi = canvasNode.getComponent(UITransform);
if (!canvasUi) canvasUi = canvasNode.addComponent(UITransform);
const size = view.getVisibleSize();
canvasUi.setContentSize(size.width, size.height);
const gameRoot = this.ensureGameRoot(scene, size, mainCam);
this.ensureBgOverlay(scene, size, mainCam);
const uiOverlay = this.ensureUIOverlay(scene, size, mainCam);
this.bindAllCanvases(scene, mainCam);
let gameRoot = canvasNode.getChildByName('GameRoot');
if (!gameRoot) {
gameRoot = new Node('GameRoot');
gameRoot.parent = canvasNode;
const grUi = gameRoot.addComponent(UITransform);
grUi.setContentSize(size.width, size.height);
}
const host = this.node;
const ctl = host.getComponent(GameController) ?? host.addComponent(GameController);
let gcNode = scene.getChildByName('GameController');
if (!gcNode) {
gcNode = new Node('GameController');
gcNode.parent = scene;
gcNode.addComponent(GameManager);
gcNode.addComponent(GameController);
}
const gctl = gcNode.getComponent(GameController);
const gm = gcNode.getComponent(GameManager)!;
if (gctl) {
if (gctl.mainLevelEntrance) gm.mainLevelEntrance = gctl.mainLevelEntrance;
gm.initialLevelID = gctl.initialLevelID;
gm.playerSkin = gctl.playerSkin;
}
let entrance = gameRoot.getChildByName('MainLevelEntrance');
if (!entrance) {
entrance = new Node('MainLevelEntrance');
entrance.parent = gameRoot;
const eUi = entrance.addComponent(UITransform);
eUi.setContentSize(size.width, size.height);
entrance.addComponent(UITransform).setContentSize(size.width, size.height);
}
gm.mainLevelEntrance = entrance;
gm.initialLevelID = 1;
entrance.layer = Layers.Enum.UI_2D;
LineGridRenderer.ensure(entrance);
GridSnapHelper.purgeRuntimeGrids(entrance);
this.ensureHint(canvasNode);
ctl.mainLevelEntrance = entrance;
if (ctl.initialLevelID <= 0) ctl.initialLevelID = LEVEL_ID_BASE;
if (!ctl.inputLevel?.trim()) ctl.inputLevel = String(ctl.initialLevelID);
await gm.createNewLevel(gm.initialLevelID);
console.log('[AppBootstrap] 关卡已加载');
this.removeRuntimeOverlayUI(gameRoot);
gameRoot.getChildByName('UIMain')?.destroy();
UIMain.ensure(uiOverlay);
GameplayDebugBar.ensure(uiOverlay);
ThemeBackground.purgeStaleNodes(entrance);
// 关卡预制体由 SwitchLevel → createNewLevel 按需加载loader 进关再下 levels_all
ctl.markReady();
ctl.onBootstrapReady();
console.log('[AppBootstrap] 引擎已就绪SwitchLevel 进关时再加载关卡预制体');
}
private ensureGameRoot(scene: Node, size: { width: number; height: number }, mainCam: Camera): Node {
let gameRoot = find('GameRoot', scene) ?? find('Canvas', scene);
if (!gameRoot) {
gameRoot = new Node('GameRoot');
gameRoot.parent = scene;
}
gameRoot.name = 'GameRoot';
gameRoot.layer = Layers.Enum.UI_2D;
gameRoot.active = true;
const ui = gameRoot.getComponent(UITransform) ?? gameRoot.addComponent(UITransform);
ui.setContentSize(size.width, size.height);
const canvas = gameRoot.getComponent(Canvas) ?? gameRoot.addComponent(Canvas);
canvas.cameraComponent = mainCam;
canvas.alignCanvasWithScreen = true;
return gameRoot;
}
/** 固定背景相机:先于主相机绘制,缩放/拖拽关卡时背景不动 */
private ensureBgOverlay(scene: Node, size: { width: number; height: number }, mainCam: Camera): Node {
let bgCamNode = find('BgCamera', scene);
if (!bgCamNode) {
bgCamNode = new Node('BgCamera');
bgCamNode.parent = scene;
}
bgCamNode.setPosition(0, 0, 1000);
bgCamNode.setRotationFromEuler(0, 0, 0);
const bgCam = bgCamNode.getComponent(Camera) ?? bgCamNode.addComponent(Camera);
bgCam.projection = Camera.ProjectionType.ORTHO;
bgCam.orthoHeight = CAMERA_ORTHO_HALF;
bgCam.near = 1;
bgCam.far = 2000;
bgCam.priority = mainCam.priority - 10;
bgCam.clearFlags = Camera.ClearFlag.SOLID_COLOR;
bgCam.clearColor = new Color(1, 1, 1, 255);
bgCam.visibility = BG_LAYER;
let overlay = find('BgOverlay', scene);
if (!overlay) {
overlay = new Node('BgOverlay');
overlay.parent = scene;
}
overlay.layer = BG_LAYER;
overlay.active = true;
const ui = overlay.getComponent(UITransform) ?? overlay.addComponent(UITransform);
ui.setContentSize(size.width, size.height);
const canvas = overlay.getComponent(Canvas) ?? overlay.addComponent(Canvas);
canvas.cameraComponent = bgCam;
canvas.alignCanvasWithScreen = true;
return overlay;
}
/** 固定 HUD 相机:缩放/平移主相机时按钮位置不变(对齐 Unity Screen Space Overlay */
private ensureUIOverlay(scene: Node, size: { width: number; height: number }, mainCam: Camera): Node {
let uiCamNode = find('UICamera', scene);
if (!uiCamNode) {
uiCamNode = new Node('UICamera');
uiCamNode.parent = scene;
}
uiCamNode.setPosition(0, 0, 1000);
uiCamNode.setRotationFromEuler(0, 0, 0);
const uiCam = uiCamNode.getComponent(Camera) ?? uiCamNode.addComponent(Camera);
uiCam.projection = Camera.ProjectionType.ORTHO;
uiCam.orthoHeight = CAMERA_ORTHO_HALF;
uiCam.near = 1;
uiCam.far = 2000;
uiCam.priority = mainCam.priority + 10;
uiCam.clearFlags = Camera.ClearFlag.DEPTH_STENCIL;
uiCam.visibility = HUD_LAYER;
let overlay = find('UIOverlay', scene);
if (!overlay) {
overlay = new Node('UIOverlay');
overlay.parent = scene;
}
overlay.layer = HUD_LAYER;
overlay.active = true;
const ui = overlay.getComponent(UITransform) ?? overlay.addComponent(UITransform);
ui.setContentSize(size.width, size.height);
const canvas = overlay.getComponent(Canvas) ?? overlay.addComponent(Canvas);
canvas.cameraComponent = uiCam;
canvas.alignCanvasWithScreen = true;
return overlay;
}
private bindAllCanvases(scene: Node, mainCam: Camera) {
const uiCam = find('UICamera', scene)?.getComponent(Camera) ?? null;
const bgCam = find('BgCamera', scene)?.getComponent(Camera) ?? null;
for (const canvas of scene.getComponentsInChildren(Canvas)) {
if (canvas.node.name === 'BgOverlay') {
if (bgCam) canvas.cameraComponent = bgCam;
canvas.alignCanvasWithScreen = true;
continue;
}
if (canvas.node.name === 'UIOverlay') {
if (uiCam) canvas.cameraComponent = uiCam;
canvas.alignCanvasWithScreen = true;
continue;
}
if (!canvas.cameraComponent?.isValid) {
canvas.cameraComponent = mainCam;
}
canvas.alignCanvasWithScreen = true;
}
// 清理无相机 Canvas避免 PointerEventDispatcher 读 null.cameraPriority
for (const canvas of scene.getComponentsInChildren(Canvas)) {
if (!canvas.cameraComponent) {
canvas.destroy();
}
}
}
private removeRuntimeOverlayUI(gameRoot: Node) {
gameRoot.getChildByName('Hint')?.destroy();
gameRoot.getChildByName('LevelSwitchBar')?.destroy();
}
private setupCamera(cam: Camera) {
const camNode = cam.node;
camNode.setPosition(0, 0, 1000);
camNode.setRotationFromEuler(0, 0, 0);
camNode.layer = Layers.Enum.DEFAULT;
cam.projection = Camera.ProjectionType.ORTHO;
cam.orthoHeight = 360;
cam.orthoHeight = CAMERA_ORTHO_HALF;
cam.near = 1;
cam.far = 2000;
cam.clearFlags = Camera.ClearFlag.SOLID_COLOR;
cam.clearColor = new Color(30, 40, 60, 255);
cam.clearFlags = Camera.ClearFlag.DEPTH_STENCIL;
cam.visibility = Layers.Enum.UI_2D;
}
private ensureHint(canvasNode: Node) {
if (canvasNode.getChildByName('Hint')) return;
const hint = new Node('Hint');
hint.parent = canvasNode;
const ui = hint.addComponent(UITransform);
ui.setContentSize(400, 40);
hint.setPosition(0, 280, 0);
const label = hint.addComponent(Label);
label.string = '主站 Cocos · 关卡运行中';
label.fontSize = 22;
label.color = new Color(200, 220, 255);
switchLevelFromBootstrap() {
const gc = this.getComponent(GameController);
if (!gc) {
console.warn('[AppBootstrap] 未找到 GameController请先点预览 ▶');
return;
}
gc.clickSwitchLevel();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "87e5bae2-5b8d-4f00-964a-51a7b4576c20",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,123 @@
import { AudioClip, AudioSource, director, Node, resources } from 'cc';
/** Unity Assets/Art/Audio → resources/audio/ */
const CLIPS = {
background: 'audio/Backgroud',
move: 'audio/Move',
jump: 'audio/Jump',
vehicleMove: 'audio/FlyingCarpetMove',
fail: 'audio/Fail',
success: 'audio/Success',
coins: 'audio/GetCoins',
} as const;
type SfxKey = Exclude<keyof typeof CLIPS, 'background'>;
/**
* 对齐 Unity关卡加载时 curLevel 上循环播放 Backgroud.mp3
* 音效路径与 Player.prefab 一致。
*/
export class GameAudio {
private static readonly cache = new Map<string, AudioClip>();
private static readonly loading = new Map<string, Promise<AudioClip | null>>();
private static sfxHost: Node | null = null;
static async preload(): Promise<void> {
await Promise.all(Object.values(CLIPS).map((p) => GameAudio.loadClip(p)));
}
static loadClip(path: string): Promise<AudioClip | null> {
const hit = GameAudio.cache.get(path);
if (hit) return Promise.resolve(hit);
const pending = GameAudio.loading.get(path);
if (pending) return pending;
const task = new Promise<AudioClip | null>((resolve) => {
resources.load(path, AudioClip, (err, clip) => {
GameAudio.loading.delete(path);
if (err || !clip) {
console.warn(`[GameAudio] 加载失败: ${path}`, err);
resolve(null);
return;
}
GameAudio.cache.set(path, clip);
resolve(clip);
});
});
GameAudio.loading.set(path, task);
return task;
}
/** 在关卡根节点播放循环背景音乐(对齐 Unity createNewLevel */
static async playBackground(levelRoot: Node): Promise<void> {
if (!levelRoot?.isValid) return;
const clip = await GameAudio.loadClip(CLIPS.background);
if (!clip || !levelRoot.isValid) return;
let host = levelRoot.getChildByName('_BGM');
if (!host) {
host = new Node('_BGM');
host.parent = levelRoot;
}
const src = host.getComponent(AudioSource) ?? host.addComponent(AudioSource);
src.clip = clip;
src.loop = true;
src.playOnAwake = false;
src.volume = 1;
if (!src.playing) {
src.play();
}
}
static playSfx(key: SfxKey, host?: Node) {
void GameAudio.playSfxAsync(key, host);
}
/** 对齐 Unity同一 AudioSource 播放中则不重复触发移动/跳跃音效 */
static async playSfxOnSource(src: AudioSource, key: SfxKey): Promise<boolean> {
if (!src?.node?.isValid || src.playing) return false;
const clip = await GameAudio.loadClip(CLIPS[key]);
if (!clip) return false;
src.clip = clip;
src.loop = false;
src.volume = 1;
src.play();
return true;
}
private static async playSfxAsync(key: SfxKey, host?: Node) {
const clip = await GameAudio.loadClip(CLIPS[key]);
if (!clip) return;
const root = host?.isValid ? host : GameAudio.ensureSfxHost();
if (!root?.isValid) return;
const src = root.getComponent(AudioSource) ?? root.addComponent(AudioSource);
src.playOneShot(clip, 1);
}
private static ensureSfxHost(): Node | null {
if (GameAudio.sfxHost?.isValid) return GameAudio.sfxHost;
const scene = director.getScene();
if (!scene) return null;
let host = scene.getChildByName('_GameSFX');
if (!host) {
host = new Node('_GameSFX');
host.parent = scene;
}
GameAudio.sfxHost = host;
return host;
}
/** 浏览器需用户交互后才能播放音频,首次点击 HUD 时恢复 */
static resumeAll() {
const scene = director.getScene();
if (!scene) return;
for (const src of scene.getComponentsInChildren(AudioSource)) {
if (src.clip && !src.playing) src.play();
}
}
}

View File

@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "507007a3-8bd2-41d5-b962-44c38a653bbb",
"uuid": "cc3a7e36-4675-4700-b873-cb9168da30c7",
"files": [],
"subMetas": {},
"userData": {}

View File

@@ -3,6 +3,13 @@
* Web 环境下调用 window 上的全局回调
*/
/** 对齐 Unity GameManager.ExternalLevelInfo关卡加载完成时回传 */
export interface ExternalLevelInfo {
LevelID: number;
PlayerName: string;
VehicleName: string;
}
declare global {
interface Window {
processData?: (json: string) => void;
@@ -10,6 +17,8 @@ declare global {
externalResult?: (json: string) => void;
externalLevelInfo?: (json: string) => void;
coinsData?: (json: string) => void;
/** 主站对接cocos-bridge.js 可选钩子 */
__tfrhOnExternalLevelInfo?: (json: string) => void;
[key: string]: unknown;
}
}
@@ -25,4 +34,10 @@ export class JsBridge {
}
console.log(`[JsBridge] ${callbackName}`, jsonData);
}
/** Unity ExternalCallLevelInfo → Application.ExternalCall("externalLevelInfo", json) */
static notifyExternalLevelInfo(info: ExternalLevelInfo) {
const json = JSON.stringify(info);
this.call('externalLevelInfo', json);
}
}

View File

@@ -1,11 +1,25 @@
import { _decorator, Vec3 } from 'cc';
import { Direction, GameState, GridType, Skin } from '../core/Define';
import { _decorator, AudioSource, Vec3 } from 'cc';
import { Direction, GameState, GridType, MoveState, MoverRole, Skin, skinToTheme, themeToSkin } from '../core/Define';
import { EventManager, EventType } from '../core/EventManager';
import { JsBridge } from '../bridge/JsBridge';
import { GameAudio } from '../audio/GameAudio';
import { GameManager } from '../manager/GameManager';
import { Movement } from '../gameplay/Movement';
import { vehicleFollowsLandingTile, canStayMountedAfterLanding, moverRoleForLandingCell, shouldDismountOnTile } from '../gameplay/MoveRules';
import { VehicleController } from './VehicleController';
import { VisualAssets } from '../visual/VisualAssets';
import { PropController } from './PropController';
import { findVehicleAtCell, tryLinkPlayerVehicle } from './RideLink';
import { PlayerAction } from '../visual/PlayerAnimPaths';
import { PlayerActionAnimator } from '../visual/PlayerActionAnimator';
import { scaledJumpArcOffset, scaledMoveSpeed, UNITY_PLAYER_MOVE_SPEED, CELL_PIXEL, PROP_COLLECT_MOVE_PROGRESS, PROP_COLLECT_TOUCH_RADIUS } from '../core/GridConstants';
import { cellToWorldCenter } from '../core/GridCoords';
import { getThemePlayerRideYOffset } from '../theme/ThemeDatabase';
import {
entityWorldPositionForRole,
moverLogicalGridPositionForRole,
worldToMoverCellForRole,
} from '../level/EntitySpawnPlacement';
import { forEachLevelEntityNode } from '../level/TileLayout';
const { ccclass } = _decorator;
@@ -27,14 +41,26 @@ export interface ExternalResult {
isInputEnd: boolean;
}
/**
* 玩家移动(对齐 Unity PlayerController
* - 玩家节点为位移主体
* - 骑乘:仅目标空地联动载具;落砖下车;视觉 Y 抬高
* - 跳跃:只能落到 Jump 砖MoveRules + moveCondition
*/
@ccclass('PlayerController')
export class PlayerController extends Movement {
coins = 0;
private vehicle: VehicleController | null = null;
private sendFinally = false;
private animator: PlayerActionAnimator | null = null;
private sfxSource: AudioSource | null = null;
onLoad() {
this.moverRole = 'player';
this.moveSpeed = scaledMoveSpeed(UNITY_PLAYER_MOVE_SPEED);
this.animator = this.node.getComponent(PlayerActionAnimator)
?? this.node.addComponent(PlayerActionAnimator);
this.sfxSource = this.node.getComponent(AudioSource) ?? this.node.addComponent(AudioSource);
EventManager.register(EventType.LevelInit, this.onLevelInit);
EventManager.register(EventType.InputEnd, this.onInputEnd);
this.coins = 0;
@@ -47,72 +73,452 @@ export class PlayerController extends Movement {
start() {
super.start();
VisualAssets.applyPlayerSprite(this.node, this.direction);
if (this.node.name === 'Player' && GameManager.instance) {
this.callChangeSkin(GameManager.instance.playerSkin);
} else {
this.animator?.setAction(this.animator?.getAction() ?? PlayerAction.Idle);
}
}
override setDirection(dir: Direction) {
super.setDirection(dir);
VisualAssets.applyPlayerSprite(this.node, dir);
// —— 骑乘视觉(逻辑坐标在载具甲板,显示抬高) ——
private rideYOffset(): number {
const gm = GameManager.instance;
const theme = gm?.getCurLevel()?.theme ?? gm?.uiStyle;
return getThemePlayerRideYOffset(theme);
}
private rideDeckAtCell(cell: Vec3): Vec3 {
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
return entityWorldPositionForRole(cell, config ?? undefined, theme, 'vehicle');
}
/** 步行上载具时的落点(甲板 + 骑乘视觉 Y避免到格后再闪一下 */
private rideMountWorldPosition(cell: Vec3): Vec3 {
const deck = this.rideDeckAtCell(cell);
return new Vec3(deck.x, deck.y + this.rideYOffset(), deck.z);
}
private applyMountVisualAtDeck(deck: Vec3) {
this.node.setPosition(deck.x, deck.y + this.rideYOffset(), deck.z);
}
/** 骑乘时逻辑甲板世界坐标(无视觉抬高) */
getRideDeckWorldPosition(): Vec3 {
if (this.vehicle) return this.vehicle.node.position.clone();
const p = this.node.position;
return new Vec3(p.x, p.y, p.z);
}
/** 是否处于「载具跟随」状态(仅 Idle 时锁定到甲板;移动中由 OnMoving 驱动载具) */
private isVehicleLinked(): boolean {
return !!this.vehicle && this.moveState !== MoveState.Moving;
}
snapToRideBase() {
if (!this.vehicle) return;
this.applyMountVisualAtDeck(this.vehicle.node.position);
}
applyRideVisual() {
if (!this.vehicle) return;
this.applyMountVisualAtDeck(this.vehicle.node.position);
}
/** 载具换向/flip 后重对齐骑乘高度 */
syncRideMountVisual() {
this.applyRideVisual();
}
setRideBaseFromVehicle(base: Vec3) {
this.node.setPosition(base);
this.applyRideVisual();
}
private syncVehicleToPlayerBase() {
if (!this.vehicle) return;
const p = this.node.position;
const off = this.rideYOffset();
this.vehicle.node.setPosition(p.x, p.y - off, p.z);
}
// —— 格子采样:骑乘期间始终以载具甲板为逻辑位置 ——
protected override getGridSamplePosition(): Vec3 {
// 优先 committedCell空地格 Y 补偿会让 worldToMoverCell 误判邻格(如 (-1,-1)→(0,0)
if (this.committedCell) {
return cellToWorldCenter(this.committedCell);
}
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
if (this.vehicle) {
return moverLogicalGridPositionForRole(
this.vehicle.node.position,
config ?? undefined,
theme,
'vehicle',
);
}
return moverLogicalGridPositionForRole(
this.node.position,
config ?? undefined,
theme,
'player',
);
}
update(dt: number) {
if (this.isVehicleLinked()) {
this.applyMountVisualAtDeck(this.vehicle!.node.position);
}
super.update(dt);
}
protected override shouldApplyPlayerStandOffset(): boolean {
if (!this.vehicle) return true;
if (this.moveState !== MoveState.Moving) return false;
return !vehicleFollowsLandingTile(this.targetGridType);
}
setRideVehicle(v: VehicleController | null) {
if (this.vehicle && this.vehicle !== v) {
this.vehicle.setPlayer(null);
}
this.vehicle = v;
if (v) {
v.setPlayer(this);
Movement.callEach = true;
v.setDirection(this.direction);
Movement.callEach = false;
}
}
getRideVehicle(): VehicleController | null {
return this.vehicle;
}
// —— 上下车 ——
private unbindVehicle() {
if (!this.vehicle) return;
this.vehicle.setPlayer(null);
this.vehicle = null;
}
private alignWithVehicleBase() {
if (!this.vehicle || !this.committedCell) return;
const deck = this.rideDeckAtCell(this.committedCell);
this.vehicle.node.setPosition(deck);
this.vehicle.shareCommittedCell(this.committedCell);
this.node.setPosition(deck);
this.applyRideVisual();
}
/** Unity CheckIfCurIsRide仅脚下为 Ride 动态格时绑定载具 */
private checkIfCurIsRide() {
if (this.curGrid !== GridType.Ride || !this.committedCell) return;
const gm = GameManager.instance!;
const vc = findVehicleAtCell(this.committedCell)
?? gm.getGameObjectAtCell(this.committedCell)?.getComponent(VehicleController);
if (!vc) return;
this.setRideVehicle(vc);
vc.setPlayer(this);
vc.setDirection(this.direction);
this.alignWithVehicleBase();
}
private onLevelInit = () => {
this.checkRide();
this.sendFinally = false;
this.coins = 0;
this.resetMoveRuntime();
this.unbindVehicle();
this.refreshVisual(PlayerAction.Idle);
this.syncCommittedCellFromPosition();
this.snapMoverToCellStand();
this.checkIfCurIsRide();
tryLinkPlayerVehicle(this);
};
private onInputEnd = () => {
this.externalCallResult(false);
const gm = GameManager.instance!;
this.externalCallResult(gm.allPropsCollected());
};
protected override snapMoverToCellStand() {
const gm = GameManager.instance;
if (!gm || !this.committedCell) return;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const landingLive = gm.calculateGridTypeAtCell(this.committedCell);
if (canStayMountedAfterLanding(landingLive)) {
const deck = this.rideDeckAtCell(this.committedCell);
if (this.vehicle) {
this.vehicle.node.setPosition(deck);
this.vehicle.shareCommittedCell(this.committedCell);
const mount = this.rideMountWorldPosition(this.committedCell);
this.node.setPosition(mount);
this.targetPosition.set(mount);
} else {
const mount = this.rideMountWorldPosition(this.committedCell);
this.node.setPosition(mount);
this.targetPosition.set(mount);
}
return;
}
const follow = !!this.vehicle && vehicleFollowsLandingTile(landingLive);
const deck = entityWorldPositionForRole(this.committedCell, config ?? undefined, theme, 'vehicle');
if (follow) {
this.vehicle!.node.setPosition(deck);
this.vehicle!.shareCommittedCell(this.committedCell);
const mount = this.rideMountWorldPosition(this.committedCell);
this.node.setPosition(mount);
this.targetPosition.set(mount);
return;
}
const pos = entityWorldPositionForRole(this.committedCell, config ?? undefined, theme, 'player');
this.node.setPosition(pos);
this.targetPosition.set(pos);
}
protected override resolveMoveStepOrigin(_targetCell: Vec3, landingGrid: GridType): Vec3 {
if (this.vehicle && vehicleFollowsLandingTile(landingGrid) && this.committedCell) {
return this.rideMountWorldPosition(this.committedCell);
}
// 下车:从骑乘视觉高度平滑移到砖面,勿先 snap 到甲板
if (this.vehicle && shouldDismountOnTile(landingGrid) && this.committedCell) {
return this.rideMountWorldPosition(this.committedCell);
}
return this.node.position.clone();
}
protected override resolveTargetWorldPosition(targetCell: Vec3, landingGrid: GridType): Vec3 {
if (this.vehicle && vehicleFollowsLandingTile(landingGrid)) {
return this.rideMountWorldPosition(targetCell);
}
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const role: MoverRole = moverRoleForLandingCell('player', landingGrid, !!this.vehicle);
if (!this.vehicle && landingGrid === GridType.Ride) {
return this.rideMountWorldPosition(targetCell);
}
return entityWorldPositionForRole(targetCell, config ?? undefined, theme, role);
}
protected override onRotateComplete() {
if (this.vehicle) {
this.vehicle.setDirection(this.direction);
if (this.committedCell) this.alignWithVehicleBase();
}
}
/** 是否应拾取该道具(移动中需进入格子且接近触碰,避免起步即消失) */
canCollectProp(prop: PropController): boolean {
const propCell = prop.getSpawnCell();
if (!propCell) return false;
const committed = this.getCommittedCell();
if (!this.isMoving()) {
return !!committed && prop.matchesCell(committed);
}
const landing = this.getLandingCell();
if (!landing || !prop.matchesCell(landing)) return false;
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const sample = worldToMoverCellForRole(this.node.position, config ?? undefined, theme, 'player');
if (!prop.matchesCell(sample)) return false;
if (this.getMoveStepProgress() < PROP_COLLECT_MOVE_PROGRESS) return false;
const touchR = CELL_PIXEL * PROP_COLLECT_TOUCH_RADIUS;
return Vec3.distance(this.node.position, prop.node.position) <= touchR;
}
private collectPropsOnApproach() {
const gm = GameManager.instance;
const level = gm?.curLevel;
if (!level) return;
forEachLevelEntityNode(level, (ch) => {
const prop = ch.getComponent(PropController);
if (!prop || !this.canCollectProp(prop)) return;
prop.collect(this);
});
}
protected onMoveNextSet(isJump: boolean) {
if (isJump && this.targetGridType === GridType.Jump && this.vehicle) {
this.unbindVehicle();
}
this.animator?.setAction(isJump ? PlayerAction.Jump : PlayerAction.Move);
if (isJump && this.targetGridType === GridType.Jump) {
const p = this.node.worldPosition.clone();
p.y += 0.15;
this.targetPosition.set(p);
this.targetPosition.y += scaledJumpArcOffset();
}
}
protected override playMoveAnim() {
this.animator?.setAction(PlayerAction.Move);
}
protected override syncRideAfterMoveStep() {
if (this.vehicle && vehicleFollowsLandingTile(this.targetGridType)) {
this.syncVehicleToPlayerBase();
}
}
onMoving() {
if (this.targetGridType === GridType.None && this.vehicle && !Movement.callEach) {
this.vehicle.setPosition(this.node.worldPosition);
if (this.vehicle && vehicleFollowsLandingTile(this.targetGridType) && !Movement.callEach) {
this.syncVehicleToPlayerBase();
}
this.collectPropsOnApproach();
if (!this.sfxSource) return;
const action = this.animator?.getAction() ?? PlayerAction.Idle;
if (action === PlayerAction.Move) {
const key = vehicleFollowsLandingTile(this.targetGridType) && this.vehicle
? 'vehicleMove' : 'move';
void GameAudio.playSfxOnSource(this.sfxSource, key);
} else if (action === PlayerAction.Jump) {
void GameAudio.playSfxOnSource(this.sfxSource, 'jump');
}
}
protected onMoveFail(isJump: boolean) {
this.externalCallResult(false);
const gm = GameManager.instance!;
if (gm.multMode) {
const other = gm.findNodeByName(this.node.name === 'Player' ? 'Enemy' : 'Player');
other?.getComponent(PlayerController)?.externalCallResult(true);
if (isJump) {
console.log(
`${this.node.name} 无法跳跃:跳跃只能落到 Jump 砖块(当前=${GridType[this.curGrid]} 前方=${GridType[this.nextGrid]}`,
);
} else if (this.curGrid === GridType.Jump) {
console.log(`${this.node.name} 在跳跃砖块上只能跳跃或转向,不能普通前后移动`);
} else {
console.log(
`${this.node.name} 无法移动(当前=${GridType[this.curGrid]} 前方=${GridType[this.nextGrid]} 后方=${GridType[this.lastGrid]}`,
);
}
console.log(`${this.node.name} 无法移动`, isJump);
}
protected onMoveToTarget() {
this.commitLandingCell();
this.finishMoveToTarget(true);
}
/** Unity OnMoveToTarget(bool) — 上下车 / 载具 Ride 注册 */
finishMoveToTarget(sendMsgOnStep0 = true) {
const gm = GameManager.instance!;
this.applyResultAction();
const cell = this.committedCell
?? worldToMoverCellForRole(
this.node.position,
gm.getCurLevel() ?? undefined,
gm.getCurLevel()?.theme ?? gm.uiStyle,
'player',
);
if (this.curGrid === GridType.Ride) {
const obj = GameManager.instance!.getGameObject(this.node.worldPosition);
if (obj) {
this.vehicle = obj.getComponent(VehicleController);
this.vehicle?.setPlayer(this);
if (this.vehicle) this.vehicle.setDirection(this.direction);
const vc = findVehicleAtCell(cell)
?? gm.getGameObjectAtCell(cell)?.getComponent(VehicleController);
if (vc) {
this.setRideVehicle(vc);
vc.setPlayer(this);
vc.setDirection(this.direction);
}
} else if (this.curGrid !== GridType.None && this.vehicle) {
this.vehicle.setPlayer(null);
this.vehicle = null;
this.unbindVehicle();
}
PropController.tryCollectAtCell(cell, this);
if (this.vehicle) {
this.vehicle.setPosition(this.node.worldPosition);
GameManager.instance!.removeObj(this.lastPosition);
GameManager.instance!.addObj(this.node.worldPosition, GridType.Ride, this.vehicle.node);
this.alignWithVehicleBase();
gm.removeObj(this.lastPosition);
const rideCell = this.committedCell ?? cell;
gm.addObjAtCell(rideCell, GridType.Ride, this.vehicle.node);
}
this.externalCall();
if (this.committedCell) {
this.targetGridType = gm.calculateGridTypeAtCell(this.committedCell);
}
if (this.step === 0 && sendMsgOnStep0) {
this.externalCall();
}
}
// —— 外观 / 外部接口 ——
private activeVisualTheme(): string {
const gm = GameManager.instance;
const levelTheme = gm?.getCurLevel()?.theme?.trim();
if (levelTheme) return levelTheme;
if (gm) return skinToTheme(gm.playerSkin);
return 'silu';
}
private visualOpts() {
const gm = GameManager.instance;
const theme = this.activeVisualTheme();
return { ...(gm?.getEntityVisualOptions() ?? {}), theme };
}
private refreshVisual(action = PlayerAction.Idle) {
this.animator?.configure(this.activeVisualTheme(), this.direction);
this.animator?.setAction(action);
}
/** 关卡加载后强制用地图主题(主站 CallChangeSkin 不得覆盖) */
applyLevelTheme(theme: string) {
const t = theme?.trim();
if (!t) return;
const gm = GameManager.instance;
if (gm) gm.playerSkin = themeToSkin(t);
this.animator?.configure(t, this.direction);
this.animator?.setAction(this.animator?.getAction() ?? PlayerAction.Idle, true);
this.reapplyCellStandPosition();
this.vehicle?.refreshIcon();
}
override setDirection(dir: Direction) {
super.setDirection(dir);
this.animator?.setDirection(dir, this.visualOpts());
if (Movement.callEach) return;
Movement.callEach = true;
this.vehicle?.setDirection(dir);
Movement.callEach = false;
}
callChangeSkin(n: number) {
if (n < 0 || n > Skin.sanxing) return;
if (GameManager.instance) GameManager.instance.playerSkin = n as Skin;
const levelTheme = GameManager.instance?.getCurLevel()?.theme?.trim();
if (levelTheme) {
this.applyLevelTheme(levelTheme);
return;
}
this.refreshVisual(this.animator?.getAction() ?? PlayerAction.Idle);
}
playCoinsAudio() {
GameAudio.playSfx('coins', this.node);
}
private applyResultAction() {
const gm = GameManager.instance;
if (!gm) return;
if (gm.gameState === GameState.ResultWin) {
this.animator?.setAction(PlayerAction.Win);
} else if (gm.gameState === GameState.ResultFail) {
this.animator?.setAction(PlayerAction.Fail);
} else {
this.animator?.setAction(PlayerAction.Idle);
}
}
callPlayerInfo() {
@@ -129,85 +535,73 @@ export class PlayerController extends Movement {
addCoins() {
this.coins++;
this.playCoinsAudio();
}
setPosition(pos: Vec3) {
this.node.setPosition(pos);
if (this.vehicle) {
this.setRideBaseFromVehicle(pos);
} else {
this.node.setPosition(pos);
}
}
setTargetGridType(t: GridType) {
this.targetGridType = t;
}
/** 供 VehicleController 同步 */
syncFromVehicle(targetGridType: GridType, isJump: boolean) {
this.targetGridType = targetGridType;
this.onMoveNextSet(isJump);
}
syncMoveToTargetFromVehicle() {
this.onMoveToTarget();
this.finishMoveToTarget(false);
}
externalCall() {
const gm = GameManager.instance!;
const list: ExternalDataList = { direction: this.direction, externalDatas: [] };
const self = gm.worldToCell(this.node.worldPosition);
const sample = this.getGridSamplePosition();
const self = gm.worldToCell(sample);
list.externalDatas.push({
position: { x: self.x, y: self.y, z: 0 },
gridType: this.curGrid,
direction: 'self',
});
for (let d = Direction.North; d <= Direction.West; d++) {
const wp = gm.nextGridPosition(this.node.worldPosition, d);
const wp = gm.nextGridPosition(sample, d);
const cell = gm.worldToCell(wp);
list.externalDatas.push({
position: { x: cell.x, y: cell.y, z: 0 },
gridType: gm.calculateNextGridType(this.node.worldPosition, d),
gridType: gm.calculateNextGridType(sample, d),
direction: gm.getRelativePosition(this.direction, d),
});
}
const json = JSON.stringify(list);
if (gm.multMode) JsBridge.call(`process${this.node.name}`, json);
else JsBridge.call('processData', json);
JsBridge.call('processData', JSON.stringify(list));
}
externalCallResult(isWin: boolean) {
const gm = GameManager.instance!;
if (isWin && (this.node.name === 'Player' || this.node.name === gm.multPlayerRole)) {
/* success audio */
} else if (!isWin && (this.node.name === 'Player' || this.node.name === gm.multPlayerRole)) {
/* fail audio */
}
let myStep = gm.stepNum;
if (gm.multMode) {
switch (gm.multPlayerRole) {
case 'PlayerA1': myStep = gm.stepA1Num; break;
case 'PlayerA2': myStep = gm.stepA2Num; break;
case 'PlayerA3': myStep = gm.stepA3Num; break;
case 'PlayerB1': myStep = gm.stepB1Num; break;
case 'PlayerB2': myStep = gm.stepB2Num; break;
case 'PlayerB3': myStep = gm.stepB3Num; break;
if (this.node.name === 'Player') {
if (isWin) {
GameAudio.playSfx('success', this.node);
this.animator?.setAction(PlayerAction.Win);
} else {
GameAudio.playSfx('fail', this.node);
this.animator?.setAction(PlayerAction.Fail);
}
}
if ((this.node.name === 'Player' || this.node.name === gm.multPlayerRole) && !this.sendFinally) {
const payload: ExternalResult = {
if (this.node.name === 'Player' && !this.sendFinally) {
JsBridge.call('externalResult', JSON.stringify({
isWin,
stepNum: myStep,
stepNum: gm.stepNum,
direction: this.direction,
isInputEnd: gm.isInputEnd,
};
JsBridge.call('externalResult', JSON.stringify(payload));
} satisfies ExternalResult));
this.sendFinally = true;
}
if (gm.gameState !== GameState.Run) return;
gm.setGameState(isWin ? GameState.ResultWin : GameState.ResultFail);
}
private checkRide() {
if (this.curGrid === GridType.Ride) {
const obj = GameManager.instance!.getGameObject(this.node.worldPosition);
if (obj) {
this.vehicle = obj.getComponent(VehicleController);
this.vehicle?.setDirection(this.direction);
this.vehicle?.setPlayer(this);
}
}
}
}

View File

@@ -1,59 +1,75 @@
import { _decorator, Component, Vec3 } from 'cc';
import { GameManager } from '../manager/GameManager';
import { forEachLevelEntityNode } from '../level/TileLayout';
import { PlayerController } from './PlayerController';
const { ccclass } = _decorator;
/**
* 可拾取物(对齐 Unity PropController OnTriggerEnter2D
* 玩家进入道具格且靠近时拾取(由 PlayerController 判定时机)
*/
@ccclass('PropController')
export class PropController extends Component {
private collected = false;
private spawnCell: Vec3 | null = null;
setSpawnCell(cell: Vec3) {
this.spawnCell = cell.clone();
}
getSpawnCell(): Vec3 | null {
return this.spawnCell ? this.spawnCell.clone() : null;
}
matchesCell(cell: Vec3): boolean {
const propCell = this.getLogicCell();
return propCell.x === cell.x && propCell.y === cell.y;
}
private getLogicCell(): Vec3 {
if (this.spawnCell) return this.spawnCell;
const gm = GameManager.instance!;
return gm.worldToCell(this.node.position);
}
/** 玩家落格后尝试拾取(由 PlayerController 调用) */
static tryCollectAtCell(cell: Vec3, player: PlayerController) {
const gm = GameManager.instance;
const level = gm?.curLevel;
if (!level) return;
forEachLevelEntityNode(level, (ch) => {
const prop = ch.getComponent(PropController);
if (!prop || prop.collected || !prop.matchesCell(cell)) return;
prop.collect(player);
});
}
update() {
if (this.collected || !GameManager.instance) return;
const gm = GameManager.instance;
const players = gm.curLevel?.children.filter((c) => c.name.includes('Player')) ?? [];
const propCell = gm.worldToCell(this.node.worldPosition);
for (const p of players) {
const pc = p.getComponent(PlayerController);
if (!pc) continue;
const pcCell = gm.worldToCell(p.worldPosition);
if (pcCell.x !== propCell.x || pcCell.y !== propCell.y) continue;
this.onCollected(pc);
break;
}
const player = GameManager.instance.findNodeByName('Player');
const pc = player?.getComponent(PlayerController);
if (!pc?.canCollectProp(this)) return;
this.collect(pc);
}
collect(player: PlayerController) {
if (this.collected) return;
this.onCollected(player);
}
private onCollected(player: PlayerController) {
if (this.collected) return;
this.collected = true;
this.node.active = false;
const gm = GameManager.instance!;
player.addCoins();
gm.removeProp(this.node.worldPosition);
const remaining = (gm.curLevel?.children.filter((c) => c.name.includes('Prop') && c !== this.node).length ?? 0);
const propCell = this.getLogicCell();
gm.removePropAtCell(propCell);
this.node.removeFromParent();
this.node.destroy();
if (remaining === 0) {
if (gm.multMode) this.resolveMultWin(gm);
else player.externalCallResult(true);
}
}
private resolveMultWin(gm: GameManager) {
const sum = (names: string[]) =>
names.reduce((t, n) => t + (gm.findNodeByName(n)?.getComponent(PlayerController)?.coins ?? 0), 0);
const totalA = sum(['PlayerA1', 'PlayerA2', 'PlayerA3']);
const totalB = sum(['PlayerB1', 'PlayerB2', 'PlayerB3']);
const winA = totalA > totalB || (totalA === totalB && gm.stepA1Num + gm.stepA2Num + gm.stepA3Num <
gm.stepB1Num + gm.stepB2Num + gm.stepB3Num);
const set = (names: string[], win: boolean) => {
for (const n of names) gm.findNodeByName(n)?.getComponent(PlayerController)?.externalCallResult(win);
};
if (winA) {
set(['PlayerA1', 'PlayerA2', 'PlayerA3'], true);
set(['PlayerB1', 'PlayerB2', 'PlayerB3'], false);
} else {
set(['PlayerA1', 'PlayerA2', 'PlayerA3'], false);
set(['PlayerB1', 'PlayerB2', 'PlayerB3'], true);
if (gm.allPropsCollected()) {
player.externalCallResult(true);
}
}
}

View File

@@ -0,0 +1,157 @@
import { Vec3 } from 'cc';
import { GridType } from '../core/Define';
import { GameManager } from '../manager/GameManager';
import { forEachLevelEntityNode } from '../level/TileLayout';
import { entityWorldPositionForRole, worldToMoverCellForRole } from '../level/EntitySpawnPlacement';
import { PlayerController } from './PlayerController';
import { VehicleController } from './VehicleController';
/** 按逻辑格查找载具gridTypes + 当前 committedCell */
export function findVehicleAtCell(cell: Vec3): VehicleController | null {
const gm = GameManager.instance;
if (!gm) return null;
const fromGrid = gm.getGameObjectAtCell(cell)?.getComponent(VehicleController);
if (fromGrid) return fromGrid;
const level = gm.curLevel;
if (!level) return null;
let found: VehicleController | null = null;
forEachLevelEntityNode(level, (ch) => {
if (found || !ch.name.includes('Vehicle')) return;
const vc = ch.getComponent(VehicleController);
if (!vc || !vehicleMatchesCell(vc, cell)) return;
found = vc;
});
return found;
}
/** 双向绑定玩家与载具,并确保 Ride 格注册 */
export function linkRidePair(player: PlayerController, vehicle: VehicleController, cell: Vec3) {
const gm = GameManager.instance;
if (!gm) return;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const deck = entityWorldPositionForRole(cell, config ?? undefined, theme, 'vehicle');
player.setRideVehicle(vehicle);
vehicle.setPlayer(player);
vehicle.setDirection(player.direction);
player.shareCommittedCell(cell);
vehicle.shareCommittedCell(cell);
vehicle.node.setPosition(deck);
gm.addObjAtCell(cell, GridType.Ride, vehicle.node);
player.setRideBaseFromVehicle(deck);
}
function playerLogicCell(player: PlayerController): Vec3 {
const committed = player.getCommittedCell();
if (committed) return committed.clone();
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
return worldToMoverCellForRole(
player.node.position,
config ?? undefined,
theme,
'player',
);
}
function vehicleLogicCell(vehicle: VehicleController): Vec3 | null {
const committed = vehicle.getCommittedCell();
if (committed) return committed.clone();
const spawn = vehicle.getSpawnCell() ?? vehicle.getCommittedCell();
if (spawn) return spawn.clone();
const gm = GameManager.instance;
if (!gm) return null;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
return worldToMoverCellForRole(vehicle.node.position, config ?? undefined, theme, 'vehicle');
}
function vehicleMatchesCell(vehicle: VehicleController, cell: Vec3): boolean {
const vCell = vehicleLogicCell(vehicle);
return !!vCell && vCell.x === cell.x && vCell.y === cell.y;
}
/** 玩家与载具占同一逻辑格时绑定(不依赖 Ride 格是否已注册) */
export function tryLinkPlayerVehicle(player: PlayerController): boolean {
const gm = GameManager.instance;
const level = gm?.curLevel;
if (!level) return false;
const pCell = playerLogicCell(player);
if (!pCell) return false;
const existing = player.getRideVehicle();
if (existing) {
const vCell = vehicleLogicCell(existing);
if (!vCell || vCell.x !== pCell.x || vCell.y !== pCell.y) {
existing.setPlayer(null);
player.setRideVehicle(null);
return false;
}
linkRidePair(player, existing, pCell);
return true;
}
let linked = false;
forEachLevelEntityNode(level, (ch) => {
if (linked || !ch.name.includes('Vehicle')) return;
const vc = ch.getComponent(VehicleController);
if (!vc || !vehicleMatchesCell(vc, pCell)) return;
linkRidePair(player, vc, pCell);
linked = true;
});
if (linked) return true;
const rideVc = findVehicleAtCell(pCell);
if (rideVc) {
linkRidePair(player, rideVc, pCell);
return true;
}
return false;
}
/** 载具侧:同格玩家自动设为骑手 */
export function tryLinkVehicleRider(vehicle: VehicleController): boolean {
const gm = GameManager.instance;
const level = gm?.curLevel;
if (!level) return false;
const vCell = vehicleLogicCell(vehicle);
if (!vCell) return false;
const rider = vehicle.getPlayer();
if (rider) {
const pCell = playerLogicCell(rider);
if (pCell && pCell.x === vCell.x && pCell.y === vCell.y) {
linkRidePair(rider, vehicle, vCell);
return true;
}
}
let matched = false;
forEachLevelEntityNode(level, (ch) => {
if (matched || !ch.name.includes('Player')) return;
const pc = ch.getComponent(PlayerController);
if (!pc) return;
const pCell = playerLogicCell(pc);
if (!pCell || pCell.x !== vCell.x || pCell.y !== vCell.y) return;
linkRidePair(pc, vehicle, vCell);
matched = true;
});
return matched;
}
/** 移动中强制位置联动Unity OnMoving 语义) */
export function syncRidePositions(driver: 'player' | 'vehicle', player: PlayerController, vehicle: VehicleController) {
if (driver === 'player') {
const p = player.node.position;
vehicle.node.setPosition(p.x, p.y, p.z);
} else {
player.setRideBaseFromVehicle(vehicle.node.position);
}
}
export function resolveRideBind(player: PlayerController, cell: Vec3 | null): VehicleController | null {
if (!cell) return null;
return findVehicleAtCell(cell);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "7152704a-c09b-4ea6-a8e5-291537bc7984",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -3,36 +3,57 @@ import { Direction, GridType } from '../core/Define';
import { EventManager, EventType } from '../core/EventManager';
import { JsBridge } from '../bridge/JsBridge';
import { GameManager } from '../manager/GameManager';
import { scaledMoveSpeed, UNITY_VEHICLE_MOVE_SPEED } from '../core/GridConstants';
import { Movement } from '../gameplay/Movement';
import { PlayerController, ExternalDataList } from './PlayerController';
import { syncRidePositions } from './RideLink';
import { VisualAssets } from '../visual/VisualAssets';
const { ccclass } = _decorator;
/**
* 载具移动(对齐 Unity VehicleController
* - 仅能在空地移动None↔None、Ride→None
* - 有骑手时每帧同步玩家;落格更新 Ride 注册
*/
@ccclass('VehicleController')
export class VehicleController extends Movement {
private player: PlayerController | null = null;
onLoad() {
this.moverRole = 'vehicle';
this.moveSpeed = scaledMoveSpeed(UNITY_VEHICLE_MOVE_SPEED);
this.moveState = 0;
}
/** 不调用 super.start(),避免 component.start 晚于 LevelInit 绑定后再次用 spawn 方向刷贴图 */
start() {
super.start();
this.syncCommittedCellFromPosition();
if (this.player) {
this.syncFacingFromRider();
}
this.setIcon();
}
setPlayer(p: PlayerController | null) {
this.player = p;
if (!p) return;
if (p.direction !== this.direction) {
super.setDirection(p.direction);
}
this.setIcon();
}
getPlayer(): PlayerController | null {
return this.player;
}
setPosition(pos: Vec3) {
this.node.setPosition(pos);
}
setName(name: string) {
/* 可挂 Label 显示名称 */
setName(_name: string) {
/* 可挂 Label */
}
override setDirection(dir: Direction) {
@@ -44,45 +65,95 @@ export class VehicleController extends Movement {
Movement.callEach = false;
}
private entityVisualOptions() {
const gm = GameManager.instance;
const levelTheme = gm?.getCurLevel()?.theme?.trim();
return {
...(gm?.getEntityVisualOptions() ?? {}),
theme: levelTheme ?? gm?.uiStyle ?? 'default',
};
}
/** 载具四向贴图:有骑手时跟随骑手 direction转向时 setIcon 直接换图 */
private iconDirection(): Direction {
return this.player?.direction ?? this.direction;
}
setIcon() {
const style = GameManager.instance?.uiStyle ?? 'default';
VisualAssets.applyVehicleSprite(this.node, this.direction, style);
const dir = this.iconDirection();
if (this.player && dir !== this.direction) {
super.setDirection(dir);
}
VisualAssets.applyVehicleIconForDirection(
this.node,
dir,
this.entityVisualOptions(),
);
this.player?.syncRideMountVisual();
}
/** 骑乘时逻辑朝向与骑手保持一致Scratch 回传等) */
private syncFacingFromRider() {
if (!this.player || this.player.direction === this.direction) return;
super.setDirection(this.player.direction);
}
/** 主题 / 软重置后按当前逻辑朝向重刷贴图 */
refreshIcon() {
this.syncFacingFromRider();
this.setIcon();
}
override callJump() {
/* 载具不可跳跃 */
}
protected override syncRideAfterMoveStep() {
if (this.player) syncRidePositions('vehicle', this.player, this);
}
protected onMoveNextSet(isJump: boolean) {
if (Movement.callEach) return;
Movement.callEach = true;
this.player?.syncFromVehicle(this.targetGridType, isJump);
if (this.player) {
this.player.setTargetGridType(this.targetGridType);
this.player.syncFromVehicle(this.targetGridType, isJump);
}
Movement.callEach = false;
}
protected onMoving() {
if (this.player) syncRidePositions('vehicle', this.player, this);
if (Movement.callEach) return;
Movement.callEach = true;
if (this.player) {
this.player.onMoving();
this.player.setPosition(this.node.worldPosition);
}
this.player?.onMoving();
Movement.callEach = false;
}
protected onMoveToTarget() {
GameManager.instance!.removeObj(this.lastPosition);
GameManager.instance!.addObj(this.node.worldPosition, GridType.Ride, this.node);
if (Movement.callEach) return;
Movement.callEach = true;
if (this.player) {
this.player.setPosition(this.node.worldPosition);
this.player.syncMoveToTargetFromVehicle();
this.commitLandingCell();
const gm = GameManager.instance!;
gm.removeObj(this.lastPosition);
if (this.committedCell) {
gm.addObjAtCell(this.committedCell, GridType.Ride, this.node);
} else {
gm.addObj(this.node.position, GridType.Ride, this.node);
}
Movement.callEach = false;
this.externalCall();
if (!Movement.callEach && this.player) {
Movement.callEach = true;
this.player.setRideBaseFromVehicle(this.node.position);
if (this.committedCell) this.player.shareCommittedCell(this.committedCell);
this.player.syncMoveToTargetFromVehicle();
Movement.callEach = false;
}
if (this.step === 0) this.externalCall();
}
protected onMoveFail(isJump: boolean) {
if (!GameManager.instance!.multMode) {
EventManager.dispatch(EventType.InputEnd, GameManager.instance!.gameState);
}
protected onMoveFail(_isJump: boolean) {
this.externalCall();
EventManager.dispatch(EventType.InputEnd, GameManager.instance!.gameState);
}
callVehicleInfo() {
@@ -92,23 +163,22 @@ export class VehicleController extends Movement {
externalCall() {
const gm = GameManager.instance!;
const list: ExternalDataList = { direction: this.direction, externalDatas: [] };
const self = gm.worldToCell(this.node.worldPosition);
const sample = this.getGridSamplePosition();
const self = gm.worldToCell(sample);
list.externalDatas.push({
position: { x: self.x, y: self.y, z: 0 },
gridType: this.curGrid,
direction: 'self',
});
for (let d = Direction.North; d <= Direction.West; d++) {
const wp = gm.nextGridPosition(this.node.worldPosition, d);
const wp = gm.nextGridPosition(sample, d);
const cell = gm.worldToCell(wp);
list.externalDatas.push({
position: { x: cell.x, y: cell.y, z: 0 },
gridType: gm.calculateNextGridType(this.node.worldPosition, d),
gridType: gm.calculateNextGridType(sample, d),
direction: gm.getRelativePosition(this.direction, d),
});
}
const json = JSON.stringify(list);
if (gm.multMode) JsBridge.call(`process${this.node.name}`, json);
else JsBridge.call('processVehicleData', json);
JsBridge.call('processVehicleData', JSON.stringify(list));
}
}

View File

@@ -0,0 +1,136 @@
import {
_decorator, Camera, Component, EventMouse, EventTouch, Input, input, Vec2, view,
} from 'cc';
import { CAMERA_ORTHO_HALF, DESIGN_WIDTH } from '../core/GridConstants';
import { getEmbeddedOrthoHalf } from '../core/EmbeddedView';
const { ccclass } = _decorator;
/** 对齐 Unity ViewControllerOrthographic 缩放与拖拽 */
@ccclass('ViewController')
export class ViewController extends Component {
static instance: ViewController | null = null;
/** Unity zoomSpeed=2 → 世界半高步进 200 */
zoomSpeed = 200;
/** Unity minZoom=3, maxZoom=10×100 世界单位) */
minOrtho = 300;
maxOrtho = 1000;
private camera: Camera | null = null;
private dragOrigin = new Vec2();
private dragging = false;
onLoad() {
if (ViewController.instance && ViewController.instance !== this) {
this.destroy();
return;
}
ViewController.instance = this;
this.camera = this.getComponent(Camera);
if (this.camera && this.camera.orthoHeight <= 0) {
this.camera.orthoHeight = CAMERA_ORTHO_HALF;
}
input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
input.on(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
input.on(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
input.on(Input.EventType.MOUSE_UP, this.onMouseUp, this);
}
onDestroy() {
if (ViewController.instance === this) ViewController.instance = null;
input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this);
input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
input.off(Input.EventType.MOUSE_DOWN, this.onMouseDown, this);
input.off(Input.EventType.MOUSE_MOVE, this.onMouseMove, this);
input.off(Input.EventType.MOUSE_UP, this.onMouseUp, this);
}
zoomIn() {
const cam = this.camera;
if (!cam || cam.orthoHeight <= this.minOrtho) return;
cam.orthoHeight = Math.max(this.minOrtho, cam.orthoHeight - this.zoomSpeed);
}
zoomOut() {
const cam = this.camera;
if (!cam || cam.orthoHeight >= this.maxOrtho) return;
cam.orthoHeight = Math.min(this.maxOrtho, cam.orthoHeight + this.zoomSpeed);
}
resetZoom() {
const cam = this.camera;
if (!cam) return;
cam.orthoHeight = getEmbeddedOrthoHalf();
cam.node.setPosition(0, 0, cam.node.position.z);
}
private onTouchStart(e: EventTouch) {
if (this.isPointerOnUI(e.getLocation())) return;
this.dragging = true;
e.getLocation(this.dragOrigin);
}
private onTouchEnd() {
this.dragging = false;
}
private onTouchMove(e: EventTouch) {
if (!this.dragging) return;
const cur = new Vec2();
e.getLocation(cur);
this.applyDrag(cur);
e.getLocation(this.dragOrigin);
}
private onMouseDown(e: EventMouse) {
if (this.isPointerOnUI(new Vec2(e.getLocationX(), e.getLocationY()))) return;
this.dragging = true;
this.dragOrigin.set(e.getLocationX(), e.getLocationY());
}
private onMouseUp() {
this.dragging = false;
}
private onMouseMove(e: EventMouse) {
if (!this.dragging) return;
this.applyDrag(new Vec2(e.getLocationX(), e.getLocationY()));
this.dragOrigin.set(e.getLocationX(), e.getLocationY());
}
private applyDrag(cur: Vec2) {
if (!this.camera) return;
const delta = cur.subtract(this.dragOrigin);
const ortho = this.camera.orthoHeight;
const { width, height } = view.getVisibleSize();
const worldPerPixelX = (2 * ortho * (width / height)) / width;
const worldPerPixelY = (2 * ortho) / height;
const pos = this.camera.node.position;
let nx = pos.x - delta.x * worldPerPixelX;
let ny = pos.y - delta.y * worldPerPixelY;
const limit = ortho - 20;
if (Math.abs(nx) > limit) nx = pos.x;
if (Math.abs(ny) > limit) ny = pos.y;
this.camera.node.setPosition(nx, ny, pos.z);
}
/** 右侧 UIMain 区域不拖拽镜头(与 UIMain 边距一致) */
private isPointerOnUI(loc: Vec2): boolean {
const vis = view.getVisibleSize();
const margin = Math.max(96, (DESIGN_WIDTH * 0.5) * 0.14);
const right = vis.width > DESIGN_WIDTH
? DESIGN_WIDTH * 0.5
: vis.width * 0.5;
return loc.x >= right - margin;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e7bb6bb7-3908-413f-9664-06267b8e39fd",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -30,6 +30,35 @@ export enum Skin {
sanxing = 5,
}
const SKIN_TO_THEME: Record<Skin, string> = {
[Skin.Silu]: 'silu',
[Skin.Panda]: 'chinese',
[Skin.RedArmy]: 'redArmy',
[Skin.numMan]: 'numMan',
[Skin.snow]: 'snow',
[Skin.sanxing]: 'sanxing',
};
const THEME_TO_SKIN: Record<string, Skin> = {
silu: Skin.Silu,
chinese: Skin.Panda,
redarmy: Skin.RedArmy,
redArmy: Skin.RedArmy,
numMan: Skin.numMan,
snow: Skin.snow,
sanxing: Skin.sanxing,
};
export function skinToTheme(skin: Skin): string {
return SKIN_TO_THEME[skin] ?? 'silu';
}
export function themeToSkin(theme: string | undefined): Skin {
if (!theme) return Skin.Silu;
const key = theme.trim();
return THEME_TO_SKIN[key] ?? THEME_TO_SKIN[key.toLowerCase()] ?? Skin.Silu;
}
export enum GameState {
Run = 0,
ResultWin = 1,
@@ -68,7 +97,10 @@ function buildMoveTable(entries: [MoverRole, GridType, GridType, boolean, number
return m;
}
/** 单人 moveCondition */
/**
* 单人 moveCondition — 基于 Unity Define.cs并扩展骑乘 → Jump 跳跃
* 判定入口gameplay/MoveRules.checkMoveStep
*/
export const moveCondition = buildMoveTable([
['player', GridType.Across, GridType.Across, false, 1],
['player', GridType.Jump, GridType.Across, false, 1],
@@ -84,6 +116,8 @@ export const moveCondition = buildMoveTable([
['player', GridType.Jump, GridType.Jump, true, 1],
['player', GridType.Ride, GridType.None, false, 1],
['player', GridType.Ride, GridType.None, true, 1],
['player', GridType.Ride, GridType.Jump, true, 1],
['player', GridType.None, GridType.Jump, true, 1],
['player', GridType.None, GridType.None, false, 1],
['player', GridType.None, GridType.None, true, 1],
['player', GridType.None, GridType.Across, false, 1],
@@ -93,7 +127,7 @@ export const moveCondition = buildMoveTable([
['vehicle', GridType.Ride, GridType.None, false, 1],
]);
/** 多人 moveConditionMult与 Unity Define.moveConditionMult 一致 */
/** 多人 moveConditionMult与 Unity Define.cs moveConditionMult 逐项一致 */
const multEntries: [MoverRole, GridType, GridType, boolean, number][] = [
['player', GridType.Across, GridType.Across, false, 1],
['player', GridType.Across, GridType.Across, true, 1],
@@ -111,6 +145,8 @@ const multEntries: [MoverRole, GridType, GridType, boolean, number][] = [
['player', GridType.Jump, GridType.Jump, true, 1],
['player', GridType.Ride, GridType.None, false, 1],
['player', GridType.Ride, GridType.None, true, 1],
['player', GridType.Ride, GridType.Jump, true, 1],
['player', GridType.None, GridType.Jump, true, 1],
['player', GridType.None, GridType.None, false, 1],
['player', GridType.None, GridType.None, true, 1],
['player', GridType.None, GridType.Across, false, 1],

View File

@@ -0,0 +1,43 @@
import { Camera, director, find, view, ResolutionPolicy } from 'cc';
import { DESIGN_WIDTH, DESIGN_HEIGHT } from './GridConstants';
/**
* 编辑器内嵌左栏:先 setFrameSize 铺满容器,再 FIXED_WIDTH 按宽适配(与 test.001code.com 一致)。
* 须配合 loader 在 resize 时调用 view.setFrameSize(clientW, clientH)。
*/
export function applyEmbeddedDesignResolution(): void {
view.resizeWithBrowserSize(true);
const frame = view.getFrameSize();
if (frame.width <= 0 || frame.height <= 0) {
view.setDesignResolutionSize(DESIGN_WIDTH, DESIGN_HEIGHT, ResolutionPolicy.FIXED_WIDTH);
syncEmbeddedCamerasOrtho();
return;
}
view.setDesignResolutionSize(DESIGN_WIDTH, DESIGN_HEIGHT, ResolutionPolicy.FIXED_WIDTH);
syncEmbeddedCamerasOrtho();
}
/** 内嵌画布当前可视半高(设计坐标) */
export function getEmbeddedOrthoHalf(): number {
const vis = view.getVisibleSize();
return (vis.height > 0 ? vis.height : DESIGN_HEIGHT) / 2;
}
/** 主相机 / 背景 / HUD 相机正交高度须与可视高度一致,否则关卡与 HUD 错位 */
export function syncEmbeddedCamerasOrtho(): void {
const halfH = getEmbeddedOrthoHalf();
const scene = director.getScene();
if (!scene) return;
for (const camName of ['UICamera', 'BgCamera', 'Main Camera']) {
const cam = find(camName, scene)?.getComponent(Camera);
if (cam) cam.orthoHeight = halfH;
}
const mainNode = find('Main Camera', scene);
if (mainNode) mainNode.setPosition(0, 0, mainNode.position.z);
(globalThis as { __tfrhSyncHudOrtho?: () => void }).__tfrhSyncHudOrtho = syncEmbeddedCamerasOrtho;
}
/** @deprecated 使用 syncEmbeddedCamerasOrtho */
export function syncHudCameraOrtho(): void {
syncEmbeddedCamerasOrtho();
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "44ae1fbb-1f7b-4afc-b4c7-66938cd25e5d",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,33 @@
/** 等距网格常量(独立模块,避免 AppBootstrap ↔ GridCoords 循环依赖) */
export const CELL_PIXEL = 100;
/** Unity Player.prefab moveSpeed=1.2、Vehicle.prefab=1按格子像素等比换算 */
export const UNITY_PLAYER_MOVE_SPEED = 1.2;
export const UNITY_VEHICLE_MOVE_SPEED = 1;
/** Unity PlayerController 跳上 JumpBlock 时 targetPosition.y += 0.15 */
export const UNITY_JUMP_ARC_OFFSET = 0.15;
/** 移动中拾取步进进度达到该比例且进入道具格后才消失01 */
export const PROP_COLLECT_MOVE_PROGRESS = 0.62;
/** 移动中拾取:角色与道具世界距离上限(相对 CELL_PIXEL */
export const PROP_COLLECT_TOUCH_RADIUS = 0.42;
export function scaledJumpArcOffset(): number {
return UNITY_JUMP_ARC_OFFSET * CELL_PIXEL;
}
export function scaledMoveSpeed(unitySpeed: number): number {
return unitySpeed * CELL_PIXEL;
}
/** 与 Unity Web 模板 canvas 一致Template/index.html 960×600 */
export const DESIGN_WIDTH = 960;
export const DESIGN_HEIGHT = 600;
/** Unity Main Camera orthographicSize=5 → 半高 500px世界尺度不随设计分辨率变 */
export const CAMERA_ORTHO_HALF = 500;
export function getHalfCellSize(): { halfW: number; halfH: number } {
return { halfW: CELL_PIXEL * 0.5, halfH: CELL_PIXEL * 0.25 };
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f0421cda-510d-4b04-9943-8ed38412858f",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,88 @@
import { Vec3 } from 'cc';
import { CELL_PIXEL } from './GridConstants';
/**
* 与 Unity Grid 一致CellLayout Isometric Z-as-YCellSize (1, 0.5, 1)。
* 缩放后 halfW=CELL/2, halfH=CELL/4。
*/
const HALF_W = CELL_PIXEL * 0.5;
const HALF_H = CELL_PIXEL * 0.25;
if (!Number.isFinite(HALF_W) || HALF_W <= 0) {
console.error('[GridCoords] CELL_PIXEL 无效,请检查 GridConstants 模块加载');
}
export function cellToWorld(cell: Vec3): Vec3 {
const x = cell.x;
const y = cell.y;
return new Vec3((x - y) * HALF_W, (x + y) * HALF_H, 0);
}
/** Unity Tilemap tileAnchor (0.5,0.5):精灵 pivot 对齐格子中心 */
export function cellToWorldCenter(cell: Vec3): Vec3 {
const w = cellToWorld(cell);
return new Vec3(w.x, w.y + HALF_H, 0);
}
export function worldToCell(world: Vec3): Vec3 {
const cx = (world.y / HALF_H + world.x / HALF_W) * 0.5;
const cy = (world.y / HALF_H - world.x / HALF_W) * 0.5;
return new Vec3(Math.round(cx), Math.round(cy), 0);
}
/** 输入为世界坐标下的格子中心(与 Tilemap tileAnchor 一致) */
export function worldCenterToCell(world: Vec3): Vec3 {
return worldToCell(new Vec3(world.x, world.y - HALF_H, world.z));
}
/** 世界坐标吸附到最近格子中心 */
export function snapWorldToCellCenter(world: Vec3, out?: Vec3): Vec3 {
const cell = worldToCell(world);
const snapped = cellToWorld(cell);
if (out) {
out.set(snapped);
return out;
}
return snapped;
}
export function formatCellKey(x: number, y: number): string {
return `${x},${y}`;
}
export function parseCellKey(key: string): { x: number; y: number } | null {
const parts = key.split(',');
if (parts.length !== 2) return null;
const x = parseInt(parts[0], 10);
const y = parseInt(parts[1], 10);
if (Number.isNaN(x) || Number.isNaN(y)) return null;
return { x, y };
}
/** 烘焙瓦片节点名 g_1_0 / b_-2_1 → 格子坐标 */
export function parseTileNodeName(name: string | undefined | null): { layer: 'ground' | 'border'; x: number; y: number } | null {
if (!name) return null;
const m = name.match(/^([gb])_(-?\d+)_(-?\d+)$/);
if (!m) return null;
return {
layer: m[1] === 'g' ? 'ground' : 'border',
x: parseInt(m[2], 10),
y: parseInt(m[3], 10),
};
}
export function tileNodeName(layer: 'ground' | 'border', x: number, y: number): string {
const p = layer === 'ground' ? 'g' : 'b';
return `${p}_${x}_${y}`;
}
export function getHalfCellSize(): { halfW: number; halfH: number } {
return { halfW: HALF_W, halfH: HALF_H };
}
/** 等距绘制深度(连续值,移动插值用;与 compareIsoDrawOrder 配套) */
export function isoDrawDepthFromWorld(world: Vec3): { x: number; y: number } {
const cx = (world.y / HALF_H + world.x / HALF_W) * 0.5;
const cy = (world.y / HALF_H - world.x / HALF_W) * 0.5;
return { x: cx, y: cy };
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4eaa5ece-8094-453e-a1e1-3b3bbe9162f0",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,23 @@
import { assetManager } from 'cc';
const RESOURCES_BUNDLE = 'resources';
let bundlePromise: Promise<assetManager.Bundle> | null = null;
/** 拆分分包后须先 loadBundle('resources')resources.load 才可用 */
export function ensureResourcesBundle(): Promise<assetManager.Bundle> {
const existing = assetManager.getBundle(RESOURCES_BUNDLE);
if (existing) return Promise.resolve(existing);
if (bundlePromise) return bundlePromise;
bundlePromise = new Promise((resolve, reject) => {
assetManager.loadBundle(RESOURCES_BUNDLE, (err, bundle) => {
bundlePromise = null;
if (err || !bundle) {
reject(err ?? new Error(`bundle "${RESOURCES_BUNDLE}" unavailable`));
return;
}
resolve(bundle);
});
});
return bundlePromise;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "db5967b6-5ed9-4793-9b53-486850d4efce",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,206 @@
import {
_decorator, Color, Component, Graphics, Layers, Node, UITransform, Vec3, view,
} from 'cc';
import { CELL_PIXEL, CAMERA_ORTHO_HALF, DESIGN_HEIGHT } from '../core/GridConstants';
import { cellToWorldCenter, getHalfCellSize } from '../core/GridCoords';
import { EventManager, EventType } from '../core/EventManager';
import { GameManager } from '../manager/GameManager';
import { GridSnapHelper } from '../level/GridSnapHelper';
const { ccclass } = _decorator;
const UI_LAYER = Layers.Enum.UI_2D;
/** 对齐 Unity LineGridRenderer导航按钮切换的满屏等距辅助网格 */
@ccclass('LineGridRenderer')
export class LineGridRenderer extends Component {
static instance: LineGridRenderer | null = null;
/** 以中心向外的格子半径(菱形个数 ≈ (2r+1)²) */
radius = 24;
cellSize = CELL_PIXEL;
private graphics: Graphics | null = null;
private visible = false;
static ensure(parent: Node): LineGridRenderer {
LineGridRenderer.purgeDuplicates(parent);
let comp = LineGridRenderer.instance?.isValid ? LineGridRenderer.instance : null;
if (!comp) {
const found = LineGridRenderer.findUnder(parent);
comp = found?.getComponent(LineGridRenderer) ?? null;
}
if (!comp) {
const node = new Node('LineGrid');
node.parent = parent;
comp = node.addComponent(LineGridRenderer);
} else if (comp.node.parent !== parent) {
comp.node.parent = parent;
}
comp.setupRuntime();
comp.sendToBack();
return comp;
}
/** 移除误挂在关卡内的重复 LineGrid会导致叠线无法关闭 */
private static purgeDuplicates(entrance: Node) {
const keep = LineGridRenderer.instance?.isValid
? LineGridRenderer.instance.node
: LineGridRenderer.findUnder(entrance);
const walk = (node: Node) => {
for (let i = node.children.length - 1; i >= 0; i--) {
const ch = node.children[i];
if (ch.name === 'LineGrid' && ch !== keep) {
ch.destroy();
continue;
}
walk(ch);
}
};
walk(entrance);
}
private static findUnder(root: Node): Node | null {
if (root.name === 'LineGrid') return root;
for (const ch of root.children) {
const hit = LineGridRenderer.findUnder(ch);
if (hit) return hit;
}
return null;
}
onLoad() {
if (LineGridRenderer.instance && LineGridRenderer.instance !== this) {
this.node.destroy();
return;
}
LineGridRenderer.instance = this;
this.setupRuntime();
EventManager.register(EventType.LevelInit, this.onLevelInit);
}
onDestroy() {
EventManager.remove(EventType.LevelInit, this.onLevelInit);
if (LineGridRenderer.instance === this) LineGridRenderer.instance = null;
}
isGridVisible(): boolean {
return this.visible;
}
private setupRuntime() {
this.node.layer = UI_LAYER;
if (!this.node.getComponent(UITransform)) {
this.node.addComponent(UITransform).setContentSize(1, 1);
}
const g = this.getComponent(Graphics) ?? this.addComponent(Graphics);
this.graphics = g;
g.lineWidth = 1.4;
g.strokeColor = new Color(0, 0, 0, 255);
if (!this.visible) {
g.clear();
this.node.active = false;
}
}
private onLevelInit = () => {
this.purgeEditorGrid();
this.syncLevelOffset();
this.updateGridRadius();
if (this.visible) {
this.rebuild();
this.node.active = true;
this.sendToBack();
} else {
this.graphics?.clear();
this.node.active = false;
}
};
toggleGridVisibility() {
this.setupRuntime();
this.visible = !this.visible;
if (this.visible) {
this.purgeEditorGrid();
this.syncLevelOffset();
this.updateGridRadius();
this.rebuild();
this.node.active = true;
this.sendToBack();
} else {
this.graphics?.clear();
this.node.active = false;
}
}
/** 移除编辑器 GridSnapHelper 遗留的蓝色/灰色参考格 */
private purgeEditorGrid() {
const entrance = this.node.parent;
if (entrance?.isValid) {
GridSnapHelper.purgeRuntimeGrids(entrance);
}
const scene = this.node.scene;
if (scene?.isValid) {
GridSnapHelper.purgeRuntimeGrids(scene);
}
}
/** 保持在 MainLevelEntrance 最底层,不遮挡关卡内砖块/角色 */
private sendToBack() {
const parent = this.node.parent;
if (parent?.isValid) {
this.node.setSiblingIndex(0);
}
}
/** 与关卡根节点对齐,网格覆盖当前可见砖块区域 */
private syncLevelOffset() {
const parent = this.node.parent;
if (!parent) return;
const levelRoot = parent.children.find(
(c) => c?.isValid && c !== this.node && (c.name ?? '').startsWith('Level'),
);
if (levelRoot?.isValid) {
const p = levelRoot.position;
this.node.setPosition(p.x, p.y, 0);
} else {
this.node.setPosition(0, 0, 0);
}
}
private updateGridRadius() {
const b = GameManager.instance?.getCurLevel()?.boundary;
const vs = view.getVisibleSize();
const halfH = CAMERA_ORTHO_HALF;
const halfW = halfH * (vs.width / Math.max(vs.height, DESIGN_HEIGHT));
const cover = Math.ceil(Math.max(halfW, halfH) / (CELL_PIXEL * 0.35)) + 6;
if (b) {
this.radius = Math.max(cover, b.x + 8, b.y + 8);
} else {
this.radius = cover;
}
}
private rebuild() {
const g = this.graphics;
if (!g) return;
g.clear();
const { halfW, halfH } = getHalfCellSize();
const r = this.radius;
for (let x = -r; x <= r; x++) {
for (let y = -r; y <= r; y++) {
const c = cellToWorldCenter(new Vec3(x, y, 0));
g.moveTo(c.x, c.y + halfH);
g.lineTo(c.x + halfW, c.y);
g.lineTo(c.x, c.y - halfH);
g.lineTo(c.x - halfW, c.y);
g.close();
}
}
g.stroke();
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "53b54d98-b129-4a11-8891-528a4a62a302",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,56 @@
/**
* 移动 / 骑乘规则moveCondition 查表与 Unity Movement.MoveNextCheck 一致)
*
* 骑乘联动、上下车见 PlayerController / VehicleController非查表部分
*/
import { GridType, MoverRole, getMoveCondition, lookupMove } from '../core/Define';
export type MoveCheckResult = -1 | 0 | 1;
/** Unity PlayerController.OnMovingtargetGridType === None 时载具跟随 */
export function vehicleFollowsLandingTile(liveGrid: GridType): boolean {
return liveGrid === GridType.None;
}
/** 落到瓦片砖面时下车None 保持骑乘Ride 动态格由上车逻辑处理) */
export function shouldDismountOnTile(tileGrid: GridType): boolean {
return tileGrid === GridType.Across
|| tileGrid === GridType.Jump
|| tileGrid === GridType.Block
|| tileGrid === GridType.Boundary;
}
/** 落格后尝试保持骑乘:脚下为 None或动态 Ride 且同格有载具 */
export function canStayMountedAfterLanding(liveGrid: GridType): boolean {
return liveGrid === GridType.None || liveGrid === GridType.Ride;
}
/** 移动落点世界坐标应使用的 mover 角色(对齐 Unity 共享 transform / 载具甲板) */
export function moverRoleForLandingCell(
role: MoverRole,
liveGrid: GridType,
mounted: boolean,
): MoverRole {
if (mounted && vehicleFollowsLandingTile(liveGrid)) return 'vehicle';
if (!mounted && liveGrid === GridType.Ride) return 'vehicle';
return role;
}
/**
* 查表判定一步移动(对齐 Unity Movement.MoveNextCheck
* - 1可移动
* - 0不动仅多人模式表外组合单人表外为 -1
* - -1失败
*/
export function checkMoveStep(
role: MoverRole,
curGrid: GridType,
nextGrid: GridType,
isJump: boolean,
mult = false,
): MoveCheckResult {
const table = getMoveCondition(mult);
const v = lookupMove(table, role, curGrid, nextGrid, isJump);
if (v === undefined) return mult ? 0 : -1;
return v as MoveCheckResult;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "261eb8b8-97f9-4df2-9de5-85bc83b9cf52",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,55 +1,223 @@
import { _decorator, Component, Vec3 } from 'cc';
import {
Direction, GameState, GridType, MoveState, MoverRole,
addDirection, getMoveCondition, lookupMove,
Direction, GameState, GridType, MoveState, MoverRole, addDirection,
} from '../core/Define';
import { GameManager } from '../manager/GameManager';
import { scaledMoveSpeed, UNITY_VEHICLE_MOVE_SPEED } from '../core/GridConstants';
import { cellToWorldCenter } from '../core/GridCoords';
import {
entityWorldPositionForRole,
moverLogicalGridPositionForRole,
roleUsesPlayerStandOffset,
worldToMoverCellForRole,
} from '../level/EntitySpawnPlacement';
import { checkMoveStep, MoveCheckResult } from './MoveRules';
const { ccclass, property } = _decorator;
/**
* 离散格子移动基类(对齐 Unity Movement
* - 查表判定能否进入下一格
* - FixedUpdate 式插值到 targetPosition
* - 骑乘联动由 Player/Vehicle 子类覆写
*/
@ccclass('Movement')
export class Movement extends Component {
private static speedMultiplier = 1;
static setSpeedMultiplier(m: number) {
Movement.speedMultiplier = m > 0 ? m : 1;
if (typeof globalThis !== 'undefined') {
(globalThis as { __tfrhGameSpeed?: number }).__tfrhGameSpeed = Movement.speedMultiplier;
}
}
static getSpeedMultiplier(): number {
return Movement.speedMultiplier;
}
@property
moveSpeed = 4;
moveSpeed = scaledMoveSpeed(UNITY_VEHICLE_MOVE_SPEED);
direction: Direction = Direction.North;
moveState: MoveState = MoveState.Idle;
moverRole: MoverRole = 'player';
protected targetPosition = new Vec3();
/** 目标格类型(含 Ride 动态覆盖),对齐 Unity Movement.targetGridType */
protected targetGridType: GridType = GridType.None;
protected lastPosition = new Vec3();
protected step = 0;
private moveWait = false;
protected moveWait = false;
protected committedCell: Vec3 | null = null;
/** 关卡 spawn 逻辑格(优先于从世界坐标反推,避免站立 Y 微调导致格偏移) */
protected spawnCell: Vec3 | null = null;
protected landingCell: Vec3 | null = null;
private moveStepFrom = new Vec3();
private queue: Promise<void> = Promise.resolve();
static callEach = false;
protected getMoverLocalPosition(): Vec3 {
return this.node.position.clone();
}
setSpawnCell(cell: Vec3) {
this.spawnCell = cell.clone();
if (!this.committedCell) this.committedCell = new Vec3();
this.committedCell.set(cell);
}
getSpawnCell(): Vec3 | null {
return this.spawnCell ? this.spawnCell.clone() : null;
}
protected syncCommittedCellFromPosition() {
const gm = GameManager.instance;
if (!gm) return;
if (this.spawnCell) {
if (!this.committedCell) this.committedCell = new Vec3();
this.committedCell.set(this.spawnCell);
this.spawnCell = null;
return;
}
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
if (!this.committedCell) this.committedCell = new Vec3();
const cell = worldToMoverCellForRole(
this.getMoverLocalPosition(),
config ?? undefined,
theme,
this.moverRole,
);
this.committedCell.set(cell);
}
public shareCommittedCell(cell: Vec3) {
if (!this.committedCell) this.committedCell = new Vec3();
this.committedCell.set(cell);
}
getCommittedCell(): Vec3 | null {
return this.committedCell ? this.committedCell.clone() : null;
}
getLandingCell(): Vec3 | null {
return this.landingCell ? this.landingCell.clone() : null;
}
isMoving(): boolean {
return this.moveState === MoveState.Moving;
}
/** 当前移动步进度 0起步→ 1落点 */
getMoveStepProgress(): number {
if (this.moveState !== MoveState.Moving) return 1;
const total = Vec3.distance(this.moveStepFrom, this.targetPosition);
if (total < 1e-4) return 1;
const remain = Vec3.distance(this.node.position, this.targetPosition);
return Math.max(0, Math.min(1, 1 - remain / total));
}
/** 逻辑格采样(含动态 Ride骑乘时由 PlayerController 覆写 */
protected getGridSamplePosition(): Vec3 {
if (this.committedCell) {
return cellToWorldCenter(this.committedCell);
}
const gm = GameManager.instance;
const local = this.getMoverLocalPosition();
if (!gm) return local;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
if (this.moverRole === 'player' || this.moverRole === 'vehicle') {
return moverLogicalGridPositionForRole(
local,
config ?? undefined,
theme,
this.moverRole,
);
}
return local;
}
/** 当前格(含 Ride 动态格) */
get curGrid(): GridType {
return GameManager.instance!.calculateGridType(this.node.position);
const gm = GameManager.instance!;
if (this.committedCell) return gm.calculateGridTypeAtCell(this.committedCell);
return gm.calculateGridType(this.getGridSamplePosition());
}
get nextGrid(): GridType {
return GameManager.instance!.calculateNextGridType(this.node.position, this.direction);
const gm = GameManager.instance!;
if (this.committedCell) return gm.calculateNextGridTypeAtCell(this.committedCell, this.direction);
return gm.calculateNextGridType(this.getGridSamplePosition(), this.direction);
}
get lastGrid(): GridType {
return GameManager.instance!.calculateLastGridType(this.node.position, this.direction);
const gm = GameManager.instance!;
if (this.committedCell) return gm.calculateLastGridTypeAtCell(this.committedCell, this.direction);
return gm.calculateLastGridType(this.getGridSamplePosition(), this.direction);
}
get isFront(): boolean {
return this.direction === Direction.South || this.direction === Direction.East;
}
start() {
this.setDirection(this.direction);
this.syncCommittedCellFromPosition();
}
protected commitLandingCell() {
if (this.landingCell) {
if (!this.committedCell) this.committedCell = new Vec3();
this.committedCell.set(this.landingCell);
this.landingCell = null;
}
this.snapMoverToCellStand();
}
protected shouldApplyPlayerStandOffset(): boolean {
return roleUsesPlayerStandOffset(this.moverRole);
}
protected snapMoverToCellStand() {
const gm = GameManager.instance;
if (!gm || !this.committedCell) return;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const pos = entityWorldPositionForRole(
this.committedCell,
config ?? undefined,
theme,
this.moverRole,
);
this.node.setPosition(pos);
this.targetPosition.set(pos);
}
/** 主题 entityDisplay 变更后重算站立 Y缩放刷新不会自动更新节点坐标 */
reapplyCellStandPosition() {
this.snapMoverToCellStand();
}
resetMoveRuntime() {
this.moveState = MoveState.Idle;
this.moveWait = false;
this.step = 0;
this.queue = Promise.resolve();
}
update(dt: number) {
if (this.moveState !== MoveState.Moving) return;
const pos = this.node.position;
const next = new Vec3();
Vec3.moveTowards(next, pos, this.targetPosition, this.moveSpeed * dt);
const speedMul = GameManager.instance?.getGameSpeed() ?? Movement.speedMultiplier;
Vec3.moveTowards(next, pos, this.targetPosition, this.moveSpeed * dt * speedMul);
this.node.setPosition(next);
this.onMoving();
if (Vec3.distance(next, this.targetPosition) < 0.01) {
this.syncRideAfterMoveStep();
if (Vec3.distance(next, this.targetPosition) < 0.005) {
this.node.setPosition(this.targetPosition);
this.moveState = MoveState.Idle;
this.moveWait = false;
@@ -57,47 +225,70 @@ export class Movement extends Component {
}
}
protected syncRideAfterMoveStep() {}
setDirection(dir: Direction) {
this.direction = dir;
}
protected onMoving() {}
protected onMoveToTarget() {}
protected onMoveToTarget() {
this.commitLandingCell();
}
/** 转向结束Unity RotateCoroutine 只改朝向,不重新 snap 落点) */
protected onRotateComplete() {}
protected onMoveNextSet(_isJump: boolean) {}
protected onMoveFail(_isJump: boolean) {}
protected playMoveAnim() {}
/** 本步移动起点(子类可去掉骑乘视觉抬高等) */
protected resolveMoveStepOrigin(_targetCell: Vec3, _landingGrid: GridType): Vec3 {
return this.node.position.clone();
}
/** 本步移动落点世界坐标 */
protected resolveTargetWorldPosition(targetCell: Vec3, landingGrid: GridType): Vec3 {
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
return entityWorldPositionForRole(
targetCell,
config ?? undefined,
theme,
this.moverRole,
);
}
protected extraMoveStepCheck(_isJump: boolean, _toFront: boolean): MoveCheckResult | null {
return null;
}
private moveNextCheck(isJump: boolean, toFront: boolean): number {
const gm = GameManager.instance!;
const mult = gm.isMultMode();
const dir = toFront ? this.direction : addDirection(this.direction, 2);
const targetTmp = gm.nextGridPosition(this.node.position, dir);
const targetType = gm.calculateGridType(targetTmp);
const table = getMoveCondition(gm.multMode);
const nextG = toFront ? this.nextGrid : this.lastGrid;
const v = lookupMove(table, this.moverRole, this.curGrid, nextG, isJump);
if (v !== undefined) {
if (v === 1) {
if (gm.multMode) {
const nextCell = gm.worldToCell(targetTmp);
for (const n of ['PlayerA1', 'PlayerA2', 'PlayerA3', 'PlayerB1', 'PlayerB2', 'PlayerB3']) {
const p = gm.findNodeByName(n);
if (p) {
const c = gm.worldToCell(p.worldPosition);
if (c.x === nextCell.x && c.y === nextCell.y) return 0;
}
}
if (nextG === GridType.Ride) {
const ride = gm.getGameObject(targetTmp);
const expect = this.node.name.replace('Player', 'Vehicle');
if (ride && ride.name !== expect) return 0;
}
}
this.targetPosition.set(targetTmp);
this.targetGridType = targetType;
}
return v;
const blocked = this.extraMoveStepCheck(isJump, toFront);
if (blocked !== null) return blocked;
const r = checkMoveStep(this.moverRole, this.curGrid, nextG, isJump, mult);
if (r !== 1) return r;
const targetTmp = gm.nextGridPosition(this.getGridSamplePosition(), dir);
const targetCell = gm.worldToCell(targetTmp);
if (mult && nextG === GridType.Ride) {
const rideNode = gm.getGameObjectAtCell(targetCell);
const expectedVehicle = this.node.name.replace('Player', 'Vehicle');
if (rideNode && rideNode.name !== expectedVehicle) return 0;
}
return gm.multMode ? 0 : -1;
if (!this.landingCell) this.landingCell = new Vec3();
this.landingCell.set(targetCell);
const landingGrid = gm.calculateGridTypeAtCell(targetCell);
const targetPos = this.resolveTargetWorldPosition(targetCell, landingGrid);
this.targetGridType = landingGrid;
this.moveStepFrom.set(this.resolveMoveStepOrigin(targetCell, landingGrid));
this.targetPosition.set(targetPos);
return 1;
}
private enqueue(fn: () => Promise<void>) {
@@ -128,13 +319,18 @@ export class Movement extends Component {
}
if (r === 1) {
this.moveWait = true;
this.lastPosition.set(this.node.position);
this.lastPosition.set(this.getGridSamplePosition());
this.moveState = MoveState.Moving;
this.onMoveNextSet(isJump);
await this.waitUntil(() => !this.moveWait);
} else {
this.playMoveAnim();
this.moveState = MoveState.Moving;
// 本步不可走但步数已消耗:须回传 processData / processVehicleData
if (this.step === 0) {
this.moveState = MoveState.Idle;
this.onMoveToTarget();
}
}
}
}
@@ -144,14 +340,20 @@ export class Movement extends Component {
this.moveWait = true;
this.setDirection(addDirection(this.direction, n));
this.moveWait = false;
this.onRotateComplete();
// 对齐 Unity RotateCoroutine → OnMoveToTarget → ExternalCallScratch/Python 等待 processData
this.onMoveToTarget();
}
protected jsCallCheck(n: number): boolean {
const gm = GameManager.instance!;
const name = this.node.name;
if (name === 'Player' || name === 'Vehicle') return gm.jsCallCheck(n);
if (gm.multMode) return gm.jsCallCheckMultMode(n, name);
if (
name === 'Player' || name === 'Vehicle'
|| /^Player[AB]\d$/.test(name) || /^Vehicle[AB]\d$/.test(name)
) {
return gm.jsCallCheck(n);
}
return false;
}

View File

@@ -0,0 +1,25 @@
import { SpawnKind } from './LevelTypes';
import { CELL_PIXEL } from '../core/GridConstants';
import { getEntityCellBox } from '../visual/EntityDisplayRefs';
/** spawns[].scale 为相对默认值的比例1 = 默认大小 */
export function resolveEntityScaleMul(kind: SpawnKind, spawnScale?: number): number {
const mul = spawnScale !== undefined && spawnScale !== null && !Number.isNaN(spawnScale)
? spawnScale
: 1;
return Math.max(0.1, Math.min(4, mul));
}
export function resolveEntityHeight(kind: 'player' | 'vehicle', spawnScale?: number, theme?: string): number {
const box = getEntityCellBox(theme)[kind];
return CELL_PIXEL * box.h * resolveEntityScaleMul(kind, spawnScale);
}
/** 可拾取物等基准缩放spawn scale 倍率用,显示尺寸见 themes-database entityDisplay */
export const ENTITY_BASE_SCALE: Record<SpawnKind, number> = {
player: 1,
vehicle: 1,
prop: 1,
prop_decor: 0.87,
enemy: 1,
};

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cc10897c-bd78-42c5-8443-ed54a4b1d461",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,185 @@
import { Vec3 } from 'cc';
import { CELL_PIXEL } from '../core/GridConstants';
import { CommonDefine, MoverRole } from '../core/Define';
import { cellToWorldCenter, worldCenterToCell } from '../core/GridCoords';
import {
getThemePropPlacementOffsets,
getThemeMoverEmptyCellYOffset,
getThemeMoverJumpCellYOffset,
getThemePlayerStandYOffset,
DEFAULT_PROP_BLOCK_Y_OFFSET,
DEFAULT_PROP_GROUND_Y_OFFSET,
} from '../theme/ThemeDatabase';
import { getTilePivot } from '../visual/TilePivots';
import { getTileDrawSize } from '../visual/TileSizes';
import { LevelConfig, SpawnConfig } from './LevelTypes';
/** 与 Unity Prop / nProp 一致:砖块上偏高,空地偏低 */
export type PropPlacement = 'block' | 'ground';
const HALF_H = CELL_PIXEL * 0.25;
/** Unity checkPoint 偏移PPU≈100Prop ≈ +14pxnProp ≈ -11px */
export const PROP_BLOCK_Y_OFFSET = DEFAULT_PROP_BLOCK_Y_OFFSET;
export const PROP_GROUND_Y_OFFSET = DEFAULT_PROP_GROUND_Y_OFFSET;
export function cellHasTile(cellX: number, cellY: number, config: LevelConfig | undefined): boolean {
if (!config) return false;
const key = `${cellX},${cellY}`;
const hasGround = !!config.ground?.[key];
const hasBorder = config.border?.[key] !== undefined && config.border?.[key] !== false;
return hasGround || hasBorder;
}
/** 砖块 pivot 对齐格心后,行走面相对格心的抬高量(与 Baseblock 差值用于 JumpBlock */
function tileWalkSurfaceAboveCenter(tileName: string, theme?: string): number {
const draw = getTileDrawSize(tileName, undefined, undefined, theme);
const pivot = getTilePivot(tileName, theme);
return draw.height * (1 - pivot.y);
}
/** JumpBlock 比 Baseblock 更高的行走面补偿px */
export function resolveMoverJumpCellYOffset(
cellX: number,
cellY: number,
config: LevelConfig | undefined,
theme?: string,
): number {
const key = `${cellX},${cellY}`;
const tile = config?.ground?.[key];
if (tile !== CommonDefine.BlockJump) return 0;
const auto = tileWalkSurfaceAboveCenter(CommonDefine.BlockJump, theme)
- tileWalkSurfaceAboveCenter(CommonDefine.BlockBase, theme);
return auto + getThemeMoverJumpCellYOffset(theme);
}
/** 角色/载具JumpBlock 抬高;有普通砖为 0空地/载具格补齐 Baseblock 高度 */
export function resolveMoverCellStandYOffset(
cellX: number,
cellY: number,
config: LevelConfig | undefined,
theme?: string,
): number {
if (!cellHasTile(cellX, cellY, config)) {
return getThemeMoverEmptyCellYOffset(theme);
}
return resolveMoverJumpCellYOffset(cellX, cellY, config, theme);
}
export function entityMoverWorldPosition(
cell: Vec3,
config: LevelConfig | undefined,
theme?: string,
): Vec3 {
const pos = cellToWorldCenter(cell);
pos.y += resolveMoverCellStandYOffset(cell.x, cell.y, config, theme);
return pos;
}
/**
* 从带站立/空地 Y 微调的世界坐标反推逻辑格(与 entityMoverWorldPosition 互逆)。
* 视觉 Y 会干扰等距 worldToCell移动采样须先还原到格心平面。
*/
export function worldToMoverCell(
world: Vec3,
config: LevelConfig | undefined,
theme?: string,
forPlayer = false,
): Vec3 {
let cell = worldCenterToCell(world);
for (let i = 0; i < 4; i++) {
const off = resolveMoverCellStandYOffset(cell.x, cell.y, config, theme);
const stand = forPlayer ? getThemePlayerStandYOffset(theme) : 0;
const adjustedY = world.y - stand - off;
const next = worldCenterToCell(new Vec3(world.x, adjustedY, world.z));
if (next.x === cell.x && next.y === cell.y) break;
cell = next;
}
return cell;
}
/**
* 移动/格子判定用的逻辑采样点(格心,不含主题站立 Y
* @param levelLocal 关卡根节点本地坐标(与砖块 alignTileNode 同一空间)
*/
export function moverLogicalGridPosition(
levelLocal: Vec3,
config: LevelConfig | undefined,
theme?: string,
forPlayer = false,
): Vec3 {
return cellToWorldCenter(worldToMoverCell(levelLocal, config, theme, forPlayer));
}
/** 角色生成/移动位置(含主题站立 Y 微调) */
export function entityPlayerWorldPosition(
cell: Vec3,
config: LevelConfig | undefined,
theme?: string,
): Vec3 {
const pos = entityMoverWorldPosition(cell, config, theme);
pos.y += getThemePlayerStandYOffset(theme);
return pos;
}
/** 角色/载具是否按玩家站立 Y 反推逻辑格(与 worldToMoverCell / entityPlayerWorldPosition 一致) */
export function roleUsesPlayerStandOffset(role: MoverRole): boolean {
return role === 'player';
}
export function worldToMoverCellForRole(
world: Vec3,
config: LevelConfig | undefined,
theme: string | undefined,
role: MoverRole,
): Vec3 {
return worldToMoverCell(world, config, theme, roleUsesPlayerStandOffset(role));
}
export function moverLogicalGridPositionForRole(
levelLocal: Vec3,
config: LevelConfig | undefined,
theme: string | undefined,
role: MoverRole,
): Vec3 {
return moverLogicalGridPosition(
levelLocal,
config,
theme,
roleUsesPlayerStandOffset(role),
);
}
/** 逻辑格 → 世界站立点player 含主题站立 Yvehicle 为 mover 甲板) */
export function entityWorldPositionForRole(
cell: Vec3,
config: LevelConfig | undefined,
theme: string | undefined,
role: MoverRole,
): Vec3 {
return roleUsesPlayerStandOffset(role)
? entityPlayerWorldPosition(cell, config, theme)
: entityMoverWorldPosition(cell, config, theme);
}
export function propWorldYOffset(placement: PropPlacement, theme?: string): number {
const offsets = getThemePropPlacementOffsets(theme);
return placement === 'ground' ? offsets.ground : offsets.block;
}
/** 无 ground/border 视为空地nProp有砖块视为 Prop */
export function resolvePropPlacement(spawn: SpawnConfig, config: LevelConfig): PropPlacement {
const key = `${spawn.x},${spawn.y}`;
const hasGround = !!config.ground?.[key];
const hasBorder = config.border?.[key] !== undefined && config.border?.[key] !== false;
if (spawn.propPlacement === 'ground' || spawn.propPlacement === 'block') {
if (!hasGround && !hasBorder) return 'ground';
return spawn.propPlacement;
}
if (!hasGround && !hasBorder) return 'ground';
return 'block';
}
export function isGroundPropSpawn(spawn: SpawnConfig, config: LevelConfig): boolean {
return spawn.kind === 'prop' && resolvePropPlacement(spawn, config) === 'ground';
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "20691257-985a-4a52-96da-6696502e60ff",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,366 @@
import {
_decorator, Component, Vec3, Graphics, Color, Node, UITransform, Layers, Prefab,
} from 'cc';
import { EDITOR, PREVIEW, BUILD } from 'cc/env';
import {
cellToWorldCenter, tileNodeName, parseTileNodeName, getHalfCellSize,
} from '../core/GridCoords';
import { CommonDefine } from '../core/Define';
import { LevelMapData } from './LevelMapData';
import { alignTileNode, setupLayerContainer, sortIsoTiles } from './TileLayout';
const { ccclass, property, executeInEditMode, executionOrder } = _decorator;
const UI_LAYER = Layers.Enum.UI_2D;
/** 与关卡编辑器 canvas 一致(仅网格线,无填充) */
const GRID_STROKE = new Color(85, 85, 85, 255);
const CELL_STROKE = new Color(74, 158, 255, 255);
const RUNTIME_GRID_NODE_NAMES = new Set(['_GridSnapGizmo']);
/**
* 挂在关卡预制体根节点LevelN。编辑器中
* - 显示与关卡地图编辑面板一致的等距菱形参考格(无灰底,不遮挡主题背景)
* - 拖动 Ground/Border/Tiles 下瓦片时吸附到格子中心
*/
@ccclass('GridSnapHelper')
@executeInEditMode(true)
@executionOrder(-200)
export class GridSnapHelper extends Component {
@property({ displayName: '吸附格子', tooltip: '场景内移动瓦片时自动对齐格子中心' })
snapEnabled = true;
@property({ displayName: '显示参考网格' })
showGrid = false;
@property({ displayName: '网格半径', min: 4, max: 24, step: 1, tooltip: '无地图数据时的备用半径' })
gridRadius = 12;
@property({ displayName: '地图周围额外格数', min: 0, max: 6, step: 1 })
gridPadding = 2;
@property({ displayName: '高亮已有砖块的格子' })
highlightOccupied = true;
@property({ displayName: '吸附时同步节点名' })
syncNodeNames = true;
private _gridHost: Node | null = null;
private _graphics: Graphics | null = null;
private _alignedOnce = false;
/** 仅「编辑器内编辑预制体/场景、未点预览」时为 true */
static isEditingInEditor(): boolean {
return EDITOR && !PREVIEW && !BUILD;
}
/** 预览、构建、真机运行 */
static isRuntime(): boolean {
return !GridSnapHelper.isEditingInEditor();
}
/**
* 实例化前关闭 prefab 内 showGrid仅当 prefab.data 可安全访问时)。
* 编辑器预览若脚本未编译完成,访问 prefab.data 可能抛错;失败时由实例化后 stripBeforePlay 兜底。
*/
static stripBeforePlayFromPrefab(prefab: Prefab) {
try {
const root = prefab.data as Node | null;
if (!root?.isValid) return;
for (const snap of root.getComponentsInChildren(GridSnapHelper)) {
if (!snap?.isValid) continue;
snap.showGrid = false;
snap.enabled = false;
}
} catch (e) {
console.warn('[GridSnapHelper] stripBeforePlayFromPrefab 跳过(将在实例化后清理)', e);
}
}
/** 关卡实例化后立即剥离编辑器网格 */
static stripBeforePlay(levelRoot: Node) {
if (!levelRoot?.isValid) return;
for (const snap of levelRoot.getComponents(GridSnapHelper)) {
if (!snap.isValid) continue;
snap.showGrid = false;
snap.enabled = false;
snap.removeGridGizmo();
}
GridSnapHelper.destroyGridGizmoNodes(levelRoot);
}
/** 预览/构建/真机运行:彻底移除一切网格节点与组件(无条件,由游戏逻辑调用) */
static purgeRuntimeGrids(root: Node) {
if (!root?.isValid) return;
try {
for (const snap of [...root.getComponentsInChildren(GridSnapHelper)]) {
if (!snap?.isValid) continue;
snap.showGrid = false;
snap.enabled = false;
snap.removeGridGizmo();
snap.destroy();
}
GridSnapHelper.destroyGridGizmoNodes(root);
} catch (e) {
console.warn('[GridSnapHelper] purgeRuntimeGrids 部分失败', e);
}
}
/** 递归销毁编辑器参考格节点 */
static destroyGridGizmoNodes(root: Node) {
const walk = (node: Node) => {
for (let i = node.children.length - 1; i >= 0; i--) {
const ch = node.children[i];
if (RUNTIME_GRID_NODE_NAMES.has(ch.name)) {
ch.getComponent(Graphics)?.clear();
ch.destroy();
continue;
}
walk(ch);
}
};
walk(root);
}
static purgeScene(scene: Node) {
GridSnapHelper.purgeRuntimeGrids(scene);
}
/** @deprecated 使用 purgeRuntimeGrids */
static stripRuntimeGrid(levelRoot: Node) {
GridSnapHelper.purgeRuntimeGrids(levelRoot);
}
onLoad() {
if (!GridSnapHelper.isEditingInEditor()) {
this.showGrid = false;
this.enabled = false;
this.removeGridGizmo();
this.destroy();
return;
}
this.ensureGridGraphics();
this.alignAllTiles();
this._alignedOnce = true;
}
onEnable() {
if (!GridSnapHelper.isEditingInEditor()) {
this.showGrid = false;
this.removeGridGizmo();
return;
}
if (!this.showGrid) return;
this.ensureGridGraphics();
if (!this._alignedOnce) this.alignAllTiles();
this.drawReferenceGrid();
}
start() {
if (!GridSnapHelper.isEditingInEditor()) this.removeGridGizmo();
}
removeGridGizmo() {
this.showGrid = false;
const host = this.node.getChildByName('_GridSnapGizmo');
if (host) {
host.getComponent(Graphics)?.clear();
host.destroy();
}
this._gridHost = null;
this._graphics = null;
}
lateUpdate() {
if (!GridSnapHelper.isEditingInEditor()) {
this.removeGridGizmo();
return;
}
if (!this.showGrid) return;
this.drawReferenceGrid();
}
/** 供扩展场景脚本或外部调用 */
snapNode(node: Node | null) {
if (!node || !node.isValid) return;
const layer = this.resolveLayer(node);
if (!layer) return;
const { ground, border } = this.readMapJson();
this.applySnap(node, layer, ground, border);
sortIsoTiles(this.node);
}
private resolveLayer(node: Node): 'ground' | 'border' | null {
let cur: Node | null = node;
while (cur) {
if (cur.name === 'Ground') return 'ground';
if (cur.name === 'Border') return 'border';
if (cur === this.node) break;
cur = cur.parent;
}
const parsed = parseTileNodeName(node.name);
return parsed?.layer ?? null;
}
private readMapJson(): {
ground: Record<string, string>;
border: Record<string, boolean | string>;
} {
const md = this.node.getComponent(LevelMapData);
if (!md) return { ground: {}, border: {} };
try {
return {
ground: JSON.parse(md.groundJson || '{}'),
border: JSON.parse(md.borderJson || '{}'),
};
} catch {
return { ground: {}, border: {} };
}
}
private occupiedKeys(ground: Record<string, string>, border: Record<string, boolean | string>): Set<string> {
return new Set([...Object.keys(ground), ...Object.keys(border)]);
}
/** 编辑器打开预制体时对齐全部瓦片并合并深度排序 */
alignAllTiles() {
const { ground, border } = this.readMapJson();
if (this.snapEnabled) {
this.snapLayerChildren('Ground', 'ground', ground, border);
this.snapLayerChildren('Border', 'border', ground, border);
this.snapLayerChildren('Tiles', null, ground, border);
}
sortIsoTiles(this.node);
}
private snapLayerChildren(
layerName: string,
layer: 'ground' | 'border' | null,
groundMap: Record<string, string>,
borderMap: Record<string, boolean | string>,
) {
const layerNode = this.node.getChildByName(layerName);
if (!layerNode) return;
setupLayerContainer(layerNode);
for (const ch of layerNode.children) {
const parsed = parseTileNodeName(ch.name);
if (!parsed) continue;
const useLayer = layer ?? parsed.layer;
this.applySnap(ch, useLayer, groundMap, borderMap);
}
}
private inferTileName(
layer: 'ground' | 'border',
key: string,
groundMap: Record<string, string>,
borderMap: Record<string, boolean | string>,
): string {
if (layer === 'ground') return groundMap[key] ?? CommonDefine.BlockBase;
const v = borderMap[key];
if (typeof v === 'string') return v;
return 'WallBlock';
}
private applySnap(
node: Node,
layer: 'ground' | 'border',
groundMap: Record<string, string>,
borderMap: Record<string, boolean | string>,
) {
const parsed = parseTileNodeName(node.name);
if (!parsed) return;
const key = `${parsed.x},${parsed.y}`;
const tileName = this.inferTileName(layer, key, groundMap, borderMap);
alignTileNode(node, parsed.x, parsed.y, tileName);
if (this.syncNodeNames) {
const expected = tileNodeName(layer, parsed.x, parsed.y);
if (node.name !== expected) node.name = expected;
}
}
private ensureGridGraphics() {
if (!GridSnapHelper.isEditingInEditor()) return;
let host = this.node.getChildByName('_GridSnapGizmo');
if (!host) {
host = new Node('_GridSnapGizmo');
host.parent = this.node;
}
host.layer = UI_LAYER;
host.setSiblingIndex(0);
let ui = host.getComponent(UITransform);
if (!ui) ui = host.addComponent(UITransform);
ui.setAnchorPoint(0, 0);
ui.setContentSize(1, 1);
let g = host.getComponent(Graphics);
if (!g) g = host.addComponent(Graphics);
this._gridHost = host;
this._graphics = g;
}
private iterGridCells(): { x: number; y: number }[] {
const { ground, border } = this.readMapJson();
const keys = [...Object.keys(ground), ...Object.keys(border)];
if (!keys.length) {
const out: { x: number; y: number }[] = [];
const r = this.gridRadius;
for (let x = -r; x <= r; x++) {
for (let y = -r; y <= r; y++) out.push({ x, y });
}
return out;
}
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const k of keys) {
const [xs, ys] = k.split(',');
const x = parseInt(xs, 10);
const y = parseInt(ys, 10);
if (Number.isNaN(x) || Number.isNaN(y)) continue;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
const pad = this.gridPadding;
const out: { x: number; y: number }[] = [];
for (let x = minX - pad; x <= maxX + pad; x++) {
for (let y = minY - pad; y <= maxY + pad; y++) out.push({ x, y });
}
return out;
}
private traceDiamond(g: Graphics, cx: number, cy: number, halfW: number, halfH: number) {
g.moveTo(cx, cy + halfH);
g.lineTo(cx + halfW, cy);
g.lineTo(cx, cy - halfH);
g.lineTo(cx - halfW, cy);
g.close();
}
private drawReferenceGrid() {
if (!GridSnapHelper.isEditingInEditor() || !this.showGrid) return;
this.ensureGridGraphics();
const g = this._graphics;
if (!g) return;
const { halfW, halfH } = getHalfCellSize();
const cells = this.iterGridCells();
const { ground, border } = this.readMapJson();
const occupied = this.highlightOccupied ? this.occupiedKeys(ground, border) : new Set<string>();
g.clear();
for (const { x, y } of cells) {
const w = cellToWorldCenter(new Vec3(x, y, 0));
const key = `${x},${y}`;
const isOcc = occupied.has(key);
g.lineWidth = isOcc ? 1.5 : 1;
g.strokeColor = isOcc ? CELL_STROKE : GRID_STROKE;
this.traceDiamond(g, w.x, w.y, halfW, halfH);
g.stroke();
}
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "739b26eb-64a3-4dd7-a084-cbe854d98a36",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,69 @@
import { Node } from 'cc';
import { LevelConfig, SpawnConfig } from './LevelTypes';
import { LevelMapData } from './LevelMapData';
function hasKeys(rec?: Record<string, unknown>): boolean {
return !!rec && Object.keys(rec).length > 0;
}
/** 地图是否覆盖玩家/道具/载具 spawn 格(旧预制体常残留错误横向砖块) */
function groundCoversSpawns(ground: Record<string, unknown> | undefined, spawns: SpawnConfig[]): boolean {
if (!ground || !spawns.length) return false;
for (const s of spawns) {
if (s.kind !== 'player' && s.kind !== 'prop' && s.kind !== 'vehicle') continue;
if (!ground[`${s.x},${s.y}`]) return false;
}
return true;
}
/** Cocos 预制体 LevelMapData 优先DB 仅作 spawns 载体时的回退 */
function pickGround(
fromPrefab: LevelConfig['ground'],
fromDb: LevelConfig['ground'],
spawns: SpawnConfig[],
): LevelConfig['ground'] {
if (groundCoversSpawns(fromPrefab, spawns)) return fromPrefab;
if (groundCoversSpawns(fromDb, spawns)) return fromDb;
if (hasKeys(fromPrefab)) return fromPrefab;
return fromDb;
}
function parseJsonRecord(json: string): Record<string, unknown> | undefined {
try {
const o = JSON.parse(json || '{}') as unknown;
if (o && typeof o === 'object' && !Array.isArray(o)) {
return o as Record<string, unknown>;
}
} catch {
/* ignore */
}
return undefined;
}
/**
* Cocos 预制体 LevelMapData 为地图/主题权威levels-database 主要提供 spawns。
*/
export function mergeLevelConfigWithMapData(config: LevelConfig, levelRoot: Node): LevelConfig {
if (!levelRoot?.isValid) return config;
const md = levelRoot.getComponent(LevelMapData);
if (!md) return config;
const prefabGround = parseJsonRecord(md.groundJson) as LevelConfig['ground'];
const prefabBorder = parseJsonRecord(md.borderJson) as LevelConfig['border'];
const prefabTheme = md.theme?.trim();
const border = hasKeys(prefabBorder as Record<string, unknown>)
? prefabBorder
: config.border;
return {
...config,
theme: prefabTheme || config.theme?.trim() || config.theme,
ground: pickGround(
prefabGround as LevelConfig['ground'],
config.ground,
config.spawns ?? [],
),
border,
};
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4a788bad-35f7-49ff-9c2e-129ae9d74c7b",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,282 @@
/**
* 关卡数据库 — 由 Cocos 工程导出level-prefabs LevelMapData + 编辑器 spawns
*
* - 编辑器 ▶ 预览:从 assets/level-data/ 加载(不在 resources bundlefetch 不可用)
* - 主站 / Web 运行fetch /unity/levels-database.json
*/
import { assetManager, JsonAsset } from 'cc';
import { PREVIEW } from 'cc/env';
import { LevelConfig } from './LevelTypes';
import { LEVEL_ID_BASE } from './LevelIds';
/** assets/level-data/levels-database.json与 .meta 一致,勿打进 resources */
const EDITOR_DB_UUID = '114420ca-c8e3-4204-a040-18282ef5e964';
export interface LevelDatabaseFile {
version: number;
generatedAt?: string;
source?: string;
levelIdBase?: number;
stats?: { total: number; withPrefabTilemap?: number; withBoundaryRing?: number };
levels: Record<string, LevelConfig>;
}
declare global {
interface Window {
/** 主站 scratch-gui 注入:/unity/levels-database.json */
__tfrhLevelsDatabaseUrl?: string;
/** loader 预注入的 JSON可选 */
__tfrhLevelsDatabaseJson?: LevelDatabaseFile;
}
}
let fileCache: LevelDatabaseFile | null = null;
let levelsMap: Record<number, LevelConfig> = {};
let sortedIds: number[] = [];
let loadPromise: Promise<void> | null = null;
function rebuildIndex() {
sortedIds = Object.keys(levelsMap)
.map((k) => parseInt(k, 10))
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);
}
function ingestFile(data: LevelDatabaseFile) {
fileCache = data;
levelsMap = {};
for (const [k, cfg] of Object.entries(data.levels ?? {})) {
const id = parseInt(k, 10);
if (!Number.isNaN(id)) {
levelsMap[id] = { ...cfg, levelID: id };
}
}
rebuildIndex();
}
function resolveRemoteUrl(): string | null {
if (typeof window === 'undefined') return null;
const url = window.__tfrhLevelsDatabaseUrl;
return url?.trim() || null;
}
function validateIngested(): void {
const total = sortedIds.length;
const minId = sortedIds[0] ?? 0;
if (total < 100 || minId < LEVEL_ID_BASE) {
throw new Error(
`关卡库过旧 (${total} 关),请用主站导出的 levels-database.json`
+ '运行 bash tools/sync-level-db.sh 后重新 package-for-project',
);
}
}
function canUseHttpFetch(): boolean {
if (typeof window === 'undefined') return false;
const proto = window.location.protocol;
return proto === 'http:' || proto === 'https:';
}
function fetchCandidates(): string[] {
const out: string[] = [];
const configured = resolveRemoteUrl();
if (configured && /^https?:\/\//i.test(configured)) {
out.push(configured);
}
if (canUseHttpFetch()) {
out.push(new URL('levels-database.json', window.location.href).href);
out.push(new URL('/unity/levels-database.json', window.location.origin).href);
}
return [...new Set(out)];
}
function loadFromRemote(url: string): Promise<void> {
const abs = /^https?:\/\//i.test(url)
? url
: new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/').href;
return fetch(abs, { credentials: 'same-origin' })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} for ${abs}`);
return res.json() as Promise<LevelDatabaseFile>;
})
.then((json) => {
ingestFile(json);
validateIngested();
console.log(
`[LevelDatabase] 已加载 ${sortedIds.length} 关 (`
+ `${sortedIds[0] ?? '?'}${sortedIds[sortedIds.length - 1] ?? '?'})`,
abs,
);
});
}
async function loadFromNetwork(): Promise<void> {
const candidates = fetchCandidates();
if (!candidates.length) {
throw new Error('[LevelDatabase] 当前环境无法 fetch且无预注入关卡库');
}
const errors: unknown[] = [];
for (const url of candidates) {
try {
await loadFromRemote(url);
return;
} catch (e) {
errors.push(e);
}
}
const detail = errors.map((e) => String(e)).join('; ');
throw new Error(
`[LevelDatabase] 无法加载关卡库,已尝试: ${candidates.join(', ')}; ${detail}`,
);
}
/** 编辑器预览packages:// 协议下用 assetManager 读工程内 JSON不进 resources bundle */
function loadFromEditorAsset(): Promise<void> {
return new Promise((resolve, reject) => {
assetManager.loadAny({ uuid: EDITOR_DB_UUID }, (err: Error | null, asset: JsonAsset) => {
if (err || !asset?.json) {
reject(err ?? new Error('assets/level-data/levels-database.json 未找到'));
return;
}
ingestFile(asset.json as LevelDatabaseFile);
validateIngested();
console.log(
`[LevelDatabase] 已加载编辑器关卡库 ${sortedIds.length} 关 (`
+ `${sortedIds[0] ?? '?'}${sortedIds[sortedIds.length - 1] ?? '?'})`,
);
resolve();
});
});
}
async function waitForInjection(maxMs = 2500): Promise<LevelDatabaseFile | null> {
if (typeof window === 'undefined') return null;
const step = 50;
const tries = Math.ceil(maxMs / step);
for (let i = 0; i < tries; i++) {
const json = window.__tfrhLevelsDatabaseJson;
if (json) return json;
await new Promise((r) => setTimeout(r, step));
}
return null;
}
/** 异步加载AppBootstrap 启动时调用) */
export function loadLevelDatabase(): Promise<void> {
if (fileCache) return Promise.resolve();
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
if (typeof window !== 'undefined' && window.__tfrhLevelsDatabaseJson) {
ingestFile(window.__tfrhLevelsDatabaseJson);
validateIngested();
console.log(`[LevelDatabase] 已注入 ${sortedIds.length}`);
return;
}
const injected = PREVIEW ? await waitForInjection(500) : null;
if (injected) {
ingestFile(injected);
validateIngested();
console.log(`[LevelDatabase] 已注入 ${sortedIds.length}`);
return;
}
if (!canUseHttpFetch()) {
await loadFromEditorAsset();
return;
}
if (PREVIEW) {
try {
await loadFromEditorAsset();
return;
} catch (e) {
console.warn('[LevelDatabase] 编辑器资源加载失败,尝试 fetch', e);
}
}
await loadFromNetwork();
} catch (e) {
loadPromise = null;
throw e;
}
})();
return loadPromise;
}
export function isLevelDatabaseReady(): boolean {
return fileCache !== null;
}
// --- 查 ---
export function getLevelConfig(levelID: number): LevelConfig | null {
return levelsMap[levelID] ?? null;
}
/** 是否在 Cocos 导出的关卡库中 */
export function hasLevel(levelID: number): boolean {
return levelID in levelsMap;
}
export function getLevelIds(): number[] {
return [...sortedIds];
}
export function getLevelCount(): number {
return sortedIds.length;
}
export const MIN_LEVEL_ID = (): number => sortedIds[0] ?? LEVEL_ID_BASE;
export const MAX_LEVEL_ID = (): number => sortedIds[sortedIds.length - 1] ?? LEVEL_ID_BASE;
export function nextLevelId(cur: number): number {
const i = sortedIds.indexOf(cur);
if (i < 0) return sortedIds[0] ?? cur;
return sortedIds[(i + 1) % sortedIds.length];
}
export function prevLevelId(cur: number): number {
const i = sortedIds.indexOf(cur);
if (i < 0) return sortedIds[0] ?? cur;
return sortedIds[(i - 1 + sortedIds.length) % sortedIds.length];
}
// --- 增改(运行时 / 编辑器脚本) ---
export function setLevel(config: LevelConfig): void {
levelsMap[config.levelID] = { ...config };
if (fileCache) {
fileCache.levels[String(config.levelID)] = levelsMap[config.levelID];
}
rebuildIndex();
}
export function addLevel(config: LevelConfig): void {
setLevel(config);
}
/** 删 */
export function removeLevel(levelID: number): boolean {
if (!(levelID in levelsMap)) return false;
delete levelsMap[levelID];
if (fileCache?.levels) {
delete fileCache.levels[String(levelID)];
}
rebuildIndex();
return true;
}
/** 导出当前内存数据(可写回 JSON 文件) */
export function exportDatabaseJson(): string {
const payload: LevelDatabaseFile = fileCache ?? {
version: 1,
generatedAt: new Date().toISOString(),
source: 'runtime',
levels: {},
};
payload.levels = Object.fromEntries(
sortedIds.map((id) => [String(id), levelsMap[id]]),
);
payload.stats = { total: sortedIds.length };
return JSON.stringify(payload, null, 2);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8e7c7877-6e68-405a-8f42-0219d15a1bb1",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,188 @@
import { Node, UITransform, Layers, Sprite } from 'cc';
import { LevelConfig } from './LevelTypes';
import { CommonDefine } from '../core/Define';
import { VisualAssets, normalizeTheme } from '../visual/VisualAssets';
import { layoutLevelTiles, sortIsoTiles } from './TileLayout';
import { LevelTileLayout } from './LevelTileLayout';
import { LevelMapData } from './LevelMapData';
import { GridSnapHelper } from './GridSnapHelper';
import { getThemeBorderDecorKey } from '../theme/ThemeRegistry';
import { centerLevelRoot, syncTileNodesFromConfig } from './LevelTileSync';
import { mergeLevelConfigWithMapData } from './LevelConfigMerge';
const UI_LAYER = Layers.Enum.UI_2D;
function resolveLevelTheme(config: LevelConfig): string {
return normalizeTheme(config.theme || 'silu');
}
/** 关卡预制体在 Canvas 下正确显示:统一 UI 层、按 JSON 对齐格子、刷新贴图 */
export class LevelDisplay {
/** 每关使用 config.theme 独立贴图,与全局 UI 主题无关 */
static async prepare(levelRoot: Node, config: LevelConfig) {
levelRoot.name = `Level_${config.levelID}`;
GridSnapHelper.purgeRuntimeGrids(levelRoot);
this.ensureUILayerTree(levelRoot);
config = mergeLevelConfigWithMapData(config, levelRoot);
const theme = resolveLevelTheme(config);
const tileCount = syncTileNodesFromConfig(levelRoot, config);
layoutLevelTiles(levelRoot, config);
const tileNames = this.collectTileNames(config);
await VisualAssets.preloadLevelTiles(theme, tileNames);
await this.refreshTileSprites(levelRoot, config, theme);
layoutLevelTiles(levelRoot, config);
centerLevelRoot(levelRoot, config);
let layout = levelRoot.getComponent(LevelTileLayout);
if (!layout) layout = levelRoot.addComponent(LevelTileLayout);
layout.setRuntimeConfig(config);
layout.applyLayout();
layout.scheduleOnce(() => {
if (!levelRoot?.isValid) return;
layoutLevelTiles(levelRoot, config);
centerLevelRoot(levelRoot, config);
GridSnapHelper.purgeRuntimeGrids(levelRoot);
}, 0);
this.syncMapDataComponent(levelRoot, config);
GridSnapHelper.purgeRuntimeGrids(levelRoot);
console.log(`[LevelDisplay] 关卡 ${config.levelID} 同步 ${tileCount} 格,地图主题=${theme}`);
}
static syncMapDataComponent(levelRoot: Node, config: LevelConfig) {
const md = levelRoot.getComponent(LevelMapData);
if (!md) return;
md.levelID = config.levelID;
md.theme = resolveLevelTheme(config);
if (config.ground && Object.keys(config.ground).length > 0) {
md.groundJson = JSON.stringify(config.ground);
}
if (config.border && Object.keys(config.border).length > 0) {
md.borderJson = JSON.stringify(config.border);
}
}
static collectTileNames(config: LevelConfig): string[] {
const names = new Set<string>(['Baseblock', 'JumpBlock', 'WallBlock', 'kuai11']);
const decorKey = getThemeBorderDecorKey(config.theme);
if (decorKey) names.add(decorKey);
for (const v of Object.values(config.ground ?? {})) {
const tile = typeof v === 'string' ? v.trim() : (typeof v === 'number' ? String(v) : '');
if (tile) names.add(tile);
}
for (const v of Object.values(config.border ?? {})) {
if (v === true) continue;
const tile = typeof v === 'string' ? v.trim() : (typeof v === 'number' ? String(v) : '');
if (tile) names.add(tile);
}
return Array.from(names);
}
static ensureUILayerTree(root: Node) {
const walk = (node: Node | null | undefined) => {
if (!node?.isValid) return;
node.layer = UI_LAYER;
node.active = true;
let ui = node.getComponent(UITransform);
if (!ui) ui = node.addComponent(UITransform);
if (node === root || node.name === 'Ground' || node.name === 'Border') {
ui.setAnchorPoint(0, 0);
ui.setContentSize(1, 1);
}
for (const ch of node.children) walk(ch);
};
walk(root);
}
static sortIsoLayers(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
private static queueTileSprite(
jobs: Promise<void>[],
node: Node,
tileName: string,
cellX: number,
cellY: number,
theme: string,
) {
jobs.push(VisualAssets.applyNamedTile(node, tileName, 255, cellX, cellY, theme));
}
/** 仅刷新砖块贴图(实体已由 GameController 单独处理) */
static async refreshTiles(levelRoot: Node, config: LevelConfig): Promise<void> {
const theme = resolveLevelTheme(config);
await VisualAssets.preloadLevelTiles(theme, this.collectTileNames(config));
await this.refreshTileSprites(levelRoot, config, theme);
}
static async refreshTileSprites(levelRoot: Node, config: LevelConfig, theme: string) {
const jobs: Promise<void>[] = [];
const processLayer = (layer: Node | null, isBorder: boolean) => {
if (!layer) return;
for (const ch of layer.children) {
if (!ch?.active) continue;
const m = isBorder
? /^b_(-?\d+)_(-?\d+)$/.exec(ch.name)
: /^g_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (!m) continue;
const key = `${m[1]},${m[2]}`;
const cx = parseInt(m[1], 10);
const cy = parseInt(m[2], 10);
let tileName: string;
if (isBorder) {
tileName = config.border?.[key] as string | boolean | undefined;
if (tileName === true || tileName === undefined) tileName = 'WallBlock';
if (typeof tileName !== 'string') tileName = 'WallBlock';
} else {
tileName = config.ground?.[key] ?? CommonDefine.BlockBase;
}
this.queueTileSprite(jobs, ch, tileName, cx, cy, theme);
}
};
processLayer(levelRoot.getChildByName('Ground'), false);
processLayer(levelRoot.getChildByName('Border'), true);
const tiles = levelRoot.getChildByName('Tiles');
if (tiles) {
for (const ch of tiles.children) {
if (!ch.active) continue;
const mg = /^g_(-?\d+)_(-?\d+)$/.exec(ch.name);
const mb = /^b_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (mg) {
const key = `${mg[1]},${mg[2]}`;
this.queueTileSprite(
jobs, ch,
config.ground?.[key] ?? CommonDefine.BlockBase,
parseInt(mg[1], 10), parseInt(mg[2], 10), theme,
);
} else if (mb) {
const key = `${mb[1]},${mb[2]}`;
let tileName = config.border?.[key];
if (tileName === true || tileName === undefined) tileName = 'WallBlock';
if (typeof tileName !== 'string') tileName = 'WallBlock';
this.queueTileSprite(
jobs, ch, tileName,
parseInt(mb[1], 10), parseInt(mb[2], 10), theme,
);
}
}
}
await Promise.all(jobs);
layoutLevelTiles(levelRoot, config);
let n = 0;
const count = (node: Node) => {
if (node.active && node.getComponent(Sprite)?.spriteFrame) n++;
for (const c of node.children) count(c);
};
count(levelRoot);
console.log(`[LevelDisplay] 关卡 ${config.levelID} 地图主题=${theme} 已刷新 ${n}`);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "81f9083e-1e28-48b0-bf0e-4d3b5b6dce21",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,7 @@
/** 与 Unity / 主站 config.js BEGINNING_REAL_LVID 一致;首关 91601 */
export const LEVEL_ID_BASE = 91601;
/** 主站合法关卡 ID从 BEGINNING_REAL_LVID91601起 */
export function isGameLevelId(levelID: number): boolean {
return Number.isFinite(levelID) && levelID >= LEVEL_ID_BASE;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f0858c47-3aeb-4416-997f-637c891c074c",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,56 @@
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
/**
* 挂在关卡预制体根节点(与 Unity LevelN.prefab 同级)。
* 地图碰撞/可走数据;视觉由预制体子节点 Ground、Border 上的 Sprite 呈现。
*/
@ccclass('LevelMapData')
export class LevelMapData extends Component {
@property
levelID = 0;
@property({ multiline: true, displayName: 'Ground JSON' })
groundJson = '{}';
@property({ multiline: true, displayName: 'Border JSON' })
borderJson = '{}';
@property({ displayName: '地图主题 (silu/sanxing/…)' })
theme = 'silu';
parseGround(): Map<string, string> {
return LevelMapData.parseRecord(this.groundJson);
}
parseBorder(): Set<string> {
return LevelMapData.parseBorder(this.borderJson);
}
static parseRecord(json: string): Map<string, string> {
const m = new Map<string, string>();
try {
const o = JSON.parse(json || '{}') as Record<string, string>;
for (const [k, v] of Object.entries(o)) {
if (typeof v === 'string' && v) m.set(k, v);
}
} catch (e) {
console.warn('[LevelMapData] groundJson parse error', e);
}
return m;
}
static parseBorder(json: string): Set<string> {
const s = new Set<string>();
try {
const o = JSON.parse(json || '{}') as Record<string, boolean>;
for (const k of Object.keys(o)) {
if (o[k]) s.add(k);
}
} catch (e) {
console.warn('[LevelMapData] borderJson parse error', e);
}
return s;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,146 @@
import { assetManager, Prefab, resources } from 'cc';
import { PREVIEW } from 'cc/env';
import { ensureResourcesBundle } from '../core/ResourcesBundle';
import {
getLevelPrefabUuid,
loadLevelPrefabUuidIndex,
parseLevelIdFromPrefabPath,
shouldTryEditorUuidLoad,
} from './LevelPrefabUuidIndex';
const LEVEL_PREFAB_BUNDLE = 'level-prefabs';
let cachedLevelBundle: assetManager.Bundle | null = null;
let levelBundlePromise: Promise<assetManager.Bundle> | null = null;
function loadLevelPrefabBundle(): Promise<assetManager.Bundle> {
if (cachedLevelBundle) return Promise.resolve(cachedLevelBundle);
if (levelBundlePromise) return levelBundlePromise;
levelBundlePromise = new Promise((resolve, reject) => {
assetManager.loadBundle(LEVEL_PREFAB_BUNDLE, (err, bundle) => {
levelBundlePromise = null;
if (err || !bundle) {
reject(err ?? new Error(`bundle "${LEVEL_PREFAB_BUNDLE}" unavailable`));
return;
}
cachedLevelBundle = bundle;
resolve(bundle);
});
});
return levelBundlePromise;
}
function loadPrefabFromBundle(bundle: assetManager.Bundle, path: string): Promise<Prefab> {
return new Promise((resolve, reject) => {
bundle.load(path, Prefab, (err, prefab) => {
if (!err && prefab) resolve(prefab);
else reject(err ?? new Error(`missing prefab: ${path}`));
});
});
}
function loadPrefabFromResources(path: string): Promise<Prefab> {
return new Promise((resolve, reject) => {
resources.load(path, Prefab, (err, prefab) => {
if (!err && prefab) resolve(prefab);
else reject(err ?? new Error(`missing prefab: ${path}`));
});
});
}
function loadPrefabByUuid(uuid: string): Promise<Prefab> {
return new Promise((resolve, reject) => {
assetManager.loadAny({ uuid }, (err: Error | null, asset: Prefab) => {
if (err || !asset) {
reject(err ?? new Error(`missing prefab uuid: ${uuid}`));
return;
}
resolve(asset);
});
});
}
/** bundle / resources 内可能的路径(子目录 level-prefabs/ 或 bundle 根下 LevelN */
function prefabPathCandidates(path: string): string[] {
const trimmed = path.trim();
const base = trimmed.replace(/^level-prefabs\//, '');
// bundle config 路径键为 level-prefabs/LevelN裸 LevelN 会触发 loadAny 解析错误
return [...new Set([trimmed, `level-prefabs/${base}`])];
}
async function loadFirstAvailable(
loader: (p: string) => Promise<Prefab>,
paths: string[],
): Promise<Prefab> {
let lastErr: unknown;
for (const p of paths) {
try {
return await loader(p);
} catch (e) {
lastErr = e;
}
}
throw lastErr ?? new Error(`missing prefab: ${paths[0]}`);
}
async function tryLoadFromEditorUuid(path: string): Promise<Prefab | null> {
if (!shouldTryEditorUuidLoad()) return null;
await loadLevelPrefabUuidIndex();
const levelId = parseLevelIdFromPrefabPath(path);
if (levelId === undefined) return null;
const uuid = getLevelPrefabUuid(levelId);
if (!uuid) return null;
try {
const prefab = await loadPrefabByUuid(uuid);
console.log(`[LevelPrefabLoader] 编辑器 uuid 加载 Level${levelId}`);
return prefab;
} catch (e) {
console.warn(`[LevelPrefabLoader] uuid 加载 Level${levelId} 失败`, e);
return null;
}
}
/** 进关前由 loader 注入:按 levelId 下载对应关卡包 */
async function ensureLevelPackForPath(path: string): Promise<void> {
const levelId = parseLevelIdFromPrefabPath(path);
if (levelId === undefined) return;
const hook = (globalThis as { __tfrhEnsureLevelPack?: (id: number) => Promise<void> }).__tfrhEnsureLevelPack;
if (typeof hook === 'function') await hook(levelId);
cachedLevelBundle = null;
levelBundlePromise = null;
}
/** 优先从 level-prefabs 分包加载;编辑器预览可回退 uuid / resources */
export async function loadLevelPrefab(path: string): Promise<Prefab> {
await ensureResourcesBundle();
await ensureLevelPackForPath(path);
const candidates = prefabPathCandidates(path);
if (PREVIEW) {
const byUuid = await tryLoadFromEditorUuid(path);
if (byUuid?.isValid) return byUuid;
}
let bundleErr: unknown = null;
try {
const bundle = await loadLevelPrefabBundle();
return await loadFirstAvailable((p) => loadPrefabFromBundle(bundle, p), candidates);
} catch (err) {
bundleErr = err;
console.warn('[LevelPrefabLoader] level-prefabs bundle 加载失败', err);
}
if (!PREVIEW) {
const byUuid = await tryLoadFromEditorUuid(path);
if (byUuid?.isValid) return byUuid;
}
console.error(
'[LevelPrefabLoader] 关卡预制体未找到。请确认 bundle-level-prefabs 已标记为 Asset Bundle '
+ `"${LEVEL_PREFAB_BUNDLE}",并运行: python3 tools/bake_cocos_level_prefabs.py`,
bundleErr,
);
throw bundleErr instanceof Error
? bundleErr
: new Error(`missing prefab: ${path}`);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "18fe4dd5-2cb5-4201-ac1c-88b839c28747",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,13 @@
import { LevelConfig } from './LevelTypes';
/** 关卡预制体在 resources 下的路径(与 Unity Assets/Prefabs/Level/LevelN.prefab 对应) */
function asTrimmedPath(v: unknown): string | undefined {
if (v == null) return undefined;
const s = (typeof v === 'string' ? v : String(v)).trim();
return s || undefined;
}
/** 与 Unity levelPath 一致Level91601.prefab → level-prefabs/Level91601 */
export function getLevelPrefabResourcePath(levelID: number, _config?: LevelConfig | null): string {
return `level-prefabs/Level${levelID}`;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2d4bcfb4-7972-4c8a-9f0e-a9d07948eb94",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,58 @@
import { assetManager, JsonAsset } from 'cc';
import { PREVIEW } from 'cc/env';
/** assets/level-data/level-prefab-uuids.json烘焙脚本生成编辑器预览按 uuid 加载 prefab */
const EDITOR_UUID_INDEX = 'f7e8d9c0-b1a2-4c3d-9e8f-1a2b3c4d5e6f';
let uuidByLevelId: Record<number, string> | null = null;
let loadPromise: Promise<void> | null = null;
function ingest(data: Record<string, string>) {
const out: Record<number, string> = {};
for (const [k, v] of Object.entries(data ?? {})) {
const id = parseInt(k, 10);
const uuid = typeof v === 'string' ? v.trim() : '';
if (!Number.isNaN(id) && uuid) out[id] = uuid;
}
uuidByLevelId = out;
}
function loadFromEditorAsset(): Promise<void> {
return new Promise((resolve, reject) => {
assetManager.loadAny({ uuid: EDITOR_UUID_INDEX }, (err: Error | null, asset: JsonAsset) => {
if (err || !asset?.json) {
reject(err ?? new Error('level-prefab-uuids.json 未找到'));
return;
}
ingest(asset.json as Record<string, string>);
resolve();
});
});
}
/** 编辑器预览levelId → prefab uuid */
export function loadLevelPrefabUuidIndex(): Promise<void> {
if (uuidByLevelId) return Promise.resolve();
if (loadPromise) return loadPromise;
loadPromise = loadFromEditorAsset().catch((e) => {
console.warn('[LevelPrefabUuidIndex] 加载失败,将仅用 bundle 路径', e);
uuidByLevelId = {};
}).then(() => undefined);
return loadPromise;
}
export function getLevelPrefabUuid(levelId: number): string | undefined {
return uuidByLevelId?.[levelId];
}
/** level-prefabs/Level91601 → 91601 */
export function parseLevelIdFromPrefabPath(path: string): number | undefined {
const m = /Level(\d+)\s*$/.exec(path.trim());
if (!m) return undefined;
const id = parseInt(m[1], 10);
return Number.isNaN(id) ? undefined : id;
}
export function shouldTryEditorUuidLoad(): boolean {
return PREVIEW;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cd170235-d6e1-4de2-a497-4d24a9b53f34",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,47 +1,94 @@
import { Direction } from '../core/Define';
/**
* 关卡注册表 — 统一从 LevelDatabase单一 JSON读取
*/
import { LevelConfig } from './LevelTypes';
import { LEVELS_600 } from './levels-600.generated';
import { LEVEL_ID_BASE } from './LevelIds';
/** 额外关卡(多人等) */
const EXTRA_LEVELS: Record<number, LevelConfig> = {
601: {
levelID: 601,
export { LEVEL_ID_BASE, isGameLevelId } from './LevelIds';
import {
getLevelConfig as dbGet,
hasLevel as dbHas,
getLevelIds as dbIds,
getLevelCount as dbCount,
nextLevelId as dbNext,
prevLevelId as dbPrev,
setLevel as dbSet,
removeLevel as dbRemove,
addLevel as dbAdd,
loadLevelDatabase,
isLevelDatabaseReady,
MIN_LEVEL_ID as dbMin,
MAX_LEVEL_ID as dbMax,
} from './LevelDatabase';
export { loadLevelDatabase, isLevelDatabaseReady };
export function getMinLevelId(): number {
return isLevelDatabaseReady() ? dbMin() : LEVEL_ID_BASE;
}
export function getMaxLevelId(): number {
return isLevelDatabaseReady() ? dbMax() : LEVEL_ID_BASE;
}
/**
* 主站 SendMessage(levelID) → 查 Cocos 关卡库;无条目时按 Level{id}.prefab 加载。
*/
export function resolveLevelConfig(levelID: number): LevelConfig | null {
const cfg = dbGet(levelID);
if (cfg) return cfg;
if (levelID <= 0) return null;
return {
levelID,
boundary: { x: 20, y: 20 },
spawns: [
{ x: 0, y: 0, kind: 'player', playerDirection: Direction.North },
{ x: 6, y: 6, kind: 'prop' },
{ x: -1, y: 2, kind: 'prop' },
],
},
999001: {
levelID: 999001,
boundary: { x: 999, y: 999 },
spawns: [
{ x: -9, y: -9, kind: 'player', playerDirection: Direction.South },
{ x: 9, y: 9, kind: 'player', playerDirection: Direction.North },
{ x: -9, y: -10, kind: 'vehicle', vehicleDirection: Direction.North },
{ x: 9, y: 10, kind: 'vehicle', vehicleDirection: Direction.South },
],
},
};
spawns: [],
cocosPrefab: `level-prefabs/Level${levelID}`,
theme: 'sanxing',
};
}
const allLevels: Record<number, LevelConfig> = {
...LEVELS_600,
...EXTRA_LEVELS,
};
/** @deprecated 请用 getMinLevelId() / getMaxLevelId() */
export let MIN_LEVEL_ID = LEVEL_ID_BASE;
/** @deprecated 请用 getMaxLevelId() */
export let MAX_LEVEL_ID = LEVEL_ID_BASE;
export function refreshLevelIdBounds() {
MIN_LEVEL_ID = getMinLevelId();
MAX_LEVEL_ID = getMaxLevelId();
}
export function getLevelConfig(levelID: number): LevelConfig | null {
return allLevels[levelID] ?? null;
return dbGet(levelID);
}
export function hasLevel(levelID: number): boolean {
return levelID in allLevels;
return dbHas(levelID);
}
export function registerLevel(config: LevelConfig) {
allLevels[config.levelID] = config;
dbSet(config);
}
export function addLevel(config: LevelConfig) {
dbAdd(config);
}
export function removeLevel(levelID: number) {
dbRemove(levelID);
}
export function getLevelCount(): number {
return Object.keys(allLevels).length;
return dbCount();
}
export function getLevelIds(): number[] {
return dbIds();
}
export function nextLevelId(cur: number): number {
return dbNext(cur);
}
export function prevLevelId(cur: number): number {
return dbPrev(cur);
}

View File

@@ -0,0 +1,18 @@
import { Vec3 } from 'cc';
import { LevelConfig } from './LevelTypes';
/** 关卡运行时上下文(避免 TileLayout ↔ GameController 循环依赖) */
export interface LevelRuntimeContext {
getCurLevel(): LevelConfig | null;
worldToCell(world: Vec3): Vec3;
}
let runtimeContext: LevelRuntimeContext | null = null;
export function setLevelRuntimeContext(ctx: LevelRuntimeContext | null): void {
runtimeContext = ctx;
}
export function getLevelRuntimeContext(): LevelRuntimeContext | null {
return runtimeContext;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1343d397-b505-4672-b30b-83e1f1628772",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,53 @@
import { _decorator, Component } from 'cc';
import { LevelMapData } from './LevelMapData';
import { layoutLevelTiles } from './TileLayout';
import { LevelConfig } from './LevelTypes';
const { ccclass, executionOrder } = _decorator;
/**
* 挂在 LevelN 根节点:运行时首帧按 levels-database 重新对齐砖块。
* 解决「编辑器 JSON 正确、运行预览叠在一起 / 只显示一块」。
*/
@ccclass('LevelTileLayout')
@executionOrder(-50)
export class LevelTileLayout extends Component {
/** 运行时权威配置(来自 levels-database.json优先于预制体 LevelMapData */
runtimeConfig: LevelConfig | null = null;
start() {
this.applyLayout();
this.scheduleOnce(() => this.applyLayout(), 0);
}
setRuntimeConfig(config: LevelConfig) {
this.runtimeConfig = config;
}
applyLayout() {
const config = this.runtimeConfig ?? this.configFromMapData();
if (!config) return;
layoutLevelTiles(this.node, config);
}
private configFromMapData(): LevelConfig | null {
const md = this.getComponent(LevelMapData);
if (!md) return null;
let ground: LevelConfig['ground'];
let border: LevelConfig['border'];
try {
ground = JSON.parse(md.groundJson || '{}');
border = JSON.parse(md.borderJson || '{}');
} catch {
return null;
}
return {
levelID: md.levelID,
boundary: { x: 0, y: 0 },
spawns: [],
theme: md.theme || 'silu',
ground,
border,
};
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "22737b72-530f-4caf-8222-1485dde33239",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,96 @@
import { Layers, Node, Sprite, UITransform, Vec3 } from 'cc';
import { cellToWorldCenter, parseCellKey, tileNodeName } from '../core/GridCoords';
import { LevelConfig } from './LevelTypes';
import { setupLayerContainer } from './TileLayout';
const UI_LAYER = Layers.Enum.UI_2D;
function ensureLayer(root: Node, name: 'Ground' | 'Border'): Node {
let layer = root.getChildByName(name);
if (!layer) {
layer = new Node(name);
layer.parent = root;
layer.layer = UI_LAYER;
}
setupLayerContainer(layer);
return layer;
}
function ensureTile(layer: Node, layerKind: 'ground' | 'border', x: number, y: number): Node {
const nodeName = tileNodeName(layerKind, x, y);
let node = layer.getChildByName(nodeName);
if (!node) {
node = new Node(nodeName);
node.parent = layer;
node.layer = UI_LAYER;
node.addComponent(UITransform);
node.addComponent(Sprite);
}
node.active = true;
return node;
}
/** 按 levels-database.json 补齐 / 隐藏砖块节点(与关卡编辑器数据一致) */
export function syncTileNodesFromConfig(levelRoot: Node, config: LevelConfig): number {
const ground = ensureLayer(levelRoot, 'Ground');
const border = ensureLayer(levelRoot, 'Border');
const wantGround = new Set(Object.keys(config.ground ?? {}));
const wantBorder = new Set(Object.keys(config.border ?? {}));
let n = 0;
for (const key of wantGround) {
const p = parseCellKey(key);
if (!p) continue;
ensureTile(ground, 'ground', p.x, p.y);
n++;
}
for (const key of wantBorder) {
const p = parseCellKey(key);
if (!p) continue;
ensureTile(border, 'border', p.x, p.y);
n++;
}
for (const ch of ground.children) {
const m = /^g_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (!m) continue;
const key = `${m[1]},${m[2]}`;
ch.active = wantGround.has(key);
}
for (const ch of border.children) {
const m = /^b_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (!m) continue;
const key = `${m[1]},${m[2]}`;
ch.active = wantBorder.has(key);
}
return n;
}
/** 关卡包围盒中心(格子 pivot 落点),用于居中显示 */
export function computeLevelCenterOffset(config: LevelConfig): Vec3 {
const keys = [
...Object.keys(config.ground ?? {}),
...Object.keys(config.border ?? {}),
];
if (!keys.length) return new Vec3(0, 0, 0);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const key of keys) {
const p = parseCellKey(key);
if (!p) continue;
const w = cellToWorldCenter(new Vec3(p.x, p.y, 0));
minX = Math.min(minX, w.x);
maxX = Math.max(maxX, w.x);
minY = Math.min(minY, w.y);
maxY = Math.max(maxY, w.y);
}
return new Vec3(-(minX + maxX) * 0.5, -(minY + maxY) * 0.5, 0);
}
export function centerLevelRoot(levelRoot: Node, config: LevelConfig) {
const off = computeLevelCenterOffset(config);
levelRoot.setPosition(off.x, off.y, 0);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "84e4a32b-5cd7-475c-9842-fd096a1c72fa",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,4 +1,5 @@
import { Direction } from '../core/Define';
import type { PropPlacement } from './EntitySpawnPlacement';
export type SpawnKind = 'player' | 'vehicle' | 'prop' | 'prop_decor' | 'enemy';
@@ -8,6 +9,32 @@ export interface SpawnConfig {
kind: SpawnKind;
playerDirection?: Direction;
vehicleDirection?: Direction;
/** 相对默认尺寸的比例1 = 默认,可单独调大/调小 */
scale?: number;
/** 贴图路径(相对 assets/resources无 .png可拾取物可单独指定 */
texture?: string;
/**
* 可拾取物高度block=砖块上Unity Propground=空地Unity nProp
* 未指定时按格子是否有 ground/border 推断。
*/
propPlacement?: PropPlacement;
}
/** 关卡级实体贴图(留空则按 theme 自动推断) */
export interface LevelEntityTextures {
playerFront?: string;
playerBack?: string;
vehicleNorth?: string;
vehicleEast?: string;
vehicleSouth?: string;
vehicleWest?: string;
/** @deprecated */
vehicleFront?: string;
/** @deprecated */
vehicleBack?: string;
prop?: string;
/** 空地可拾取物贴图Unity nProp*,留空则从 prop 推导) */
propGround?: string;
}
/** 稀疏地块key 为 "x,y" */
@@ -15,8 +42,17 @@ export interface LevelConfig {
levelID: number;
boundary: { x: number; y: number };
spawns: SpawnConfig[];
/** Ground 层 tile 名 */
/** Unity 原 Prefab 路径(仅存档参考) */
unityPrefab?: string;
/** Cocos resources 路径,默认 level-prefabs/Level{id} */
cocosPrefab?: string;
/** Ground 层 tile 名Baseblock / JumpBlock与 Unity Tilemap Ground 一致) */
ground?: Record<string, string>;
/** Border 阻挡格 */
border?: Record<string, boolean>;
/** Border 阻挡格(与 Unity Tilemap Border 一致) */
/** true 或瓦片名WallBlock、kuai11 等) */
border?: Record<string, boolean | string>;
/** 烘焙/调色板主题silu、sanxing、snow … */
theme?: string;
/** 本关角色/载具/可拾取物贴图(优先于 theme 自动映射) */
entityTextures?: LevelEntityTextures;
}

View File

@@ -0,0 +1,472 @@
import { Node, UITransform, Vec3, Layers, Sprite } from 'cc';
import { cellToWorldCenter, parseTileNodeName } from '../core/GridCoords';
import { CommonDefine } from '../core/Define';
import { GameManager } from '../manager/GameManager';
import { getTilePivot } from '../visual/TilePivots';
import { getTileDrawSize, resolveTilePixelSize } from '../visual/TileSizes';
import { LevelConfig } from './LevelTypes';
import { getLevelRuntimeContext } from './LevelRuntimeContext';
const UI_LAYER = Layers.Enum.UI_2D;
/**
* 遮挡分类(仅在关卡加载时按格子坐标排序一次)
* - walkable : Baseblock / JumpBlock永不在 actor 之上
* - wall : Border / WallBlock与 actor 按 iso 深度比较
* - actor : 角色 / 载具
* - pickable : 可拾取物Prop永不在墙砖/路径砖之下;玩家仍在其上
* - scenery : 其它
*/
type DrawKind = 'walkable' | 'wall' | 'actor' | 'pickable' | 'scenery';
const DRAW_RANK = {
groundProp: 10,
groundTile: 20,
borderTile: 22,
vehicle: 28,
coin: 30,
propDecor: 30,
player: 40,
} as const;
interface DrawEntry {
node: Node;
x: number;
y: number;
rank: number;
kind: DrawKind;
}
function safeNodeName(node: Node | null | undefined): string {
return (node?.isValid ? node.name : '') || '';
}
function isPlayerEntityName(name: string | undefined | null): boolean {
if (!name) return false;
return name === 'Player' || /^Player[AB]\d$/.test(name);
}
function isVehicleEntityName(name: string | undefined | null): boolean {
if (!name) return false;
return name === 'Vehicle' || /^Vehicle[AB]\d$/.test(name);
}
function isCoinEntityName(name: string | undefined | null): boolean {
if (!name) return false;
return name === 'Prop' || name.startsWith('Prop_');
}
function isActorEntity(node: Node): boolean {
return isPlayerEntityName(safeNodeName(node))
|| isVehicleEntityName(safeNodeName(node));
}
/** 可拾取金币 / 道具PropController */
function isPickableProp(node: Node): boolean {
if (!node?.isValid) return false;
if (node.getComponent('PropController')) return true;
return isCoinEntityName(safeNodeName(node));
}
function isEntityDrawNode(node: Node): boolean {
const n = safeNodeName(node);
if (!n) return false;
return isActorEntity(node) || isPickableProp(node)
|| n === 'PropDecor' || n.startsWith('PropDecor_');
}
function isWalkablePathTile(tileName: string): boolean {
return tileName === CommonDefine.BlockBase || tileName === CommonDefine.BlockJump;
}
function isWallTileName(tileName: string): boolean {
return tileName === 'WallBlock' || tileName === 'kuai11';
}
function resolveGroundTileName(cellX: number, cellY: number, config?: LevelConfig): string {
return config?.ground?.[`${cellX},${cellY}`] ?? CommonDefine.BlockBase;
}
function classifyTileKind(
parsed: { layer: 'ground' | 'border'; x: number; y: number },
config?: LevelConfig,
): DrawKind {
if (parsed.layer === 'border') return 'wall';
const name = resolveGroundTileName(parsed.x, parsed.y, config);
if (isWalkablePathTile(name)) return 'walkable';
if (isWallTileName(name)) return 'wall';
return 'scenery';
}
function classifyEntityKind(node: Node): DrawKind {
if (isPickableProp(node)) return 'pickable';
if (isActorEntity(node)) return 'actor';
return 'scenery';
}
export interface EntityDrawOrderOptions {
/** @deprecated 保留字段避免旧调用报错 */
preferVehicleOverPlayer?: boolean;
}
function entityRank(node: Node): number {
const n = safeNodeName(node);
if (isPlayerEntityName(n)) return DRAW_RANK.player;
if (isVehicleEntityName(n)) return DRAW_RANK.vehicle;
if (isCoinEntityName(n)) return DRAW_RANK.coin;
if (n === 'PropDecor' || n.startsWith('PropDecor_')) return DRAW_RANK.propDecor;
return DRAW_RANK.groundProp;
}
function tileRank(kind: DrawKind, layer: 'ground' | 'border'): number {
if (kind === 'walkable') return DRAW_RANK.groundTile;
if (kind === 'wall') return DRAW_RANK.borderTile;
return layer === 'border' ? DRAW_RANK.borderTile : DRAW_RANK.groundTile;
}
/** 等距深度:返回值 > 0 表示 a 应排在 b 之后(更靠前) */
export function compareIsoDrawOrder(cellX: number, cellY: number, otherX: number, otherY: number): number {
const ka = cellX + cellY;
const kb = otherX + otherY;
if (ka !== kb) return kb - ka;
return otherX - cellX;
}
function wallFaceSamples(wallX: number, wallY: number): { x: number; y: number }[] {
return [{ x: wallX, y: wallY - 1 }, { x: wallX - 1, y: wallY }];
}
function compareWallActorIso(actor: DrawEntry, wall: DrawEntry): number {
let actorAhead = 0;
for (const face of wallFaceSamples(wall.x, wall.y)) {
const iso = compareIsoDrawOrder(actor.x, actor.y, face.x, face.y);
if (iso < 0) return iso;
if (iso > actorAhead) actorAhead = iso;
}
return actorAhead;
}
function compareDrawEntries(a: DrawEntry, b: DrawEntry): number {
if (a.kind === 'walkable' && b.kind === 'actor') return -1;
if (b.kind === 'walkable' && a.kind === 'actor') return 1;
// 可拾取物:永远在墙砖/路径砖/载具之上;玩家仍在其上
if (a.kind === 'pickable' && b.kind === 'actor') {
return isPlayerEntityName(safeNodeName(b.node)) ? -1 : 1;
}
if (a.kind === 'actor' && b.kind === 'pickable') {
return isPlayerEntityName(safeNodeName(a.node)) ? 1 : -1;
}
if (a.kind === 'pickable' && b.kind !== 'pickable') return 1;
if (b.kind === 'pickable' && a.kind !== 'pickable') return -1;
if (a.kind === 'actor' && b.kind === 'wall') {
const wallIso = compareWallActorIso(a, b);
if (wallIso !== 0) return wallIso;
}
if (a.kind === 'wall' && b.kind === 'actor') {
const wallIso = compareWallActorIso(b, a);
if (wallIso !== 0) return -wallIso;
}
const iso = compareIsoDrawOrder(a.x, a.y, b.x, b.y);
if (iso !== 0) return iso;
return a.rank - b.rank;
}
/** 实体所在关卡根Ground/Border/Tiles 的父节点,不是 Tiles 容器本身) */
export function resolveLevelDrawRoot(from: Node): Node | null {
let cur: Node | null = from;
while (cur?.isValid) {
if (cur.getChildByName('Ground') || cur.getChildByName('Border') || cur.getChildByName('Tiles')) {
return cur;
}
if (/^Level_\d+/.test(cur.name)) return cur;
cur = cur.parent;
}
return null;
}
/** 实体逻辑格(仅 spawn / committed移动中不重算 */
function getEntityLogicCell(node: Node): { x: number; y: number } {
const propCtrl = node.getComponent('PropController') as { getSpawnCell?: () => Vec3 | null } | null;
const propCell = propCtrl?.getSpawnCell?.();
if (propCell) return { x: propCell.x, y: propCell.y };
const m = /^Prop_(-?\d+)_(-?\d+)$/.exec(safeNodeName(node));
if (m) return { x: Number(m[1]), y: Number(m[2]) };
const vehicle = node.getComponent('VehicleController') as {
getPlayer?: () => { getCommittedCell?: () => Vec3 | null; getSpawnCell?: () => Vec3 | null } | null;
} | null;
const rider = vehicle?.getPlayer?.() ?? null;
if (rider) {
const riderCell = rider.getCommittedCell?.() ?? rider.getSpawnCell?.();
if (riderCell) return { x: riderCell.x, y: riderCell.y };
}
const mov = node.getComponent('Movement') as {
getCommittedCell?: () => Vec3 | null;
getSpawnCell?: () => Vec3 | null;
} | null;
if (mov) {
const cell = mov.getCommittedCell?.() ?? mov.getSpawnCell?.();
if (cell) return { x: cell.x, y: cell.y };
}
const gm = GameManager.instance;
if (gm) {
const c = gm.worldToCell(node.position);
return { x: c.x, y: c.y };
}
return { x: 0, y: 0 };
}
/** 关卡内实体(可能在 levelRoot 或 Tiles 下) */
export function forEachLevelEntityNode(levelRoot: Node, fn: (node: Node) => void) {
if (!levelRoot?.isValid) return;
for (const ch of levelRoot.children) {
if (ch?.isValid && isEntityDrawNode(ch)) fn(ch);
}
const tiles = levelRoot.getChildByName('Tiles');
if (!tiles?.isValid) return;
for (const ch of tiles.children) {
if (ch?.isValid && isEntityDrawNode(ch)) fn(ch);
}
}
export function findLevelChildByName(levelRoot: Node, name: string): Node | null {
if (!levelRoot?.isValid) return null;
const direct = levelRoot.getChildByName(name);
if (direct) return direct;
return levelRoot.getChildByName('Tiles')?.getChildByName(name) ?? null;
}
function resolveLevelConfig(): LevelConfig | undefined {
return getLevelRuntimeContext()?.getCurLevel()
?? GameManager.instance?.getCurLevel?.()
?? undefined;
}
function collectTileEntries(levelRoot: Node, tilesRoot: Node, seen: Set<Node>): DrawEntry[] {
const config = resolveLevelConfig();
const entries: DrawEntry[] = [];
const pushTile = (node: Node, parsed: { layer: 'ground' | 'border'; x: number; y: number }) => {
if (seen.has(node)) return;
seen.add(node);
const kind = classifyTileKind(parsed, config);
entries.push({
node,
x: parsed.x,
y: parsed.y,
rank: tileRank(kind, parsed.layer),
kind,
});
};
forEachTileChild(levelRoot, pushTile);
for (const ch of tilesRoot.children) {
if (!ch?.isValid || seen.has(ch)) continue;
const parsed = parseTileNodeName(ch.name);
if (parsed) pushTile(ch, parsed);
}
return entries;
}
function collectEntityEntries(levelRoot: Node, tilesRoot: Node, seen: Set<Node>): DrawEntry[] {
const entries: DrawEntry[] = [];
const pushEntity = (node: Node | null | undefined) => {
if (!node?.isValid || seen.has(node) || !isEntityDrawNode(node)) return;
seen.add(node);
const cell = getEntityLogicCell(node);
entries.push({
node,
x: cell.x,
y: cell.y,
rank: entityRank(node),
kind: classifyEntityKind(node),
});
};
for (const ch of levelRoot.children) pushEntity(ch);
for (const ch of tilesRoot.children) pushEntity(ch);
return entries;
}
function applyDrawOrder(tilesRoot: Node, entries: DrawEntry[]) {
const sorted = entries.slice().sort(compareDrawEntries);
const inSort = new Set<Node>();
for (const { node } of sorted) {
if (!node.isValid) continue;
inSort.add(node);
if (isEntityDrawNode(node)) ensureEntityUILayer(node);
if (node.parent !== tilesRoot) {
const lp = node.position.clone();
node.parent = tilesRoot;
node.setPosition(lp);
}
}
for (let i = 0; i < sorted.length; i++) {
const node = sorted[i].node;
if (!node.isValid) continue;
if (node.getSiblingIndex() !== i) node.setSiblingIndex(i);
}
let tail = sorted.length;
for (const ch of [...tilesRoot.children]) {
if (!ch.isValid || inSort.has(ch)) continue;
if (ch.getSiblingIndex() !== tail) ch.setSiblingIndex(tail);
tail++;
}
}
function finalizeLevelRootOrder(levelRoot: Node, tilesRoot: Node) {
for (const name of ['Ground', 'Border'] as const) {
const layer = levelRoot.getChildByName(name);
if (layer && layer.children.length === 0) {
layer.setSiblingIndex(0);
}
}
tilesRoot.setSiblingIndex(levelRoot.children.length - 1);
}
/**
* 关卡加载 / 重置时按格子坐标排序一次;移动过程中不再改 sibling。
*/
export function sortIsoTiles(levelRoot: Node) {
if (!levelRoot?.isValid) return;
const tilesRoot = ensureTilesRoot(levelRoot);
const seen = new Set<Node>();
const tileEntries = collectTileEntries(levelRoot, tilesRoot, seen);
const entityEntries = collectEntityEntries(levelRoot, tilesRoot, seen);
applyDrawOrder(tilesRoot, [...tileEntries, ...entityEntries]);
finalizeLevelRootOrder(levelRoot, tilesRoot);
}
/** @deprecated 与 sortIsoTiles 相同 */
export function refreshIsoEntityDrawOrder(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
/** @deprecated 与 sortIsoTiles 相同 */
export function refreshIsoDrawOrder(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
/** @deprecated 与 sortIsoTiles 相同 */
export function refreshIsoDrawOrderImmediate(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
export function bringEntityNodesToFront(levelRoot: Node, _opts?: EntityDrawOrderOptions) {
sortIsoTiles(levelRoot);
}
export function ensureEntityUILayer(node: Node) {
const walk = (n: Node) => {
n.layer = UI_LAYER;
for (const ch of n.children) walk(ch);
};
walk(node);
}
function ensureTilesRoot(levelRoot: Node): Node {
let tilesRoot = levelRoot.getChildByName('Tiles');
if (!tilesRoot) {
tilesRoot = new Node('Tiles');
tilesRoot.layer = UI_LAYER;
tilesRoot.parent = levelRoot;
setupLayerContainer(tilesRoot);
}
tilesRoot.setSiblingIndex(levelRoot.children.length - 1);
return tilesRoot;
}
const TILE_LAYER_NAMES = ['Ground', 'Border', 'Tiles'] as const;
function forEachTileChild(levelRoot: Node, fn: (node: Node, parsed: { layer: 'ground' | 'border'; x: number; y: number }) => void) {
for (const layerName of TILE_LAYER_NAMES) {
const layer = levelRoot.getChildByName(layerName);
if (!layer) continue;
for (const ch of layer.children) {
if (!ch?.isValid || !ch.active) continue;
const parsed = parseTileNodeName(ch.name);
if (!parsed) continue;
fn(ch, parsed);
}
}
}
export function alignTileNode(node: Node, cellX: number, cellY: number, tileName: string, theme?: string) {
const w = cellToWorldCenter(new Vec3(cellX, cellY, 0));
let ui = node.getComponent(UITransform);
if (!ui) ui = node.addComponent(UITransform);
const spr = node.getComponent(Sprite);
const source = resolveTilePixelSize(tileName, spr?.spriteFrame ?? null, theme);
const draw = getTileDrawSize(tileName, source.width, source.height, theme);
const pivot = getTilePivot(tileName, theme);
ui.setContentSize(draw.width, draw.height);
ui.setAnchorPoint(pivot.x, pivot.y);
node.setPosition(w.x, w.y, 0);
node.setScale(1, 1, 1);
}
export function setupLayerContainer(layer: Node) {
let ui = layer.getComponent(UITransform);
if (!ui) ui = layer.addComponent(UITransform);
ui.setAnchorPoint(0, 0);
ui.setContentSize(1, 1);
layer.setPosition(0, 0, 0);
}
function tileNameFromConfig(
layer: 'ground' | 'border',
key: string,
config: LevelConfig,
): string {
if (layer === 'ground') {
return config.ground?.[key] ?? CommonDefine.BlockBase;
}
let v = config.border?.[key];
if (v === true || v === undefined) return 'WallBlock';
if (typeof v === 'string') return v;
return 'WallBlock';
}
export function layoutLevelTiles(levelRoot: Node, config: LevelConfig) {
if (!levelRoot?.isValid) return;
const theme = config.theme;
const ground = levelRoot.getChildByName('Ground');
const border = levelRoot.getChildByName('Border');
if (ground) {
setupLayerContainer(ground);
for (const ch of ground.children) {
const parsed = parseTileNodeName(ch.name);
if (!parsed || parsed.layer !== 'ground') continue;
const key = `${parsed.x},${parsed.y}`;
alignTileNode(ch, parsed.x, parsed.y, tileNameFromConfig('ground', key, config), theme);
}
}
if (border) {
setupLayerContainer(border);
for (const ch of border.children) {
const parsed = parseTileNodeName(ch.name);
if (!parsed || parsed.layer !== 'border') continue;
const key = `${parsed.x},${parsed.y}`;
alignTileNode(ch, parsed.x, parsed.y, tileNameFromConfig('border', key, config), theme);
}
}
const tilesRoot = levelRoot.getChildByName('Tiles');
if (tilesRoot) {
setupLayerContainer(tilesRoot);
for (const ch of tilesRoot.children) {
const parsed = parseTileNodeName(ch.name);
if (!parsed) continue;
const key = `${parsed.x},${parsed.y}`;
const layer = parsed.layer;
alignTileNode(ch, parsed.x, parsed.y, tileNameFromConfig(layer, key, config), theme);
}
}
sortIsoTiles(levelRoot);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8d236aa5-d44c-4bae-af60-2346b314e8ba",
"files": [],
"subMetas": {},
"userData": {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,388 +1,2 @@
import { _decorator, Component, Node, Vec3, Color, Graphics, UITransform, director } from 'cc';
import { CELL_PIXEL } from '../AppBootstrap';
import {
CELL_SIZE, CommonDefine, Direction, GameState, GridType, Skin, addDirection,
} from '../core/Define';
import { EventManager, EventType } from '../core/EventManager';
import { JsBridge } from '../bridge/JsBridge';
import { getLevelConfig, hasLevel, registerLevel } from '../level/LevelRegistry';
import { LevelConfig, SpawnConfig } from '../level/LevelTypes';
import { PlayerController } from '../controller/PlayerController';
import { VehicleController } from '../controller/VehicleController';
import { PropController } from '../controller/PropController';
import { VisualAssets } from '../visual/VisualAssets';
import { SpawnKind } from '../level/LevelTypes';
const { ccclass, property } = _decorator;
interface GridEntry {
type: GridType;
node: Node;
}
@ccclass('GameManager')
export class GameManager extends Component {
static instance: GameManager | null = null;
@property(Node)
mainLevelEntrance: Node | null = null;
@property
initialLevelID = 1;
playerSkin: Skin = Skin.Silu;
multMode = false;
multPlayerRole = '';
gameState: GameState = GameState.Run;
isInputEnd = false;
uiStyle = 'default';
curLevelID = 1;
stepNum = 0;
stepA1Num = 0;
stepA2Num = 0;
stepA3Num = 0;
stepB1Num = 0;
stepB2Num = 0;
stepB3Num = 0;
private creating = false;
private curLevel: Node | null = null;
private curConfig: LevelConfig | null = null;
private gridTypes = new Map<string, GridEntry>();
private gridTypesForProps = new Map<string, GridEntry>();
private groundCells = new Map<string, string>();
private borderCells = new Set<string>();
onLoad() {
if (GameManager.instance && GameManager.instance !== this) {
this.destroy();
return;
}
GameManager.instance = this;
}
start() {
/* 关卡由 AppBootstrap 在就绪后加载 */
}
onDestroy() {
if (GameManager.instance === this) GameManager.instance = null;
}
// --- 状态 ---
setGameState(s: GameState) { this.gameState = s; }
initData() {
this.setGameState(GameState.Run);
this.stepNum = 0;
this.stepA1Num = this.stepA2Num = this.stepA3Num = 0;
this.stepB1Num = this.stepB2Num = this.stepB3Num = 0;
this.callSetIsInputEnd(0);
}
jsCallCheck(n: number): boolean {
if (this.isInputEnd || this.gameState !== GameState.Run) return false;
this.stepNum += Math.abs(n);
return true;
}
jsCallCheckMultMode(n: number, name: string): boolean {
if (this.isInputEnd || this.gameState !== GameState.Run) return false;
const a = Math.abs(n);
switch (name) {
case 'PlayerA1':
case 'VehicleA1': this.stepA1Num += a; break;
case 'PlayerA2':
case 'VehicleA2': this.stepA2Num += a; break;
case 'PlayerA3':
case 'VehicleA3': this.stepA3Num += a; break;
case 'PlayerB1':
case 'VehicleB1': this.stepB1Num += a; break;
case 'PlayerB2':
case 'VehicleB2': this.stepB2Num += a; break;
case 'PlayerB3':
case 'VehicleB3': this.stepB3Num += a; break;
default: break;
}
return true;
}
callSetIsInputEnd(v: number) {
this.isInputEnd = v !== 0;
if (this.isInputEnd) EventManager.dispatch(EventType.InputEnd, this.gameState);
}
getRelativePosition(given: Direction, input: Direction): string {
const rel = (((input - given) % 4) + 4) % 4;
return ['front', 'right', 'back', 'left'][rel];
}
// --- 坐标 ---
cellKey(x: number, y: number) { return `${x},${y}`; }
cellToWorld(cell: Vec3): Vec3 {
return new Vec3(cell.x * CELL_PIXEL, cell.y * CELL_PIXEL, 0);
}
worldToCell(world: Vec3): Vec3 {
return new Vec3(Math.round(world.x / CELL_PIXEL), Math.round(world.y / CELL_PIXEL), 0);
}
nextGridPosition(pos: Vec3, dir: Direction): Vec3 {
const c = this.worldToCell(pos);
switch (dir) {
case Direction.North: c.x += 1; break;
case Direction.South: c.x -= 1; break;
case Direction.East: c.y -= 1; break;
case Direction.West: c.y += 1; break;
}
return this.cellToWorld(c);
}
calculateGridType(pos: Vec3): GridType {
const cell = this.worldToCell(pos);
const key = this.cellKey(cell.x, cell.y);
const dyn = this.gridTypes.get(key);
if (dyn) return dyn.type;
if (this.borderCells.has(key)) return GridType.Block;
const g = this.groundCells.get(key);
if (g === CommonDefine.BlockBase) return GridType.Across;
if (g === CommonDefine.BlockJump) return GridType.Jump;
const b = this.curConfig?.boundary;
if (b && (Math.abs(cell.x) >= b.x || Math.abs(cell.y) >= b.y)) return GridType.Boundary;
return GridType.None;
}
calculateNextGridType(pos: Vec3, dir: Direction): GridType {
return this.calculateGridType(this.nextGridPosition(pos, dir));
}
calculateLastGridType(pos: Vec3, dir: Direction): GridType {
return this.calculateGridType(this.nextGridPosition(pos, addDirection(dir, 2)));
}
getGameObject(pos: Vec3): Node | null {
const key = this.cellKey(this.worldToCell(pos).x, this.worldToCell(pos).y);
return this.gridTypes.get(key)?.node ?? null;
}
addObj(pos: Vec3, type: GridType, node: Node) {
const c = this.worldToCell(pos);
this.gridTypes.set(this.cellKey(c.x, c.y), { type, node });
}
removeObj(pos: Vec3) {
const c = this.worldToCell(pos);
this.gridTypes.delete(this.cellKey(c.x, c.y));
}
removeProp(pos: Vec3) {
const c = this.worldToCell(pos);
this.gridTypesForProps.delete(this.cellKey(c.x, c.y));
}
countProp(): number {
if (!this.curLevel) return 0;
return this.curLevel.children.filter((c) => c.isValid && c.getComponent(PropController)).length;
}
getCurLevel() { return this.curConfig; }
findNodeByName(name: string): Node | null {
const scene = director.getScene();
if (!scene) return null;
return this.findInTree(scene, name);
}
private findInTree(root: Node, name: string): Node | null {
if (root.name === name) return root;
for (const ch of root.children) {
const f = this.findInTree(ch, name);
if (f) return f;
}
return null;
}
// --- 关卡 ---
destroyCurLevel() {
if (this.curLevel?.isValid) this.curLevel.destroy();
this.curLevel = null;
this.gridTypes.clear();
this.gridTypesForProps.clear();
this.groundCells.clear();
this.borderCells.clear();
}
switchLevel(levelID: number) {
if (!hasLevel(levelID) || this.creating) return;
this.multMode = levelID >= 999000;
this.destroyCurLevel();
this.createNewLevel(levelID);
}
async createNewLevel(levelID: number) {
if (this.creating) return;
this.creating = true;
const config = getLevelConfig(levelID);
if (!config || !this.mainLevelEntrance) {
this.creating = false;
return;
}
this.curLevelID = levelID;
this.curConfig = config;
const levelRoot = new Node(`Level_${levelID}`);
levelRoot.parent = this.mainLevelEntrance;
this.curLevel = levelRoot;
if (config.ground) {
for (const [k, v] of Object.entries(config.ground)) this.groundCells.set(k, v);
}
if (config.border) {
for (const k of Object.keys(config.border)) this.borderCells.add(k);
}
this.drawGridDebug(levelRoot, config);
const spawned: Node[] = [];
for (const s of config.spawns) {
const node = this.spawnEntity(levelRoot, s);
if (node) spawned.push(node);
}
this.initGridTypes();
this.initData();
EventManager.dispatch(EventType.LevelInit);
this.externalCallLevelInfo(spawned);
this.creating = false;
}
resetLevel() {
this.destroyCurLevel();
this.createNewLevel(this.curLevelID);
}
startMultPlay(role: string, coinsJson: string) {
this.multMode = true;
this.multPlayerRole = role;
let coins: string[] = [];
try {
coins = JSON.parse(coinsJson) as string[];
} catch (e) {
console.error('StartMultPlay coins parse', e);
}
const base = getLevelConfig(999001);
if (!base) return;
const extra = coins.map((s) => {
const [xs, ys] = s.split(',');
return { x: parseInt(xs, 10), y: parseInt(ys, 10), kind: 'prop' as const };
});
registerLevel({ ...base, spawns: [...base.spawns, ...extra] });
this.switchLevel(999001);
}
changeUIStyle(style: string) {
this.uiStyle = style;
}
callMute() { /* 可由 UIMain 实现 */ }
callUnmute() { /* 可由 UIMain 实现 */ }
private initGridTypes() {
this.gridTypes.clear();
this.gridTypesForProps.clear();
if (!this.curLevel) return;
const vehicles = this.curLevel.children.filter((c) => c.name.includes('Vehicle'));
for (const v of vehicles) {
const c = this.worldToCell(v.worldPosition);
this.gridTypes.set(this.cellKey(c.x, c.y), { type: GridType.Ride, node: v });
}
const props = this.curLevel.children.filter((c) => c.getComponent(PropController));
for (const p of props) {
const c = this.worldToCell(p.worldPosition);
this.gridTypesForProps.set(this.cellKey(c.x, c.y), {
type: this.calculateGridType(p.worldPosition),
node: p,
});
}
}
private spawnEntity(parent: Node, s: SpawnConfig): Node | null {
const pos = this.cellToWorld(new Vec3(s.x, s.y, 0));
let node: Node;
if (s.kind === 'player') {
node = new Node(s.x === -9 ? 'PlayerA1' : s.x === 9 ? 'PlayerB1' : 'Player');
node.addComponent(PlayerController);
const pc = node.getComponent(PlayerController)!;
pc.direction = s.playerDirection ?? Direction.South;
} else if (s.kind === 'vehicle') {
node = new Node(s.x < 0 ? 'VehicleA1' : 'VehicleB1');
node.addComponent(VehicleController);
const vc = node.getComponent(VehicleController)!;
vc.direction = s.vehicleDirection ?? Direction.North;
} else if (s.kind === 'prop') {
node = new Node('Prop');
node.addComponent(PropController);
} else if (s.kind === 'prop_decor') {
node = new Node('PropDecor');
} else {
return null;
}
this.attachVisual(node, s.kind, s.playerDirection, s.vehicleDirection);
node.setPosition(pos);
const ui = node.getComponent(UITransform) || node.addComponent(UITransform);
ui.setContentSize(CELL_PIXEL * 0.9, CELL_PIXEL * 0.9);
node.parent = parent;
return node;
}
private attachVisual(
node: Node,
kind: SpawnKind,
playerDir?: Direction,
vehicleDir?: Direction,
) {
const dir = kind === 'player' ? playerDir : kind === 'vehicle' ? vehicleDir : undefined;
VisualAssets.setupEntityVisual(node, kind, dir);
}
private drawGridDebug(root: Node, config: LevelConfig) {
const tiles = new Node('Ground');
tiles.parent = root;
const bx = config.boundary.x;
const by = config.boundary.y;
const half = CELL_PIXEL * 0.45;
for (let x = -bx + 1; x < bx; x++) {
for (let y = -by + 1; y < by; y++) {
const key = this.cellKey(x, y);
if (this.borderCells.has(key)) continue;
const p = this.cellToWorld(new Vec3(x, y, 0));
const cell = new Node(`cell_${x}_${y}`);
cell.parent = tiles;
cell.setPosition(p);
const cui = cell.addComponent(UITransform);
cui.setContentSize(CELL_PIXEL * 0.9, CELL_PIXEL * 0.9);
VisualAssets.applySprite(cell, 'tile', false, 1, 50);
}
}
}
externalCallLevelInfo(objects: Node[]) {
const info: { LevelID: number; PlayerName: string; VehicleName: string } = {
LevelID: this.curLevelID,
PlayerName: '',
VehicleName: '',
};
for (const obj of objects) {
if (this.multMode) {
if (obj.name === this.multPlayerRole) info.PlayerName = obj.name;
else if (obj.name === this.multPlayerRole.replace('Player', 'Vehicle')) info.VehicleName = obj.name;
} else {
if (obj.name.includes('Player')) info.PlayerName = obj.name;
if (obj.name.includes('Vehicle')) info.VehicleName = obj.name;
}
}
JsBridge.call('externalLevelInfo', JSON.stringify(info));
}
}
/** @deprecated 请直接使用 GameController保留此路径以兼容旧 import */
export { GameController, GameController as GameManager } from '../GameController';

View File

@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "67fa2457-bacd-4989-820f-875de805f5b0",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,82 @@
import { Sprite, UITransform, Node, view, Layers, director, find } from 'cc';
import { normalizeTexturePath } from '../visual/EntityTextureResolver';
import { VisualAssets } from '../visual/VisualAssets';
import { getThemeBackground } from './ThemeRegistry';
const BG_NODE_NAME = 'LevelThemeBackground';
/** 与主关卡 UI_2D、HUD UI_3D 分离,由 BgCamera 固定渲染 */
const BG_LAYER = Layers.Enum.DEFAULT;
/** 按关卡 theme 应用背景图BgOverlay + BgCamera不随主相机缩放/拖拽) */
export class ThemeBackground {
static resolveHost(entrance: Node | null): Node | null {
const scene = entrance?.scene ?? director.getScene();
if (!scene) return null;
return find('BgOverlay', scene);
}
/** 移除误挂在 GameRoot / MainLevelEntrance 等处的旧背景节点 */
static purgeStaleNodes(entrance: Node | null) {
const scene = entrance?.scene ?? director.getScene();
if (!scene) return;
const host = find('BgOverlay', scene);
const walk = (node: Node) => {
for (const ch of [...node.children]) {
if (ch.name === BG_NODE_NAME && ch.parent !== host) {
ch.destroy();
} else {
walk(ch);
}
}
};
walk(scene);
}
static async apply(entrance: Node | null, themeId: string | undefined): Promise<void> {
const parent = this.resolveHost(entrance);
if (!parent?.isValid) return;
this.purgeStaleNodes(entrance);
const path = normalizeTexturePath(getThemeBackground(themeId));
let bgNode = parent.getChildByName(BG_NODE_NAME);
if (!path) {
if (bgNode) bgNode.active = false;
return;
}
if (!bgNode) {
bgNode = new Node(BG_NODE_NAME);
bgNode.parent = parent;
bgNode.addComponent(UITransform);
bgNode.addComponent(Sprite);
}
bgNode.layer = BG_LAYER;
bgNode.active = true;
bgNode.setSiblingIndex(0);
bgNode.setPosition(0, 0, 0);
bgNode.setScale(1, 1, 1);
const sf = await VisualAssets.loadTexturePath(path);
if (!sf || !bgNode?.isValid) return;
const ui = bgNode.getComponent(UITransform)!;
const spr = bgNode.getComponent(Sprite)!;
spr.spriteFrame = sf;
spr.sizeMode = Sprite.SizeMode.CUSTOM;
this.layoutFullScreen(bgNode, sf);
}
private static layoutFullScreen(bgNode: Node, sf: NonNullable<Awaited<ReturnType<typeof VisualAssets.loadTexturePath>>>) {
const ui = bgNode.getComponent(UITransform)!;
const vs = view.getVisibleSize();
const scale = Math.max(vs.width / sf.originalSize.width, vs.height / sf.originalSize.height);
ui.setContentSize(sf.originalSize.width * scale, sf.originalSize.height * scale);
ui.setAnchorPoint(0.5, 0.5);
bgNode.setPosition(0, 0, 0);
}
static clear(entrance: Node | null) {
this.purgeStaleNodes(entrance);
const parent = this.resolveHost(entrance);
const bgNode = parent?.getChildByName(BG_NODE_NAME);
if (bgNode) bgNode.active = false;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "83a0c5ff-dabf-4448-9aa9-e0f95caee32d",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,438 @@
import { JsonAsset, resources } from 'cc';
import { CELL_PIXEL } from '../core/GridConstants';
import type {
ThemeConfig,
ThemeDatabaseFile,
ThemeEntityConfig,
ThemeTileConfig,
ThemeHudConfig,
ThemeHudIconKey,
EntityDisplayGlobalConfig,
EntityDisplayCellBox,
EntityDisplayKind,
EntityDisplayScaleConfig,
} from './ThemeTypes';
const DB_PATH = 'theme/themes-database';
/** scale=1 时的包围盒(相对 100px 格子),宽高比固定 */
export const ENTITY_DISPLAY_BASE: Record<EntityDisplayKind, EntityDisplayCellBox> = {
player: { w: 0.68, h: 0.9 },
vehicle: { w: 0.96, h: 0.88 },
/** 与 Unity Prop_kuai* 竖长比例一致(约 49×69 */
prop: { w: 0.52, h: 0.69 },
propGround: { w: 0.52, h: 0.69 },
};
const HALF_H = CELL_PIXEL * 0.25;
/** Unity Prop_sanxing checkPoint collider ≈ +0.144 world (PPU 100) */
export const DEFAULT_PROP_BLOCK_Y_OFFSET = 14;
/** Unity nProp_sanxing checkPoint collider ≈ -0.115 world */
export const DEFAULT_PROP_GROUND_Y_OFFSET = -11;
/** 空地/载具格角色与载具:补齐无 Baseblock 时的视觉高度(约半格) */
export const DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET = HALF_H;
/** JumpBlock 自动补偿之上的主题微调px */
export const DEFAULT_MOVER_JUMP_CELL_Y_OFFSET = 0;
/** 骑乘时角色相对载具节点抬高,对齐载具甲板(约 vehicle 包围盒 40% */
export const DEFAULT_PLAYER_RIDE_Y_OFFSET = HALF_H * 0.88;
export const DEFAULT_PLAYER_STAND_Y_OFFSET = 0;
export const DEFAULT_ENTITY_DISPLAY: EntityDisplayGlobalConfig = {
player: { scale: 1 },
vehicle: { scale: 1 },
prop: { scale: 1 },
propGround: { scale: 1 },
propBlockYOffset: DEFAULT_PROP_BLOCK_Y_OFFSET,
propGroundYOffset: DEFAULT_PROP_GROUND_Y_OFFSET,
moverEmptyCellYOffset: DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET,
moverJumpCellYOffset: DEFAULT_MOVER_JUMP_CELL_Y_OFFSET,
playerRideYOffset: DEFAULT_PLAYER_RIDE_Y_OFFSET,
playerStandYOffset: DEFAULT_PLAYER_STAND_Y_OFFSET,
};
const DISPLAY_KINDS: EntityDisplayKind[] = ['player', 'vehicle', 'prop', 'propGround'];
const HUD_ICON_KEYS: ThemeHudIconKey[] = [
'navigation', 'revert', 'speed1', 'speed2', 'speed4',
'zoomIn', 'zoomOut', 'audioOn', 'audioOff',
];
/** Unity 导入的内置 HUD 回退themes-database 未配置 hud 时使用) */
const FALLBACK_HUD: Record<string, ThemeHudConfig> = {
silu: {
navigation: 'textures/silu/anniu_03',
revert: 'textures/silu/anniu_06',
speed1: 'textures/silu/anniu_08',
speed2: 'textures/silu/anniu_10',
speed4: 'textures/silu/anniu_12',
zoomIn: 'textures/silu/anniu_17',
zoomOut: 'textures/silu/anniu_19',
audioOn: 'textures/silu/anniu_22',
audioOff: 'textures/silu/anniu_21',
},
};
const THEME_ID_ALIASES: Record<string, string> = {
default: 'silu',
SILU: 'silu',
redArmy: 'redarmy',
};
const FOLDER_TO_THEME_ID: Record<string, string> = {
redArmy: 'redarmy',
};
function asTrimmedString(v: unknown): string | undefined {
if (v == null) return undefined;
if (typeof v === 'string') {
const s = v.trim();
return s || undefined;
}
if (typeof v === 'number' || typeof v === 'boolean') {
return String(v);
}
return undefined;
}
export function resolveThemeId(themeId: string | undefined): string {
const trimmed = asTrimmedString(themeId);
if (!trimmed) return 'silu';
const raw = trimmed;
if (themesMap[raw]) return raw;
const alias = THEME_ID_ALIASES[raw];
if (alias && themesMap[alias]) return alias;
const fromFolder = FOLDER_TO_THEME_ID[raw];
if (fromFolder && themesMap[fromFolder]) return fromFolder;
const lower = raw.toLowerCase();
if (themesMap[lower]) return lower;
return raw;
}
function mergeHudConfig(raw: ThemeHudConfig | undefined, themeId: string): ThemeHudConfig {
const layers: ThemeHudConfig[] = [FALLBACK_HUD.silu];
if (FALLBACK_HUD[themeId]) layers.push(FALLBACK_HUD[themeId]!);
if (raw) layers.push(raw);
return Object.assign({}, ...layers);
}
export function getThemeHudConfig(themeId: string | undefined): ThemeHudConfig {
const id = resolveThemeId(themeId);
const cfg = getThemeConfig(id);
return mergeHudConfig(cfg?.hud, themesMap[id] ? id : 'silu');
}
export function getThemeHudIconPath(themeId: string | undefined, icon: ThemeHudIconKey): string | undefined {
const hud = getThemeHudConfig(themeId);
const path = asTrimmedString(hud[icon]);
if (path) return path;
return FALLBACK_HUD.silu[icon];
}
export function getThemeHudIconScale(themeId: string | undefined): { x: number; y: number } {
const hud = getThemeHudConfig(themeId);
return {
x: hud.iconScaleX ?? 1,
y: hud.iconScaleY ?? 1,
};
}
/** 左上角肖像贴图(优先 entities.portrait */
export function getThemePortraitPath(themeId: string | undefined): string | undefined {
const id = resolveThemeId(themeId);
const ent = getThemeConfig(id)?.entities;
const portrait = asTrimmedString(ent?.portrait);
if (portrait) return portrait;
return asTrimmedString(ent?.playerFront);
}
/** 左上角肖像水平翻转(默认朝右) */
export function getThemePortraitFlipX(themeId: string | undefined): boolean {
const hud = getThemeHudConfig(themeId);
return hud.portraitFlipX ?? true;
}
const DEFAULT_PORTRAIT_SCALE = 1.35;
export function getThemePortraitScale(themeId: string | undefined): number {
const hud = getThemeHudConfig(themeId);
const raw = hud.portraitScale;
if (raw != null && Number.isFinite(raw)) {
return Math.max(0.5, Math.min(2.5, raw));
}
return DEFAULT_PORTRAIT_SCALE;
}
export function getThemeHudIconCandidates(themeId: string | undefined, icon: ThemeHudIconKey): string[] {
const primary = getThemeHudIconPath(themeId, icon);
if (!primary) return [];
// 不回退到 silu 碎图,避免非丝路主题下倍速/音量变成黄圆按钮
return [primary];
}
function clampScale(v: number, fallback = 1): number {
if (!Number.isFinite(v)) return fallback;
return Math.max(0.1, Math.min(2, v));
}
function readScale(raw: EntityDisplayScaleConfig | EntityDisplayCellBox | undefined, base: EntityDisplayCellBox): number {
if (raw && 'scale' in raw && raw.scale != null) {
return clampScale(raw.scale);
}
const legacy = raw as EntityDisplayCellBox | undefined;
if (legacy?.w != null && legacy?.h != null) {
return clampScale(Math.min(legacy.w / base.w, legacy.h / base.h));
}
if (legacy?.w != null) return clampScale(legacy.w / base.w);
if (legacy?.h != null) return clampScale(legacy.h / base.h);
return 1;
}
function mergeScale(raw: EntityDisplayScaleConfig | EntityDisplayCellBox | undefined, kind: EntityDisplayKind): EntityDisplayScaleConfig {
return { scale: readScale(raw, ENTITY_DISPLAY_BASE[kind]) };
}
function readYOffset(raw: number | undefined, fallback: number): number {
if (raw == null || !Number.isFinite(raw)) return fallback;
return raw;
}
/** 读取 JSON 中的等比缩放配置(兼容旧版 w/h */
export function mergeEntityDisplay(
raw: (Partial<Record<EntityDisplayKind, EntityDisplayScaleConfig | EntityDisplayCellBox>> & Pick<EntityDisplayGlobalConfig, 'propBlockYOffset' | 'propGroundYOffset' | 'moverEmptyCellYOffset' | 'playerRideYOffset' | 'playerStandYOffset'>) | undefined,
): EntityDisplayGlobalConfig {
const prop = mergeScale(raw?.prop, 'prop');
const blockY = readYOffset(raw?.propBlockYOffset, DEFAULT_PROP_BLOCK_Y_OFFSET);
return {
player: mergeScale(raw?.player, 'player'),
vehicle: mergeScale(raw?.vehicle, 'vehicle'),
prop,
propGround: mergeScale(raw?.propGround ?? raw?.prop, 'propGround'),
propBlockYOffset: blockY,
propGroundYOffset: readYOffset(raw?.propGroundYOffset, DEFAULT_PROP_GROUND_Y_OFFSET),
moverEmptyCellYOffset: readYOffset(raw?.moverEmptyCellYOffset, DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET),
moverJumpCellYOffset: readYOffset(raw?.moverJumpCellYOffset, DEFAULT_MOVER_JUMP_CELL_Y_OFFSET),
playerRideYOffset: readYOffset(raw?.playerRideYOffset, DEFAULT_PLAYER_RIDE_Y_OFFSET),
playerStandYOffset: readYOffset(raw?.playerStandYOffset, DEFAULT_PLAYER_STAND_Y_OFFSET),
};
}
/** 骑乘载具时角色视觉 Y 抬高(逻辑格点仍与载具一致) */
export function getThemePlayerRideYOffset(themeId: string | undefined): number {
const cfg = getThemeEntityDisplayScales(themeId);
return cfg.playerRideYOffset ?? DEFAULT_PLAYER_RIDE_Y_OFFSET;
}
/** 角色站立 Y 偏移(负值降低) */
export function getThemePlayerStandYOffset(themeId: string | undefined): number {
const cfg = getThemeEntityDisplayScales(themeId);
return cfg.playerStandYOffset ?? DEFAULT_PLAYER_STAND_Y_OFFSET;
}
/** 空地/载具格角色与载具 Y 补偿(有砖块格为 0 */
export function getThemeMoverEmptyCellYOffset(themeId: string | undefined): number {
const cfg = getThemeEntityDisplayScales(themeId);
return cfg.moverEmptyCellYOffset ?? cfg.propBlockYOffset ?? DEFAULT_MOVER_EMPTY_CELL_Y_OFFSET;
}
/** JumpBlock 额外 Y 抬高(在自动 pivot 差值之上) */
export function getThemeMoverJumpCellYOffset(themeId: string | undefined): number {
const cfg = getThemeEntityDisplayScales(themeId);
return cfg.moverJumpCellYOffset ?? DEFAULT_MOVER_JUMP_CELL_Y_OFFSET;
}
/** 可拾取物砖块/空地 Y 偏移(来自主题 entityDisplay缺省为 Unity Prop/nProp 换算值) */
export function getThemePropPlacementOffsets(themeId: string | undefined): { block: number; ground: number } {
const cfg = getThemeEntityDisplayScales(themeId);
return {
block: cfg.propBlockYOffset ?? DEFAULT_PROP_BLOCK_Y_OFFSET,
ground: cfg.propGroundYOffset ?? DEFAULT_PROP_GROUND_Y_OFFSET,
};
}
/** 由缩放算出运行时包围盒(保持默认宽高比) */
export function entityDisplayCellBoxes(scales: EntityDisplayGlobalConfig): Record<EntityDisplayKind, EntityDisplayCellBox> {
const out = {} as Record<EntityDisplayKind, EntityDisplayCellBox>;
for (const kind of DISPLAY_KINDS) {
const s = scales[kind].scale;
const base = ENTITY_DISPLAY_BASE[kind];
out[kind] = { w: base.w * s, h: base.h * s };
}
return out;
}
export function getGlobalEntityDisplay(): Record<EntityDisplayKind, EntityDisplayCellBox> {
return entityDisplayCellBoxes(getGlobalEntityDisplayScales());
}
/** @deprecated 使用 getThemeEntityDisplayScales(themeId) */
export function getGlobalEntityDisplayScales(): EntityDisplayGlobalConfig {
return mergeEntityDisplay(fileCache?.entityDisplay);
}
export function getThemeEntityDisplayScales(themeId: string | undefined): EntityDisplayGlobalConfig {
const cfg = getThemeConfig(resolveThemeId(themeId));
if (cfg?.entityDisplay) {
return mergeEntityDisplay(cfg.entityDisplay);
}
if (fileCache?.entityDisplay) {
return mergeEntityDisplay(fileCache.entityDisplay);
}
return mergeEntityDisplay(undefined);
}
export function getThemeEntityDisplayCellBoxes(themeId: string | undefined): Record<EntityDisplayKind, EntityDisplayCellBox> {
return entityDisplayCellBoxes(getThemeEntityDisplayScales(themeId));
}
let fileCache: ThemeDatabaseFile | null = null;
let themesMap: Record<string, ThemeConfig> = {};
let sortedIds: string[] = [];
let loadPromise: Promise<void> | null = null;
function rebuildIndex() {
sortedIds = Object.keys(themesMap).sort((a, b) => a.localeCompare(b));
}
function ingestFile(data: ThemeDatabaseFile) {
migrateLegacyEntityDisplay(data);
fileCache = data;
themesMap = { ...(data.themes ?? {}) };
rebuildIndex();
}
/** 将旧版根级 entityDisplay 迁移到各主题(仅补缺失项) */
function migrateLegacyEntityDisplay(data: ThemeDatabaseFile) {
const legacy = data.entityDisplay;
if (!legacy || !data.themes) return;
for (const id of Object.keys(data.themes)) {
if (!data.themes[id].entityDisplay) {
data.themes[id].entityDisplay = mergeEntityDisplay(legacy);
}
}
}
export function loadThemeDatabase(): Promise<void> {
if (loadPromise) return loadPromise;
loadPromise = new Promise((resolve, reject) => {
resources.load(DB_PATH, JsonAsset, (err, asset) => {
if (err || !asset?.json) {
console.warn('[ThemeDatabase] 未找到 themes-database.json将使用内置回退', err);
ingestFile({ version: 1, themes: {} });
resolve();
return;
}
ingestFile(asset.json as ThemeDatabaseFile);
console.log(`[ThemeDatabase] 已加载 ${sortedIds.length} 个主题: ${sortedIds.join(', ')}`);
resolve();
});
});
return loadPromise;
}
/** 重新读取 themes-database.json主题控制器保存全局尺寸后生效 */
export function reloadThemeDatabase(): Promise<void> {
fileCache = null;
loadPromise = null;
themesMap = {};
sortedIds = [];
return new Promise((resolve) => {
resources.release(DB_PATH);
loadThemeDatabase().then(resolve);
});
}
export function isThemeDatabaseReady(): boolean {
return fileCache !== null;
}
export function getThemeIds(): string[] {
return [...sortedIds];
}
export function getThemeConfig(themeId: string | undefined): ThemeConfig | null {
if (!themeId) return null;
return themesMap[themeId] ?? null;
}
export function hasTheme(themeId: string): boolean {
return themeId in themesMap;
}
export function getThemeTextureFolder(themeId: string | undefined): string {
const cfg = getThemeConfig(themeId);
if (cfg?.textureFolder) return cfg.textureFolder;
if (themeId === 'redarmy') return 'redArmy';
return themeId ?? 'silu';
}
export function getThemeEntities(themeId: string | undefined): ThemeEntityConfig | null {
const cfg = getThemeConfig(themeId);
return cfg?.entities ?? null;
}
export function getThemeBackground(themeId: string | undefined): string | undefined {
const cfg = getThemeConfig(themeId);
return asTrimmedString(cfg?.background);
}
export function getThemeTiles(themeId: string | undefined): ThemeTileConfig | null {
const cfg = getThemeConfig(themeId);
return cfg?.tiles ?? null;
}
export function getThemeBorderDecorKey(themeId: string | undefined): string | undefined {
const cfg = getThemeConfig(resolveThemeId(themeId));
return asTrimmedString(cfg?.borderDecorKey);
}
/** 关卡 JSON / Unity 预制体里常见的装饰砖 tileKey统一映射到 themes-database 的 borderDecor */
const BORDER_DECOR_ALIASES = new Set([
'kuai11',
'Decor23',
'borderDecor',
'素材切图-23',
'素材切图2-23',
'小游戏素材红色_03',
]);
/** 按 tileKey 解析贴图路径(含 borderDecorKey 与跨主题别名) */
export function getThemeTilePath(themeId: string | undefined, tileKey: unknown): string | undefined {
const id = resolveThemeId(themeId);
const tiles = getThemeTiles(id);
if (!tiles) return undefined;
const key = asTrimmedString(tileKey);
if (!key) return undefined;
if (key === 'Baseblock' && tiles.Baseblock) return tiles.Baseblock;
if (key === 'JumpBlock' && tiles.JumpBlock) return tiles.JumpBlock;
if (key === 'WallBlock' && tiles.WallBlock) return tiles.WallBlock;
const decorKey = getThemeBorderDecorKey(id);
if (tiles.borderDecor) {
if (key === decorKey || BORDER_DECOR_ALIASES.has(key)) {
return tiles.borderDecor;
}
}
if (tiles.borderDecor && key === 'borderDecor') return tiles.borderDecor;
return undefined;
}
export function setTheme(themeId: string, config: ThemeConfig): void {
themesMap[themeId] = { ...config };
if (fileCache) fileCache.themes[themeId] = themesMap[themeId];
rebuildIndex();
}
export function removeTheme(themeId: string): boolean {
if (!(themeId in themesMap)) return false;
delete themesMap[themeId];
if (fileCache?.themes) delete fileCache.themes[themeId];
rebuildIndex();
return true;
}
export function exportDatabaseJson(): string {
const payload: ThemeDatabaseFile = fileCache ?? { version: 1, themes: {} };
payload.updatedAt = new Date().toISOString();
payload.themes = { ...themesMap };
return JSON.stringify(payload, null, 2);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ff990bd4-abf4-4e61-93a1-d602e00ea740",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,38 @@
export {
loadThemeDatabase,
reloadThemeDatabase,
isThemeDatabaseReady,
getThemeIds,
getThemeConfig,
hasTheme,
getThemeTextureFolder,
getThemeEntities,
getThemeBackground,
getThemeTiles,
getThemeBorderDecorKey,
getThemeTilePath,
getGlobalEntityDisplay,
getGlobalEntityDisplayScales,
getThemeEntityDisplayScales,
getThemeEntityDisplayCellBoxes,
mergeEntityDisplay,
entityDisplayCellBoxes,
ENTITY_DISPLAY_BASE,
DEFAULT_ENTITY_DISPLAY,
DEFAULT_PROP_BLOCK_Y_OFFSET,
DEFAULT_PROP_GROUND_Y_OFFSET,
getThemePropPlacementOffsets,
getThemeHudConfig,
getThemeHudIconPath,
getThemeHudIconScale,
getThemeHudIconCandidates,
getThemePortraitPath,
getThemePortraitFlipX,
getThemePortraitScale,
resolveThemeId,
setTheme,
removeTheme,
exportDatabaseJson,
} from './ThemeDatabase';
export type { ThemeConfig, ThemeEntityConfig, ThemeTileConfig, ThemeHudConfig, ThemeHudIconKey, ThemeDatabaseFile, EntityDisplayGlobalConfig, EntityDisplayCellBox, EntityDisplayScaleConfig, EntityDisplayKind } from './ThemeTypes';

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "02caf4b6-3db1-455e-9293-6caad43db09f",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,119 @@
/** 主题内实体贴图resources 相对路径,无 .png */
export interface ThemeEntityConfig {
/** 左上角 HUD 肖像(缺省用 playerFront丝路对齐 Unity idel-反) */
portrait?: string;
playerFront?: string;
playerBack?: string;
/** 载具四向贴图(北/东/南/西);转向时直接切换,不做 flipX */
vehicleNorth?: string;
vehicleEast?: string;
vehicleSouth?: string;
vehicleWest?: string;
/** @deprecated 使用 vehicleSouth / vehicleNorth */
vehicleFront?: string;
/** @deprecated 使用 vehicleNorth / vehicleSouth */
vehicleBack?: string;
prop?: string;
/** 空地可拾取物Unity nProp* */
propGround?: string;
}
/** 四种砖块地面×2 + 墙×1 + 装饰墙×1 */
export interface ThemeTileConfig {
Baseblock?: string;
JumpBlock?: string;
WallBlock?: string;
borderDecor?: string;
}
/** HUD 右侧功能按钮贴图(相对 resources/,无扩展名;对齐 Unity GameManager.changeIcon */
export type ThemeHudIconKey =
| 'navigation'
| 'revert'
| 'speed1'
| 'speed2'
| 'speed4'
| 'zoomIn'
| 'zoomOut'
| 'audioOn'
| 'audioOff';
export interface ThemeHudConfig {
navigation?: string;
revert?: string;
speed1?: string;
speed2?: string;
speed4?: string;
zoomIn?: string;
zoomOut?: string;
audioOn?: string;
audioOff?: string;
/** Unity redArmy 等主题按钮图标缩放 */
iconScaleX?: number;
iconScaleY?: number;
/** 左上角肖像是否水平翻转朝右 */
portraitFlipX?: boolean;
/** 左上角肖像相对 Unity 194 框的缩放(默认 1.35 */
portraitScale?: number;
}
export interface ThemeConfig {
/** 显示名 */
displayName: string;
/** textures 下文件夹名(可与主题 id 不同,如 redarmy → redArmy */
textureFolder?: string;
/** 关卡背景图 */
background?: string;
/** 右侧 HUD 按钮贴图 */
hud?: ThemeHudConfig;
entities?: ThemeEntityConfig;
tiles?: ThemeTileConfig;
/** border 层第 4 块砖在关卡 JSON 里使用的 tileKey如 kuai11 */
borderDecorKey?: string;
/** 本主题实体等比缩放scale=1 为默认包围盒) */
entityDisplay?: EntityDisplayGlobalConfig;
}
export interface ThemeDatabaseFile {
version: number;
updatedAt?: string;
/** @deprecated 已迁移至各 theme.entityDisplay */
entityDisplay?: EntityDisplayGlobalConfig;
themes: Record<string, ThemeConfig>;
}
/** 相对 CELL_PIXEL100的宽高比例运行时由 scale × 基准盒计算) */
export interface EntityDisplayCellBox {
w: number;
h: number;
}
/** themes-database.json 中存储的等比缩放1 = 内置默认包围盒) */
export interface EntityDisplayScaleConfig {
scale: number;
}
export interface EntityDisplayGlobalConfig {
player: EntityDisplayScaleConfig;
vehicle: EntityDisplayScaleConfig;
prop: EntityDisplayScaleConfig;
/** 空地可拾取物nProp等比缩放缺省与 prop 相同 */
propGround?: EntityDisplayScaleConfig;
/** 砖块上可拾取物世界 Y 偏移px默认 HALF_H×1.36≈34 */
propBlockYOffset?: number;
/** 空地上可拾取物世界 Y 偏移px默认 -12 */
propGroundYOffset?: number;
/**
* 无砖块格子(空地/载具)上角色与载具额外 Y 偏移px
* 使视觉高度与站在 Baseblock 上一致;默认与 propBlockYOffset 相同
*/
moverEmptyCellYOffset?: number;
/** JumpBlock 上角色/载具额外 Y 抬高px在自动按贴图 pivot 差值之上叠加 */
moverJumpCellYOffset?: number;
/** 骑乘载具时角色相对载具的额外 Y 偏移px使角色站在载具甲板上 */
playerRideYOffset?: number;
/** 角色站立 Y 偏移px负值降低按主题微调贴图与砖面对齐 */
playerStandYOffset?: number;
}
export type EntityDisplayKind = 'player' | 'vehicle' | 'prop' | 'propGround';

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8aa76f83-d412-4a87-9ac9-72080487110d",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,151 @@
import {
_decorator, Component, Node, Label, EditBox, Button, UITransform, Color, Widget, Graphics, Layers,
} from 'cc';
import { EDITOR, PREVIEW } from 'cc/env';
import { GameManager } from '../manager/GameManager';
const { ccclass } = _decorator;
const HUD_LAYER = Layers.Enum.UI_3D;
/** 预览模式下调试条(对齐 Unity Editor/TestPlayer */
@ccclass('GameplayDebugBar')
export class GameplayDebugBar extends Component {
private stepBox: EditBox | null = null;
static shouldShow(): boolean {
return EDITOR && PREVIEW;
}
static ensure(parent: Node): GameplayDebugBar | null {
if (!GameplayDebugBar.shouldShow()) return null;
let bar = parent.getChildByName('GameplayDebugBar');
if (!bar) {
bar = new Node('GameplayDebugBar');
bar.parent = parent;
bar.layer = HUD_LAYER;
bar.addComponent(GameplayDebugBar);
}
bar.setSiblingIndex(parent.children.length - 1);
console.log('[GameplayDebugBar] 已显示(左下角)');
return bar.getComponent(GameplayDebugBar)!;
}
onLoad() {
this.node.layer = HUD_LAYER;
this.buildUI();
}
private gm() {
return GameManager.instance;
}
private buildUI() {
const rootUi = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
rootUi.setContentSize(680, 52);
rootUi.setAnchorPoint(0, 0);
const bg = this.node.getComponent(Graphics) || this.node.addComponent(Graphics);
bg.fillColor = new Color(0, 0, 0, 160);
bg.clear();
bg.rect(0, 0, 680, 52);
bg.fill();
this.mkLabel('Title', '调试', 8, 26, 14, new Color(255, 220, 120));
this.stepBox = this.mkEdit('StepInput', '1', 52, 8, 40);
this.mkBtn('前', 100, 8, 44, () => this.act('debugMove', this.step()));
this.mkBtn('后', 150, 8, 44, () => this.act('debugMove', -this.step()));
this.mkBtn('跳', 200, 8, 40, () => this.act('debugJump'));
this.mkBtn('←', 246, 8, 36, () => this.act('debugRotateLeft', 1));
this.mkBtn('→', 288, 8, 36, () => this.act('debugRotateRight', 1));
this.mkBtn('坐标', 330, 8, 52, () => this.act('debugPlayerInfo'));
this.mkBtn('结束', 388, 8, 52, () => this.act('debugInputEnd'));
this.mkBtn('载具', 446, 8, 52, () => this.act('debugVehicleMove', 1));
const widget = this.node.getComponent(Widget) || this.node.addComponent(Widget);
widget.isAlignBottom = true;
widget.bottom = 12;
widget.isAlignLeft = true;
widget.left = 12;
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
}
private step(): number {
const n = parseInt(this.stepBox?.string ?? '1', 10);
return Number.isNaN(n) || n === 0 ? 1 : n;
}
private act(method: string, arg?: number) {
const gm = this.gm();
if (!gm) {
console.warn('[GameplayDebugBar] GameController 未就绪');
return;
}
const fn = (gm as unknown as Record<string, unknown>)[method];
if (typeof fn !== 'function') {
console.warn(`[GameplayDebugBar] 无方法 ${method}`);
return;
}
if (arg === undefined) (fn as () => void).call(gm);
else (fn as (n: number) => void).call(gm, arg);
}
private mkLabel(name: string, text: string, x: number, y: number, size: number, color: Color): Label {
const n = new Node(name);
n.parent = this.node;
n.layer = HUD_LAYER;
n.setPosition(x, y, 0);
n.addComponent(UITransform).setContentSize(80, 24);
const lb = n.addComponent(Label);
lb.string = text;
lb.fontSize = size;
lb.color = color;
return lb;
}
private mkEdit(name: string, value: string, x: number, y: number, w: number): EditBox {
const n = new Node(name);
n.parent = this.node;
n.layer = HUD_LAYER;
n.setPosition(x, y, 0);
n.addComponent(UITransform).setContentSize(w, 32);
const box = n.addComponent(EditBox);
box.string = value;
box.fontSize = 16;
const tl = new Node('TEXT_LABEL');
tl.parent = n;
tl.layer = HUD_LAYER;
tl.addComponent(UITransform).setContentSize(w, 32);
box.textLabel = tl.addComponent(Label);
box.textLabel.fontSize = 16;
box.textLabel.color = new Color(255, 255, 255);
const pl = new Node('PLACEHOLDER_LABEL');
pl.parent = n;
pl.layer = HUD_LAYER;
pl.addComponent(UITransform).setContentSize(w, 32);
box.placeholderLabel = pl.addComponent(Label);
box.placeholderLabel.fontSize = 16;
box.placeholderLabel.color = new Color(140, 140, 140);
return box;
}
private mkBtn(caption: string, x: number, y: number, w: number, handler: () => void) {
const n = new Node(`Btn_${caption}`);
n.parent = this.node;
n.layer = HUD_LAYER;
n.setPosition(x, y, 0);
n.addComponent(UITransform).setContentSize(w, 32);
const btn = n.addComponent(Button);
btn.transition = Button.Transition.SCALE;
const tl = new Node('Label');
tl.parent = n;
tl.layer = HUD_LAYER;
tl.addComponent(UITransform).setContentSize(w, 32);
const lb = tl.addComponent(Label);
lb.string = caption;
lb.fontSize = 15;
lb.color = new Color(255, 255, 255);
n.on(Button.EventType.CLICK, handler, this);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "bdc2f2db-a739-4cc1-a5d8-84ca0303d703",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,161 @@
import {
_decorator, Component, Node, Label, EditBox, Button, UITransform, Color, Widget,
} from 'cc';
import { GameManager } from '../manager/GameManager';
import { getMaxLevelId, getMinLevelId } from '../level/LevelRegistry';
const { ccclass } = _decorator;
/**
* 预览/运行时的关卡切换条(真实 Button不依赖 Inspector 扩展)
*/
@ccclass('LevelSwitchBar')
export class LevelSwitchBar extends Component {
private editBox: EditBox | null = null;
static ensure(parent: Node): LevelSwitchBar {
let bar = parent.getChildByName('LevelSwitchBar');
if (!bar) {
bar = new Node('LevelSwitchBar');
bar.parent = parent;
bar.addComponent(LevelSwitchBar);
}
return bar.getComponent(LevelSwitchBar)!;
}
onLoad() {
this.buildUI();
}
private buildUI() {
const rootUi = this.node.getComponent(UITransform) || this.node.addComponent(UITransform);
rootUi.setContentSize(520, 48);
const hint = this.node.getChildByName('HintLabel') ?? new Node('HintLabel');
hint.parent = this.node;
const hintUi = hint.getComponent(UITransform) || hint.addComponent(UITransform);
hintUi.setContentSize(200, 36);
hint.setPosition(-160, 0, 0);
const hintLabel = hint.getComponent(Label) || hint.addComponent(Label);
hintLabel.string = `关卡 ${getMinLevelId()}${getMaxLevelId()}`;
hintLabel.fontSize = 18;
hintLabel.color = new Color(200, 220, 255);
let inputNode = this.node.getChildByName('LevelInput');
if (!inputNode) {
inputNode = new Node('LevelInput');
inputNode.parent = this.node;
}
inputNode.setPosition(-20, 0, 0);
const inputUi = inputNode.getComponent(UITransform) || inputNode.addComponent(UITransform);
inputUi.setContentSize(80, 36);
this.editBox = inputNode.getComponent(EditBox) || inputNode.addComponent(EditBox);
this.editBox.placeholder = '关卡号';
this.editBox.string = '1';
this.editBox.fontSize = 20;
this.editBox.textLabel = this.ensureEditLabel(inputNode);
this.editBox.placeholderLabel = this.ensurePlaceholderLabel(inputNode);
let btnNode = this.node.getChildByName('BtnSwitchLevel');
if (!btnNode) {
btnNode = new Node('BtnSwitchLevel');
btnNode.parent = this.node;
}
btnNode.setPosition(120, 0, 0);
const btnUi = btnNode.getComponent(UITransform) || btnNode.addComponent(UITransform);
btnUi.setContentSize(140, 40);
const btn = btnNode.getComponent(Button) || btnNode.addComponent(Button);
btn.transition = Button.Transition.SCALE;
const btnLabel = this.ensureButtonLabel(btnNode);
btnLabel.string = '切换关卡';
btnLabel.fontSize = 20;
btnLabel.color = new Color(255, 255, 255);
btn.node.off(Button.EventType.CLICK);
btn.node.on(Button.EventType.CLICK, this.onClickSwitch, this);
let prevNode = this.node.getChildByName('BtnPrev');
if (!prevNode) {
prevNode = new Node('BtnPrev');
prevNode.parent = this.node;
}
prevNode.setPosition(200, 0, 0);
prevNode.getComponent(UITransform) || prevNode.addComponent(UITransform).setContentSize(56, 36);
const prevBtn = prevNode.getComponent(Button) || prevNode.addComponent(Button);
const prevLbl = this.ensureButtonLabel(prevNode);
prevLbl.string = '◀';
prevLbl.fontSize = 22;
prevNode.off(Button.EventType.CLICK);
prevNode.on(Button.EventType.CLICK, () => GameManager.instance?.prevLevel(), this);
let nextNode = this.node.getChildByName('BtnNext');
if (!nextNode) {
nextNode = new Node('BtnNext');
nextNode.parent = this.node;
}
nextNode.setPosition(248, 0, 0);
nextNode.getComponent(UITransform) || nextNode.addComponent(UITransform).setContentSize(56, 36);
const nextBtn = nextNode.getComponent(Button) || nextNode.addComponent(Button);
const nextLbl = this.ensureButtonLabel(nextNode);
nextLbl.string = '▶';
nextLbl.fontSize = 22;
nextNode.off(Button.EventType.CLICK);
nextNode.on(Button.EventType.CLICK, () => GameManager.instance?.nextLevel(), this);
const widget = this.node.getComponent(Widget) || this.node.addComponent(Widget);
widget.isAlignTop = true;
widget.top = 12;
widget.isAlignHorizontalCenter = true;
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
}
syncFromManager() {
const gm = GameManager.instance;
if (!gm || !this.editBox) return;
this.editBox.string = gm.inputLevel || String(gm.curLevelID);
}
private onClickSwitch() {
const gm = GameManager.instance;
if (!gm) {
console.warn('[LevelSwitchBar] GameManager 未就绪');
return;
}
if (this.editBox) {
gm.inputLevel = this.editBox.string.trim();
}
gm.clickSwitchLevel();
this.syncFromManager();
}
private ensureButtonLabel(btnNode: Node): Label {
let t = btnNode.getChildByName('Label');
if (!t) {
t = new Node('Label');
t.parent = btnNode;
t.addComponent(UITransform).setContentSize(140, 40);
}
return t.getComponent(Label) || t.addComponent(Label);
}
private ensureEditLabel(parent: Node): Label {
let t = parent.getChildByName('TEXT_LABEL');
if (!t) {
t = new Node('TEXT_LABEL');
t.parent = parent;
t.addComponent(UITransform).setContentSize(80, 36);
}
return t.getComponent(Label) || t.addComponent(Label);
}
private ensurePlaceholderLabel(parent: Node): Label {
let t = parent.getChildByName('PLACEHOLDER_LABEL');
if (!t) {
t = new Node('PLACEHOLDER_LABEL');
t.parent = parent;
t.addComponent(UITransform).setContentSize(80, 36);
}
const lb = t.getComponent(Label) || t.addComponent(Label);
lb.color = new Color(160, 160, 160);
return lb;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "d0e55793-0083-4720-af9d-153fabbbc424",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,20 +1,502 @@
import { _decorator, Component } from 'cc';
import {
_decorator, Button, Component, director, find,
Label, Node, Sprite, SpriteFrame, UITransform, view, AudioSource, Color, Layers,
tween, Tween, Vec3, Widget,
} from 'cc';
import { EventManager, EventType } from '../core/EventManager';
import { GameManager } from '../manager/GameManager';
import { ViewController } from '../controller/ViewController';
import { LineGridRenderer } from '../gameplay/LineGridRenderer';
import { Movement } from '../gameplay/Movement';
import { GameAudio } from '../audio/GameAudio';
import { loadThemeCharacterPortrait, loadUIIcon, UIIconKey } from './UIStyleAssets';
import {
getThemeHudIconScale, getThemePortraitFlipX, getThemePortraitScale,
} from '../theme/ThemeRegistry';
import { DESIGN_HEIGHT, DESIGN_WIDTH } from '../core/GridConstants';
import { syncEmbeddedCamerasOrtho } from '../core/EmbeddedView';
import { spriteOriginalSize } from '../visual/EntityDisplayRefs';
const { ccclass } = _decorator;
/** 对应 Unity UIMainJS 可 SendMessage("UIMain", "SetText", ...) */
type IconSlot = { node: Node; sprite: Sprite };
type BtnLayout = { node: Node };
/** Unity UIMain/RightVerticalLayout spacing=20, padding right=20 top=20, 按钮 165×165 */
const UNITY_BTN = 165;
const UNITY_SPACING = 20;
const UNITY_PAD_RIGHT = 20;
const UNITY_PAD_TOP = 20;
/** Unity UIMain/ImageBall左上角角色肖像 194×194距左 40、距顶 28 */
const UNITY_PORTRAIT = 194;
const UNITY_PAD_LEFT = 40;
const UNITY_PAD_TOP_PORTRAIT = 28;
const REF_WIDTH = 2560;
const scaleUi = (v: number) => v * (DESIGN_WIDTH / REF_WIDTH);
const BTN_SIZE = Math.round(scaleUi(UNITY_BTN));
const BTN_SPACING = Math.round(scaleUi(UNITY_SPACING));
const PAD_RIGHT = Math.round(scaleUi(UNITY_PAD_RIGHT));
const PAD_TOP = Math.round(scaleUi(UNITY_PAD_TOP));
const PORTRAIT_SIZE = Math.round(scaleUi(UNITY_PORTRAIT));
const PAD_LEFT = Math.round(scaleUi(UNITY_PAD_LEFT));
const PAD_TOP_PORTRAIT = Math.round(scaleUi(UNITY_PAD_TOP_PORTRAIT));
/** 点击时短暂放大再回弹 */
const BTN_POP_SCALE = 1.14;
const BTN_POP_UP = 0.07;
const BTN_POP_DOWN = 0.13;
const HUD_LAYER = Layers.Enum.UI_3D;
/** 左上角肖像等比缩放系数(统一高度优先,与 Unity ImageBall 194 框一致) */
function portraitUniformScale(sf: SpriteFrame, scaleMul = 1): number {
const max = PORTRAIT_SIZE * scaleMul;
const { width: ow, height: oh } = spriteOriginalSize(sf);
return Math.min(max / ow, max / oh);
}
/** 对齐 Unity UIMain右侧 6 个圆形功能按钮HUD 相机固定,不随关卡缩放) */
@ccclass('UIMain')
export class UIMain extends Component {
private static readonly SPEEDS = [1, 2, 4];
private speedIndex = 0;
private audioMute = false;
private textVisible = false;
private textContent = '';
private nodePlaySpeed: IconSlot | null = null;
private nodeAudio: IconSlot | null = null;
private portraitSprite: Sprite | null = null;
private textLabel: Label | null = null;
private uiBuilt = false;
private readonly buttons: BtnLayout[] = [];
/** 忽略过期的异步贴图回调,避免重置关卡时 HUD 闪一下 */
private hudStyleGen = 0;
static ensure(parent: Node): UIMain {
parent.getChildByName('UIMain')?.destroy();
const root = new Node('UIMain');
root.parent = parent;
root.layer = HUD_LAYER;
return root.addComponent(UIMain);
}
static findInstance(): UIMain | null {
const scene = director.getScene();
if (!scene) return null;
for (const rootName of ['UIOverlay', 'GameRoot', 'Canvas']) {
const ui = scene.getChildByName(rootName)?.getChildByName('UIMain')?.getComponent(UIMain);
if (ui) return ui;
}
return null;
}
setMuted(mute: boolean) {
if (this.audioMute === mute) return;
this.audioMute = mute;
this.applyAudioVolume();
void this.setAudioIcon(this.resolveHudTheme());
}
onLoad() {
const speed = UIMain.SPEEDS[this.speedIndex];
GameManager.instance?.setGameSpeed(speed);
Movement.setSpeedMultiplier(speed);
this.buildUI();
EventManager.register(EventType.LevelInit, this.onLevelInit);
}
onDestroy() {
view.off('canvas-resize', this.layoutPanel, this);
EventManager.remove(EventType.LevelInit, this.onLevelInit);
}
SetText(str: string) { this.setText(str); }
SetTextActive(active: string) { this.setTextActive(active); }
setText(str: string) {
this.textContent = str;
console.log('[UIMain]', str);
if (this.textLabel) {
this.textLabel.string = str;
if (this.textLabel.node.parent) {
this.textLabel.node.parent.active = this.textVisible && str.length > 0;
}
}
}
setTextActive(active: string) {
this.textVisible = active === 'true';
if (this.textLabel?.node.parent) {
this.textLabel.node.parent.active = this.textVisible && (this.textLabel.string?.length ?? 0) > 0;
}
}
refreshStyle(uiStyle?: string) {
const style = uiStyle ?? this.resolveHudTheme();
const gen = ++this.hudStyleGen;
void this.setPlaySpeedSprite(style, gen);
void this.setAudioIcon(style, gen);
const keys: UIIconKey[] = ['navigation', 'revert', 'zoomIn', 'zoomOut'];
const names = ['NodeNavigation', 'NodeRevert', 'NodeZoomIn', 'NodeZoomOut'];
names.forEach((name, i) => {
const slot = this.node.getChildByName(name)?.getChildByName('Icon')?.getComponent(Sprite);
if (!slot) return;
void loadUIIcon(style, keys[i]).then((sf) => {
if (gen !== this.hudStyleGen || !sf || !slot.isValid) return;
slot.spriteFrame = sf;
});
});
this.applyHudIconScale(style);
void this.refreshThemePortrait(style, gen);
}
/** Unity redArmy 等主题的按钮图标缩放 */
private applyHudIconScale(themeId?: string) {
const { x, y } = getThemeHudIconScale(themeId);
for (const { node } of this.buttons) {
const icon = node.getChildByName('Icon');
if (icon?.isValid) icon.setScale(x, y, 1);
}
}
private buildUI() {
if (this.uiBuilt) {
this.layoutPanel();
return;
}
this.uiBuilt = true;
const rootUi = this.node.getComponent(UITransform) ?? this.node.addComponent(UITransform);
rootUi.setAnchorPoint(1, 1);
this.setupButtonColumnWidget();
const specs: { name: string; onClick: () => void }[] = [
{ name: 'NodeNavigation', onClick: () => this.onToggleLineGrid() },
{ name: 'NodeRevert', onClick: () => this.onRevertGame() },
{ name: 'NodePlaySpeed', onClick: () => this.onPlaySpeedChange() },
{ name: 'NodeZoomIn', onClick: () => this.onZoomIn() },
{ name: 'NodeZoomOut', onClick: () => this.onZoomOut() },
{ name: 'NodeAudio', onClick: () => this.onAudioMute() },
];
this.buttons.length = 0;
for (const spec of specs) {
const slot = this.ensureIconButton(spec.name, spec.onClick);
if (spec.name === 'NodePlaySpeed') this.nodePlaySpeed = slot;
if (spec.name === 'NodeAudio') this.nodeAudio = slot;
}
this.buildTextArea();
this.buildThemePortrait();
this.node.setSiblingIndex(this.node.parent!.children.length - 1);
this.layoutPanel();
view.on('canvas-resize', this.layoutPanel, this);
this.refreshStyle(this.resolveHudTheme());
}
/** HUD 贴图始终跟当前关卡主题,避免倍速/音量误用 uiStyle 默认 silu */
private resolveHudTheme(): string | undefined {
return GameManager.instance?.getCurLevel()?.theme ?? GameManager.instance?.uiStyle;
}
private syncOverlaySize() {
const vis = view.getVisibleSize();
const overlay = this.node.parent;
const ui = overlay?.getComponent(UITransform);
if (!ui) return;
if (ui.contentSize.width !== vis.width || ui.contentSize.height !== vis.height) {
ui.setContentSize(vis.width, vis.height);
}
this.ensureOverlayWidget(overlay);
}
private ensureOverlayWidget(overlay: Node) {
const widget = overlay.getComponent(Widget) ?? overlay.addComponent(Widget);
widget.isAlignTop = true;
widget.isAlignBottom = true;
widget.isAlignLeft = true;
widget.isAlignRight = true;
widget.top = 0;
widget.bottom = 0;
widget.left = 0;
widget.right = 0;
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
widget.updateAlignment();
}
private setupButtonColumnWidget() {
const widget = this.node.getComponent(Widget) ?? this.node.addComponent(Widget);
widget.isAlignRight = true;
widget.isAlignTop = true;
widget.right = PAD_RIGHT;
widget.top = PAD_TOP;
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
}
private setupPortraitWidget(portrait: Node) {
const widget = portrait.getComponent(Widget) ?? portrait.addComponent(Widget);
widget.isAlignTop = true;
widget.isAlignLeft = true;
widget.top = PAD_TOP_PORTRAIT;
widget.left = PAD_LEFT;
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
}
private layoutPanel = () => {
syncEmbeddedCamerasOrtho();
this.syncOverlaySize();
const count = this.buttons.length;
const totalHeight = count > 0
? count * BTN_SIZE + (count - 1) * BTN_SPACING
: 0;
const rootUi = this.node.getComponent(UITransform)!;
rootUi.setContentSize(BTN_SIZE, totalHeight);
const btnX = -BTN_SIZE * 0.5;
for (let i = 0; i < count; i++) {
const y = -BTN_SIZE * 0.5 - i * (BTN_SIZE + BTN_SPACING);
this.buttons[i].node.setPosition(btnX, y, 0);
}
this.node.getComponent(Widget)?.updateAlignment();
const h = view.getVisibleSize().height || DESIGN_HEIGHT;
const texts = this.node.parent?.getChildByName('NodeTexts');
if (texts) texts.setPosition(0, -h * 0.5 + 36, 0);
const portrait = this.portraitSprite?.node.parent
?? this.node.parent?.getChildByName('ImageBall');
portrait?.getComponent(Widget)?.updateAlignment();
};
private ensureIconButton(name: string, onClick: () => void): IconSlot {
let btnNode = this.node.getChildByName(name);
if (!btnNode) {
btnNode = new Node(name);
btnNode.parent = this.node;
}
btnNode.layer = HUD_LAYER;
btnNode.active = true;
const ui = btnNode.getComponent(UITransform) ?? btnNode.addComponent(UITransform);
ui.setContentSize(BTN_SIZE, BTN_SIZE);
ui.setAnchorPoint(0.5, 0.5);
let iconNode = btnNode.getChildByName('Icon');
if (!iconNode) {
iconNode = new Node('Icon');
iconNode.parent = btnNode;
}
iconNode.layer = HUD_LAYER;
const iconUi = iconNode.getComponent(UITransform) ?? iconNode.addComponent(UITransform);
iconUi.setContentSize(BTN_SIZE, BTN_SIZE);
const sprite = iconNode.getComponent(Sprite) ?? iconNode.addComponent(Sprite);
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const btn = btnNode.getComponent(Button) ?? btnNode.addComponent(Button);
btn.transition = Button.Transition.NONE;
btn.target = iconNode;
btnNode.off(Button.EventType.CLICK);
btnNode.on(Button.EventType.CLICK, () => {
GameAudio.resumeAll();
this.playClickPop(btnNode);
onClick();
}, this);
this.buttons.push({ node: btnNode });
return { node: btnNode, sprite };
}
/** 点击反馈:只缩放 Icon避免整颗按钮放大遮挡下方倍速键 */
private playClickPop(target: Node) {
if (!target.isValid) return;
const popTarget = target.getChildByName('Icon') ?? target;
const { x, y } = getThemeHudIconScale(this.resolveHudTheme());
const base = new Vec3(x, y, 1);
Tween.stopAllByTarget(popTarget);
popTarget.setScale(base);
const pop = new Vec3(x * BTN_POP_SCALE, y * BTN_POP_SCALE, 1);
tween(popTarget)
.to(BTN_POP_UP, { scale: pop }, { easing: 'quadOut' })
.to(BTN_POP_DOWN, { scale: base }, { easing: 'backOut' })
.start();
}
/** 对齐 Unity UIMain/ImageBall关卡主题角色待机贴图 */
private buildThemePortrait() {
const overlay = this.node.parent;
if (!overlay) return;
let portrait = overlay.getChildByName('ImageBall');
if (!portrait) {
portrait = new Node('ImageBall');
portrait.parent = overlay;
}
portrait.layer = HUD_LAYER;
portrait.active = true;
portrait.getComponent(Sprite)?.destroy();
const ui = portrait.getComponent(UITransform) ?? portrait.addComponent(UITransform);
ui.setAnchorPoint(0, 1);
let icon = portrait.getChildByName('Portrait');
if (!icon) {
icon = new Node('Portrait');
icon.parent = portrait;
}
icon.layer = HUD_LAYER;
const iconUi = icon.getComponent(UITransform) ?? icon.addComponent(UITransform);
iconUi.setAnchorPoint(0, 1);
const sprite = icon.getComponent(Sprite) ?? icon.addComponent(Sprite);
sprite.sizeMode = Sprite.SizeMode.TRIMMED;
this.portraitSprite = sprite;
this.setupPortraitWidget(portrait);
}
private applyPortraitSprite(sf: SpriteFrame, themeId?: string) {
if (!this.portraitSprite?.isValid) return;
const icon = this.portraitSprite.node;
const container = icon.parent;
if (!container?.isValid) return;
const theme = themeId ?? this.resolveHudTheme();
const s = portraitUniformScale(sf, getThemePortraitScale(theme));
const { width: ow, height: oh } = spriteOriginalSize(sf);
const flipX = getThemePortraitFlipX(theme);
const dispW = Math.round(ow * s);
const dispH = Math.round(oh * s);
const iconUi = icon.getComponent(UITransform) ?? icon.addComponent(UITransform);
this.portraitSprite.spriteFrame = sf;
if (flipX) {
// 绕右缘翻转,避免 scaleX<0 时贴图画到屏幕左侧外
iconUi.setAnchorPoint(1, 1);
icon.setPosition(dispW, 0, 0);
icon.setScale(-s, s, 1);
} else {
iconUi.setAnchorPoint(0, 1);
icon.setPosition(0, 0, 0);
icon.setScale(s, s, 1);
}
const containerUi = container.getComponent(UITransform) ?? container.addComponent(UITransform);
containerUi.setAnchorPoint(0, 1);
containerUi.setContentSize(dispW, dispH);
container.getComponent(Widget)?.updateAlignment();
}
private async refreshThemePortrait(themeId?: string, gen = this.hudStyleGen) {
if (!this.portraitSprite) return;
const gm = GameManager.instance;
const theme = themeId ?? gm?.getCurLevel()?.theme ?? gm?.uiStyle;
const options = gm?.getEntityVisualOptions() ?? { theme };
const sf = await loadThemeCharacterPortrait({ ...options, theme: options.theme ?? theme });
if (gen !== this.hudStyleGen || !sf || !this.portraitSprite?.isValid) return;
this.applyPortraitSprite(sf, theme);
}
private buildTextArea() {
let texts = this.node.parent?.getChildByName('NodeTexts');
if (!texts) {
texts = new Node('NodeTexts');
texts.parent = this.node.parent!;
texts.layer = HUD_LAYER;
texts.addComponent(UITransform).setContentSize(400, 48);
texts.active = false;
const labelNode = new Node('Text1');
labelNode.parent = texts;
labelNode.addComponent(UITransform).setContentSize(400, 48);
this.textLabel = labelNode.addComponent(Label);
this.textLabel.fontSize = 22;
this.textLabel.color = new Color(255, 240, 200);
this.textLabel.horizontalAlign = Label.HorizontalAlign.CENTER;
} else {
this.textLabel = texts.getChildByName('Text1')?.getComponent(Label) ?? null;
}
}
private onToggleLineGrid() {
const entrance = this.resolveLevelEntrance();
if (!entrance) {
console.warn('[UIMain] 未找到 MainLevelEntrance无法切换网格');
return;
}
const grid = LineGridRenderer.instance?.isValid
? LineGridRenderer.instance
: LineGridRenderer.ensure(entrance);
grid.toggleGridVisibility();
console.log(`[UIMain] 导航网格 ${grid.isGridVisible() ? '显示' : '隐藏'}`);
}
private resolveLevelEntrance(): Node | null {
const gm = GameManager.instance;
if (gm?.mainLevelEntrance?.isValid) return gm.mainLevelEntrance;
const scene = director.getScene();
if (!scene) return null;
return find('MainLevelEntrance', scene)
?? find('GameRoot/MainLevelEntrance', scene);
}
private onRevertGame() {
this.speedIndex = 0;
GameManager.instance?.setGameSpeed(UIMain.SPEEDS[this.speedIndex]);
GameManager.instance?.resetLevel();
void this.setPlaySpeedSprite(this.resolveHudTheme());
}
private onPlaySpeedChange() {
this.speedIndex = (this.speedIndex + 1) % UIMain.SPEEDS.length;
const speed = UIMain.SPEEDS[this.speedIndex];
GameManager.instance?.setGameSpeed(speed);
console.log(`[UIMain] 倍速 x${speed}`);
void this.setPlaySpeedSprite(this.resolveHudTheme());
}
private onZoomIn() {
ViewController.instance?.zoomIn();
}
private onZoomOut() {
ViewController.instance?.zoomOut();
}
private onAudioMute() {
this.audioMute = !this.audioMute;
this.applyAudioVolume();
void this.setAudioIcon(this.resolveHudTheme());
}
private async setPlaySpeedSprite(uiStyle?: string, gen = this.hudStyleGen) {
const keys: UIIconKey[] = ['speed1', 'speed2', 'speed4'];
const icon = keys[this.speedIndex];
const sf = await loadUIIcon(uiStyle ?? this.resolveHudTheme(), icon);
if (gen !== this.hudStyleGen || !sf || !this.nodePlaySpeed) return;
this.nodePlaySpeed.sprite.spriteFrame = sf;
}
private async setAudioIcon(uiStyle?: string, gen = this.hudStyleGen) {
const icon: UIIconKey = this.audioMute ? 'audioOff' : 'audioOn';
const sf = await loadUIIcon(uiStyle ?? this.resolveHudTheme(), icon);
if (gen !== this.hudStyleGen || !sf || !this.nodeAudio) return;
this.nodeAudio.sprite.spriteFrame = sf;
}
private applyAudioVolume() {
const vol = this.audioMute ? 0 : 1;
const scene = director.getScene();
if (!scene) return;
for (const src of scene.getComponentsInChildren(AudioSource)) {
src.volume = vol;
}
}
private onLevelInit = () => {
this.setText('');
this.applyAudioVolume();
};
}

View File

@@ -0,0 +1,90 @@
import { ImageAsset, resources, SpriteFrame, Texture2D } from 'cc';
import { Direction } from '../core/Define';
import { ensureSpriteFrameSize } from '../visual/EntityDisplayRefs';
import { ensureResourcesBundle } from '../core/ResourcesBundle';
import { resolvePlayerTexturePaths, EntityVisualOptions } from '../visual/EntityTextureResolver';
import { VisualAssets } from '../visual/VisualAssets';
import {
getThemeHudIconCandidates,
getThemePortraitPath,
ThemeHudIconKey,
} from '../theme/ThemeRegistry';
export type UIIconKey = ThemeHudIconKey;
const frameCache = new Map<string, SpriteFrame>();
function loadSpriteAt(path: string): Promise<SpriteFrame | null> {
const cached = frameCache.get(path);
if (cached) return Promise.resolve(cached);
return ensureResourcesBundle().then(() => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
frameCache.set(path, sf);
resolve(sf);
return;
}
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
frameCache.set(path, sf2);
resolve(sf2);
return;
}
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
frameCache.set(path, frame);
resolve(frame);
return;
}
resolve(null);
});
});
});
}));
}
export function getUIIconPath(uiStyle: string | undefined, icon: UIIconKey): string {
const paths = getThemeHudIconCandidates(uiStyle, icon);
if (!paths.length) throw new Error(`[UIStyleAssets] 缺少图标 ${icon}`);
return paths[0];
}
export async function loadUIIcon(
uiStyle: string | undefined,
icon: UIIconKey,
): Promise<SpriteFrame | null> {
for (const path of getThemeHudIconCandidates(uiStyle, icon)) {
const sf = await loadSpriteAt(path);
if (sf) return sf;
}
console.warn(`[UIStyleAssets] 贴图加载失败: ${icon} (${uiStyle ?? 'silu'})`);
return null;
}
export function clearUIIconCache() {
frameCache.clear();
}
/** 地图左上角角色肖像(优先 entities.portrait否则 playerFront */
export async function loadThemeCharacterPortrait(
options: EntityVisualOptions = {},
): Promise<SpriteFrame | null> {
const theme = options.theme;
const portrait = getThemePortraitPath(theme);
if (portrait) {
const sf = await VisualAssets.loadTexturePath(portrait);
if (sf) return sf;
}
for (const path of resolvePlayerTexturePaths(Direction.South, options)) {
const sf = await VisualAssets.loadTexturePath(path);
if (sf) return sf;
}
console.warn(`[UIStyleAssets] 角色肖像加载失败 (${theme ?? 'silu'})`);
return null;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "bd61a294-d993-404c-8818-cd71d51557eb",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,157 @@
import { Size, SpriteFrame } from 'cc';
import { CELL_PIXEL } from '../core/GridConstants';
import { getThemeEntityDisplayCellBoxes } from '../theme/ThemeDatabase';
/** 内置默认themes-database 未加载时使用) */
export const DEFAULT_ENTITY_CELL_BOX = {
player: { w: 0.68, h: 0.9 },
vehicle: { w: 0.96, h: 0.88 },
prop: { w: 0.52, h: 0.69 },
propGround: { w: 0.52, h: 0.69 },
} as const;
/** 装饰物相对可拾取物的缩放比 */
const PROP_DECOR_RATIO = 0.45 / 0.69;
const cellW = (ratio: number) => CELL_PIXEL * ratio;
const cellH = (ratio: number) => CELL_PIXEL * ratio;
export function getEntityCellBox(theme?: string) {
if (theme) return getThemeEntityDisplayCellBoxes(theme);
return { ...DEFAULT_ENTITY_CELL_BOX };
}
export const DEFAULT_PLAYER_ANCHOR_Y = 0.35;
/** Unity silu ship pivot y = 0.45 */
export const DEFAULT_VEHICLE_ANCHOR_Y = 0.45;
export function getEntityDisplaySizes(theme?: string) {
const box = getEntityCellBox(theme);
return {
player: {
width: cellW(box.player.w),
height: cellH(box.player.h),
},
vehicle: {
width: cellW(box.vehicle.w),
height: cellH(box.vehicle.h),
},
prop: {
width: cellW(box.prop.w),
height: cellH(box.prop.h),
},
propGround: {
width: cellW(box.propGround.w),
height: cellH(box.propGround.h),
},
prop_decor: {
width: cellW(box.prop.w) * PROP_DECOR_RATIO,
height: cellH(box.prop.h) * PROP_DECOR_RATIO,
},
};
}
/** @deprecated 使用 getEntityDisplaySizes(theme) */
export const ENTITY_CELL_BOX = DEFAULT_ENTITY_CELL_BOX;
/** @deprecated 使用 getEntityDisplaySizes(theme) */
export const SANXING_ENTITY_DISPLAY = {
get player() { return getEntityDisplaySizes().player; },
get vehicle() { return getEntityDisplaySizes().vehicle; },
get prop() { return getEntityDisplaySizes().prop; },
get prop_decor() { return getEntityDisplaySizes().prop_decor; },
};
export type EntityDisplayKind = 'player' | 'vehicle' | 'prop' | 'propGround' | 'prop_decor';
type SpawnKindLike = 'player' | 'vehicle' | 'prop' | 'prop_decor';
export function entityPlaceholderSize(kind: SpawnKindLike, theme?: string): { width: number; height: number } {
const sizes = getEntityDisplaySizes(theme);
if (kind === 'prop_decor') return sizes.prop_decor;
if (kind === 'prop') return sizes.prop;
if (kind === 'vehicle') return sizes.vehicle;
return sizes.player;
}
export function spriteOriginalSize(sf: SpriteFrame): { width: number; height: number } {
const rect = sf.rect;
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height };
}
let w = sf.originalSize?.width ?? 0;
let h = sf.originalSize?.height ?? 0;
if (w <= 0 || h <= 0) {
w = sf.texture?.width ?? 1;
h = sf.texture?.height ?? 1;
}
return { width: Math.max(1, w), height: Math.max(1, h) };
}
/** 等比缩放完整落入主题包围盒contain */
export function fitEntityDisplaySize(
kind: EntityDisplayKind,
sf: SpriteFrame,
scaleMul = 1,
theme?: string,
): { width: number; height: number; anchorX: number; anchorY: number } {
const s = computeEntityUniformScale(kind, sf, scaleMul, theme);
return sizeFromUniformScale(sf, s, kind, theme);
}
/** 单帧 contain 缩放系数(序列动画各帧共用,避免画布尺寸不同导致比例跳变) */
export function computeEntityUniformScale(
kind: EntityDisplayKind,
sf: SpriteFrame,
scaleMul = 1,
theme?: string,
): number {
const mul = Math.max(0.1, Math.min(4, scaleMul));
const { width: ow, height: oh } = spriteOriginalSize(sf);
const ref = getEntityDisplaySizes(theme)[kind];
return Math.min(ref.width * mul / ow, ref.height * mul / oh);
}
export function sizeFromUniformScale(
sf: SpriteFrame,
uniformScale: number,
kind: EntityDisplayKind = 'player',
theme?: string,
): { width: number; height: number; anchorX: number; anchorY: number } {
const { width: ow, height: oh } = spriteOriginalSize(sf);
let anchorY = 0;
if (kind === 'player') {
anchorY = DEFAULT_PLAYER_ANCHOR_Y;
} else if (kind === 'vehicle') {
anchorY = DEFAULT_VEHICLE_ANCHOR_Y;
} else if (kind !== 'prop' && kind !== 'propGround' && kind !== 'prop_decor') {
anchorY = DEFAULT_PLAYER_ANCHOR_Y;
}
return {
width: ow * uniformScale,
height: oh * uniformScale,
anchorX: 0.5,
anchorY,
};
}
/**
* 序列帧脚点对齐:各帧保留原始比例,仅调 anchorY。
* 脚离画布底边像素一致时anchorY = baseAnchorY * refOh / oh
*/
export function playerSeqFrameAnchorY(
sf: SpriteFrame,
refSf: SpriteFrame,
baseAnchorY = DEFAULT_PLAYER_ANCHOR_Y,
): number {
const oh = Math.max(1, spriteOriginalSize(sf).height);
const refOh = Math.max(1, spriteOriginalSize(refSf).height);
if (oh === refOh) return baseAnchorY;
return baseAnchorY * refOh / oh;
}
/** 动态 SpriteFrame 补全 originalSizeImageAsset 直载时 Cocos 默认为 0 */
export function ensureSpriteFrameSize(sf: SpriteFrame, width: number, height: number) {
if (sf.originalSize.width > 0 && sf.originalSize.height > 0) return;
sf.originalSize = new Size(Math.max(1, width), Math.max(1, height));
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c5ea903f-83fc-42e9-8721-5540fc38c79f",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,318 @@
import { Direction } from '../core/Define';
import { LevelConfig, LevelEntityTextures, SpawnConfig, SpawnKind } from '../level/LevelTypes';
import { resolvePropPlacement } from '../level/EntitySpawnPlacement';
import type { PropPlacement } from '../level/EntitySpawnPlacement';
import { getThemeBackground, getThemeEntities, getThemeTextureFolder, isThemeDatabaseReady } from '../theme/ThemeRegistry';
import {
entityTextureCandidates,
vehicleSpriteRoleForDirection,
vehicleThemeFieldForDirection,
allVehicleDirectionTextureCandidates,
type EntitySpriteRole,
} from './ThemeEntityTextures';
const THEME_TEXTURE_FOLDER: Record<string, string> = {
default: 'default',
silu: 'silu',
SILU: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
};
function resolveThemeFolder(theme: string | undefined): string {
if (!theme) return 'silu';
const trimmed = String(theme).trim();
if (isThemeDatabaseReady()) return getThemeTextureFolder(trimmed);
return THEME_TEXTURE_FOLDER[trimmed] ?? THEME_TEXTURE_FOLDER[trimmed.toLowerCase()] ?? trimmed;
}
export interface EntityVisualOptions {
theme?: string;
entityTextures?: LevelEntityTextures;
/** 单个 spawn 的贴图覆盖(可拾取物常用) */
spawnTexture?: string;
/** 可拾取物放置高度Unity Prop / nProp */
propPlacement?: PropPlacement;
}
/** 统一为 resources 相对路径,无 .png 后缀 */
export function normalizeTexturePath(raw: string | undefined): string | undefined {
if (!raw) return undefined;
let p = String(raw).trim().replace(/\\/g, '/');
if (!p) return undefined;
if (p.startsWith('assets/resources/')) p = p.slice('assets/resources/'.length);
if (p.startsWith('resources/')) p = p.slice('resources/'.length);
if (p.startsWith('/')) p = p.slice(1);
if (p.endsWith('.png')) p = p.slice(0, -4);
return p;
}
function themePropCandidates(theme: string | undefined): string[] {
const folder = resolveThemeFolder(theme);
return [
`textures/${folder}/Prop_kuai1`,
'textures/silu/Prop_kuai1',
];
}
function themeGroundPropCandidates(theme: string | undefined): string[] {
const folder = resolveThemeFolder(theme);
return [
`textures/${folder}/nProp_kuai1`,
'textures/silu/nProp_kuai1',
];
}
/** Prop_kuai2 → nProp_kuai2Prop → nProp */
export function toGroundPropTexturePath(blockPath: string | undefined): string | undefined {
const norm = normalizeTexturePath(blockPath);
if (!norm) return undefined;
const slash = norm.lastIndexOf('/');
const dir = slash >= 0 ? norm.slice(0, slash + 1) : '';
const file = slash >= 0 ? norm.slice(slash + 1) : norm;
if (file.startsWith('Prop_')) return `${dir}n${file}`;
if (file === 'Prop') return `${dir}nProp`;
if (file.startsWith('nProp')) return norm;
return norm.replace(/\/Prop([^/]*)$/, '/nProp$1');
}
function themeDbEntityPath(
theme: string | undefined,
field: keyof NonNullable<ReturnType<typeof getThemeEntities>>,
): string | undefined {
const ent = getThemeEntities(theme);
if (!ent) return undefined;
return normalizeTexturePath(ent[field]);
}
function themeDbEntityCandidates(
theme: string | undefined,
field: keyof NonNullable<ReturnType<typeof getThemeEntities>>,
role: EntitySpriteRole,
): string[] {
const db = themeDbEntityPath(theme, field);
const legacy = entityTextureCandidates(theme, role);
return withFallback(db, legacy);
}
function levelVehicleTextureOverride(
direction: Direction,
entityTextures: LevelEntityTextures | undefined,
): string | undefined {
if (!entityTextures) return undefined;
const field = vehicleThemeFieldForDirection(direction);
const direct = normalizeTexturePath(entityTextures[field]);
if (direct) return direct;
const front = isEntityFront(direction);
return normalizeTexturePath(
front ? entityTextures.vehicleFront : entityTextures.vehicleBack,
);
}
function withFallback(primary: string | undefined, fallbacks: string[]): string[] {
const out: string[] = [];
const norm = normalizeTexturePath(primary);
if (norm) out.push(norm);
for (const p of fallbacks) {
if (!out.includes(p)) out.push(p);
}
return out;
}
function themePropCandidatesWithDb(theme: string | undefined): string[] {
const db = themeDbEntityPath(theme, 'prop');
if (db) return [db];
return themePropCandidates(theme);
}
function themeGroundPropCandidatesWithDb(
theme: string | undefined,
blockPropPath?: string,
): string[] {
const dbGround = themeDbEntityPath(theme, 'propGround');
if (dbGround) return [dbGround];
const derived = toGroundPropTexturePath(blockPropPath ?? themeDbEntityPath(theme, 'prop'));
if (derived) return [derived];
return themeGroundPropCandidates(theme);
}
export function isEntityFront(direction: Direction | undefined): boolean {
return direction === Direction.South || direction === Direction.East;
}
export function entityFlipX(direction: Direction | undefined): boolean {
return direction === Direction.West || direction === Direction.East;
}
/** @deprecated 载具已改用四向贴图;玩家仍用 IsFront + flipX */
export function resolveVehicleOrientation(direction: Direction | undefined): {
front: boolean;
flipX: boolean;
} {
const dir = direction ?? Direction.North;
return {
front: isEntityFront(dir),
flipX: entityFlipX(dir),
};
}
export function resolvePlayerTexturePaths(
direction: Direction | undefined,
options: EntityVisualOptions = {},
): string[] {
const front = isEntityFront(direction);
const custom = front
? normalizeTexturePath(options.entityTextures?.playerFront)
: normalizeTexturePath(options.entityTextures?.playerBack);
const spawnCustom = normalizeTexturePath(options.spawnTexture);
const themePaths = themeDbEntityCandidates(
options.theme,
front ? 'playerFront' : 'playerBack',
front ? 'playerFront' : 'playerBack',
);
if (spawnCustom) return withFallback(spawnCustom, themePaths);
if (custom) return withFallback(custom, themePaths);
return themePaths;
}
/** 载具四向贴图路径(北/东/南/西各一张,转向时直接切换,无 flipX */
export function resolveVehicleTexturePaths(
direction: Direction | undefined,
options: EntityVisualOptions = {},
): string[] {
const dir = direction ?? Direction.North;
const field = vehicleThemeFieldForDirection(dir);
const role = vehicleSpriteRoleForDirection(dir);
const custom = levelVehicleTextureOverride(dir, options.entityTextures);
const spawnCustom = normalizeTexturePath(options.spawnTexture);
const themePaths = themeDbEntityCandidates(options.theme, field, role);
if (spawnCustom) return withFallback(spawnCustom, themePaths);
if (custom) return withFallback(custom, themePaths);
return themePaths;
}
/** 预加载主题载具四向贴图 */
export function collectThemeVehicleTexturePaths(
theme: string | undefined,
entityTextures?: LevelEntityTextures,
): string[] {
const set = new Set<string>();
const add = (p: string | undefined) => {
const n = normalizeTexturePath(p);
if (n) set.add(n);
};
for (const p of allVehicleDirectionTextureCandidates(theme)) add(p);
if (entityTextures) {
for (let d = Direction.North; d <= Direction.West; d++) {
add(levelVehicleTextureOverride(d, entityTextures));
}
}
return Array.from(set);
}
export function resolvePropTexturePaths(options: EntityVisualOptions = {}): string[] {
const spawnCustom = normalizeTexturePath(options.spawnTexture);
const levelBlock = normalizeTexturePath(options.entityTextures?.prop);
const levelGround = normalizeTexturePath(options.entityTextures?.propGround);
const onGround = options.propPlacement === 'ground';
if (spawnCustom) {
const custom = onGround ? toGroundPropTexturePath(spawnCustom) ?? spawnCustom : spawnCustom;
const fallback = onGround
? themeGroundPropCandidatesWithDb(options.theme, levelBlock)
: themePropCandidatesWithDb(options.theme);
return withFallback(custom, fallback);
}
if (onGround && levelGround) {
return withFallback(levelGround, themeGroundPropCandidatesWithDb(options.theme, levelBlock));
}
if (levelBlock) {
const custom = onGround ? toGroundPropTexturePath(levelBlock) ?? levelBlock : levelBlock;
return withFallback(custom, onGround
? themeGroundPropCandidatesWithDb(options.theme, levelBlock)
: themePropCandidatesWithDb(options.theme));
}
return onGround
? themeGroundPropCandidatesWithDb(options.theme)
: themePropCandidatesWithDb(options.theme);
}
export function resolveEntityTexturePaths(
kind: SpawnKind,
direction: Direction | undefined,
options: EntityVisualOptions = {},
): string[] {
if (kind === 'player') return resolvePlayerTexturePaths(direction, options);
if (kind === 'vehicle') return resolveVehicleTexturePaths(direction, options);
if (kind === 'prop' || kind === 'prop_decor') return resolvePropTexturePaths(options);
return themePropCandidates(options.theme);
}
/** 预加载关卡用到的实体贴图(仅主路径,不扫全量 fallback */
export function collectLevelEntityTexturePaths(
theme: string | undefined,
entityTextures?: LevelEntityTextures,
spawns?: SpawnConfig[],
levelConfig?: Pick<LevelConfig, 'ground' | 'border'>,
): string[] {
const set = new Set<string>();
const add = (p: string | undefined) => {
const n = normalizeTexturePath(p);
if (n) set.add(n);
};
const addPrimary = (paths: string[]) => {
for (const p of paths) add(p);
};
add(getThemeBackground(theme));
const visualBase: EntityVisualOptions = { theme, entityTextures };
const list = spawns ?? [];
for (const s of list) {
if (s.kind === 'player') {
addPrimary(resolvePlayerTexturePaths(
parseSpawnDirection(s.playerDirection) ?? Direction.South,
{ ...visualBase, spawnTexture: s.texture },
));
} else if (s.kind === 'vehicle') {
for (let d = Direction.North; d <= Direction.West; d++) {
addPrimary(resolveVehicleTexturePaths(d, { ...visualBase, spawnTexture: s.texture }));
}
} else if (s.kind === 'prop') {
const placement: PropPlacement = levelConfig
? resolvePropPlacement(s, levelConfig as LevelConfig)
: (s.propPlacement ?? 'block');
addPrimary(resolvePropTexturePaths({
...visualBase,
spawnTexture: s.texture,
propPlacement: placement,
}));
} else if (s.kind === 'prop_decor') {
addPrimary(resolvePropTexturePaths(visualBase));
}
}
if (!list.length) {
addPrimary(resolvePlayerTexturePaths(Direction.South, visualBase));
for (const p of collectThemeVehicleTexturePaths(theme, entityTextures)) add(p);
addPrimary(resolvePropTexturePaths(visualBase));
}
return Array.from(set);
}
function parseSpawnDirection(raw: unknown): Direction | undefined {
if (typeof raw === 'number') return raw as Direction;
if (typeof raw !== 'string') return undefined;
const name = raw.replace(/^Direction\./, '');
const key = name as keyof typeof Direction;
if (Object.prototype.hasOwnProperty.call(Direction, key)) {
const v = Direction[key];
if (typeof v === 'number') return v as Direction;
}
return undefined;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f794ac4d-27dd-4b75-ac42-dabe97cdb679",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,335 @@
import {
_decorator, Asset, Component, ImageAsset, Node, resources, Sprite, SpriteFrame, Texture2D, UITransform,
} from 'cc';
import { Direction } from '../core/Define';
import { EntityVisualOptions, entityFlipX } from './EntityTextureResolver';
import {
computeEntityUniformScale, ensureSpriteFrameSize, playerSeqFrameAnchorY,
sizeFromUniformScale, spriteOriginalSize,
} from './EntityDisplayRefs';
import { VisualAssets } from './VisualAssets';
import { GameManager } from '../manager/GameManager';
import { Movement } from '../gameplay/Movement';
import { resolveThemeId } from '../theme/ThemeDatabase';
import {
PlayerAction, PlayerAnimPaths, actionFolder, resolvePlayerAnimPaths,
} from './PlayerAnimPaths';
const { ccclass } = _decorator;
const FRAME_INTERVAL: Record<PlayerAction, number> = {
[PlayerAction.Idle]: 0.22,
[PlayerAction.Move]: 0.12,
[PlayerAction.Jump]: 0.1,
[PlayerAction.Win]: 0.14,
[PlayerAction.Fail]: 0,
};
const seqCache = new Map<string, SpriteFrame[]>();
async function loadSpriteFrame(path: string): Promise<SpriteFrame | null> {
const loadSf = (p: string) => new Promise<SpriteFrame | null>((resolve) => {
resources.load(p, SpriteFrame, (err, sf) => resolve(!err && sf ? sf : null));
});
const loadImg = (p: string) => new Promise<SpriteFrame | null>((resolve) => {
resources.load(p, ImageAsset, (err, img) => {
if (err || !img) {
resolve(null);
return;
}
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
});
});
return (await loadSf(`${path}/spriteFrame`))
?? (await loadSf(path))
?? (await loadImg(path));
}
/** 从文件名推断序列帧顺序(兼容 1.png / 走1.png / 小红军走1.png / 机器人-1.png */
function frameOrderFromName(assetPath: string): number {
const stem = assetPath.replace(/\\/g, '/').split('/').pop()?.replace(/\.[^.]+$/, '') ?? assetPath;
if (stem === '机器人') return 0;
const dash = stem.match(/-(\d+)$/);
if (dash) return parseInt(dash[1], 10);
const trail = stem.match(/(\d+)$/);
if (trail) return parseInt(trail[1], 10) - 1;
return 0;
}
function imageToSpriteFrame(img: ImageAsset): SpriteFrame {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
return frame;
}
function loadDirAssets<T extends Asset>(
folder: string,
type: new (...args: never[]) => T,
): Promise<T[]> {
return new Promise((resolve) => {
resources.loadDir(folder, type, (err, assets) => {
if (err || !assets?.length) {
resolve([]);
return;
}
resolve(assets);
});
});
}
async function loadFrameSequence(folder: string): Promise<SpriteFrame[]> {
const hit = seqCache.get(folder);
if (hit) return hit;
const spriteAssets = await loadDirAssets(folder, SpriteFrame);
let entries: { order: number; frame: SpriteFrame }[] = spriteAssets.map((sf) => ({
order: frameOrderFromName(sf.name),
frame: sf,
}));
if (entries.length === 0) {
const images = await loadDirAssets(folder, ImageAsset);
entries = images.map((img) => ({
order: frameOrderFromName(img.name),
frame: imageToSpriteFrame(img),
}));
}
if (entries.length === 0) {
const frames: SpriteFrame[] = [];
for (let i = 1; i <= 16; i++) {
const leaf = folder.split('/').pop() ?? '';
const candidates = [
`${folder}/${i}`,
leaf ? `${folder}/${leaf}${i}` : '',
i === 1 ? `${folder}/机器人` : `${folder}/机器人-${i - 1}`,
`${folder}/小红军${leaf}${i}`,
].filter(Boolean);
let sf: SpriteFrame | null = null;
for (const path of candidates) {
sf = await loadSpriteFrame(path);
if (sf) break;
}
if (!sf) break;
frames.push(sf);
}
if (frames.length > 0) seqCache.set(folder, frames);
return frames;
}
entries.sort((a, b) => a.order - b.order || a.frame.name.localeCompare(b.frame.name));
const frames = entries.map((e) => e.frame);
seqCache.set(folder, frames);
return frames;
}
/**
* 序列帧角色动画(对齐 Unity Animator Action 04
* 无 skin 动画目录时回退 VisualAssets 静态贴图。
*/
@ccclass('PlayerActionAnimator')
export class PlayerActionAnimator extends Component {
private action = PlayerAction.Idle;
private direction = Direction.South;
private theme = 'silu';
private scaleMul = 1;
private animPaths: PlayerAnimPaths | null = null;
private frames: SpriteFrame[] = [];
private frameIdx = 0;
private frameTimer = 0;
private loadingGen = 0;
private useSequence = false;
/** 主题级统一缩放(按最高序列帧锁定,避免待机/走路切换时重新算 scale */
private lockedUniformScale: number | null = null;
/** 脚点对齐参考帧(与 lockedUniformScale 同源,取最高帧) */
private refIdleFrame: SpriteFrame | null = null;
private displayLockPromise: Promise<void> | null = null;
private get spriteNode(): Node {
return this.node;
}
configure(theme: string | undefined, direction: Direction, scaleMul = 1) {
const nextTheme = theme ?? 'silu';
const themeChanged = resolveThemeId(this.theme) !== resolveThemeId(nextTheme)
|| this.scaleMul !== scaleMul;
this.theme = nextTheme;
this.direction = direction;
this.scaleMul = scaleMul;
if (themeChanged) {
this.lockedUniformScale = null;
this.refIdleFrame = null;
this.displayLockPromise = null;
}
this.animPaths = resolvePlayerAnimPaths(this.theme);
this.useSequence = this.animPaths !== null;
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(this.spriteNode, direction, { theme: this.theme }, scaleMul);
return;
}
void this.applyActionFrames(this.action);
}
setDirection(direction: Direction, options?: EntityVisualOptions, scaleMul = 1) {
const nextTheme = options?.theme ?? this.theme;
if (
this.direction === direction
&& this.theme === nextTheme
&& this.scaleMul === scaleMul
&& this.useSequence
&& this.frames.length > 0
) {
const flipX = entityFlipX(direction);
this.spriteNode.setScale(flipX ? -1 : 1, 1, 1);
return;
}
this.direction = direction;
if (options?.theme) this.theme = options.theme;
this.scaleMul = scaleMul;
this.animPaths = resolvePlayerAnimPaths(this.theme);
this.useSequence = this.animPaths !== null;
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(this.spriteNode, direction, options ?? { theme: this.theme }, scaleMul);
return;
}
void this.applyActionFrames(this.action);
}
setAction(action: PlayerAction, force = false) {
if (!force && this.action === action && this.useSequence && this.frames.length > 0) return;
this.action = action;
this.frameIdx = 0;
this.frameTimer = 0;
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(
this.spriteNode, this.direction, { theme: this.theme }, this.scaleMul,
);
return;
}
void this.applyActionFrames(action);
}
getAction(): PlayerAction {
return this.action;
}
update(dt: number) {
if (!this.useSequence || this.frames.length <= 1) return;
const interval = FRAME_INTERVAL[this.action];
if (interval <= 0) return;
const speedMul = GameManager.instance?.getGameSpeed() ?? Movement.getSpeedMultiplier();
this.frameTimer += dt * speedMul;
if (this.frameTimer < interval) return;
this.frameTimer = 0;
const loop = this.action === PlayerAction.Move
|| this.action === PlayerAction.Jump
|| this.action === PlayerAction.Win
|| this.action === PlayerAction.Idle;
if (loop) {
this.frameIdx = (this.frameIdx + 1) % this.frames.length;
} else if (this.frameIdx < this.frames.length - 1) {
this.frameIdx++;
}
this.showFrame(this.frames[this.frameIdx]!);
}
private lockDisplayFromFrame(sf: SpriteFrame) {
this.lockedUniformScale = computeEntityUniformScale(
'player', sf, this.scaleMul, this.theme,
);
this.refIdleFrame = sf;
}
/** 用正/背面全部动作序列里的最高帧锁定显示盒,避免走路帧更高导致缩放闪动 */
private ensureThemeDisplayLock(): Promise<void> {
if (this.lockedUniformScale != null) return Promise.resolve();
if (this.displayLockPromise) return this.displayLockPromise;
if (!this.animPaths) return Promise.resolve();
this.displayLockPromise = (async () => {
let tallest: SpriteFrame | null = null;
let maxH = 0;
const sides = [this.animPaths!.front, this.animPaths!.back];
for (const side of sides) {
for (const folder of [side.idle, side.move, side.jump]) {
const frames = await loadFrameSequence(folder);
for (const sf of frames) {
const h = spriteOriginalSize(sf).height;
if (h > maxH) {
maxH = h;
tallest = sf;
}
}
}
}
if (tallest && this.lockedUniformScale == null) {
this.lockDisplayFromFrame(tallest);
}
})();
return this.displayLockPromise;
}
private async applyActionFrames(action: PlayerAction) {
if (!this.animPaths) return;
const gen = ++this.loadingGen;
const isFront = this.direction === Direction.South || this.direction === Direction.East;
const side = isFront ? this.animPaths.front : this.animPaths.back;
await this.ensureThemeDisplayLock();
let folder = actionFolder(side, action);
let frames = await loadFrameSequence(folder);
if (frames.length === 0 && action === PlayerAction.Win) {
folder = side.idle;
frames = await loadFrameSequence(folder);
if (frames.length > 1) frames = frames.slice(0, 2);
}
if (frames.length === 0) {
VisualAssets.applyPlayerSprite(
this.spriteNode, this.direction, { theme: this.theme }, this.scaleMul,
);
return;
}
if (gen !== this.loadingGen || !this.node.isValid) return;
this.frames = frames;
this.frameIdx = 0;
this.frameTimer = 0;
this.showFrame(frames[0]!);
}
private showFrame(sf: SpriteFrame) {
const flipX = entityFlipX(this.direction);
let ui = this.spriteNode.getComponent(UITransform);
if (!ui) {
ui = this.spriteNode.addComponent(UITransform);
ui.setContentSize(48, 48);
}
const sprite = this.spriteNode.getComponent(Sprite) ?? this.spriteNode.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
if (this.lockedUniformScale == null) {
this.lockDisplayFromFrame(sf);
}
const uniform = this.lockedUniformScale ?? computeEntityUniformScale(
'player', sf, this.scaleMul, this.theme,
);
const fit = sizeFromUniformScale(sf, uniform, 'player', this.theme);
const ref = this.refIdleFrame ?? sf;
const anchorY = ref === sf
? fit.anchorY
: playerSeqFrameAnchorY(sf, ref, fit.anchorY);
ui.setAnchorPoint(0.5, anchorY);
ui.setContentSize(fit.width, fit.height);
this.spriteNode.setScale(flipX ? -1 : 1, 1, 1);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "bbfaa54a-c152-430e-bc7a-ad567f25bf85",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,65 @@
import { canonicalThemeKey, resolveThemeFolder } from './ThemeEntityTextures';
/** 对齐 Unity Animator Integer「Action」 */
export enum PlayerAction {
Idle = 0,
Move = 1,
Jump = 2,
Win = 3,
Fail = 4,
}
export interface PlayerAnimSidePaths {
idle: string;
move: string;
jump: string;
win?: string;
fail?: string;
}
export interface PlayerAnimPaths {
front: PlayerAnimSidePaths;
back: PlayerAnimSidePaths;
}
/** 各主题 skin 动画目录;无序列帧的主题返回 null仍用静态贴图 */
export function resolvePlayerAnimPaths(theme: string | undefined): PlayerAnimPaths | null {
const key = canonicalThemeKey(theme);
const folder = resolveThemeFolder(theme);
const base = `textures/${folder}`;
switch (key) {
case 'silu':
case 'sanxing':
case 'snow':
case 'chinese':
case 'redArmy':
case 'numMan':
return {
front: {
idle: `${base}/skin/待机正面`,
move: `${base}/skin/走`,
jump: `${base}/skin/跳`,
fail: key === 'silu' ? `${base}/player/失败正` : undefined,
},
back: {
idle: `${base}/skin/待机背面`,
move: `${base}/skin/走背面`,
jump: `${base}/skin/跳背面`,
fail: key === 'silu' ? `${base}/player/失败反` : undefined,
},
};
default:
return null;
}
}
export function actionFolder(side: PlayerAnimSidePaths, action: PlayerAction): string {
switch (action) {
case PlayerAction.Move: return side.move;
case PlayerAction.Jump: return side.jump;
case PlayerAction.Win: return side.win ?? side.idle;
case PlayerAction.Fail: return side.fail ?? side.idle;
default: return side.idle;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5c42d6d0-2388-470a-a738-9e2640194d51",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,163 @@
/**
* 内置回退贴图themes-database.json 未加载或未配置时使用)
*/
import { Direction } from '../core/Define';
import { getThemeTextureFolder, isThemeDatabaseReady } from '../theme/ThemeRegistry';
export interface ThemeEntityTextureSet {
playerFront: string[];
playerBack: string[];
shipNorth: string[];
shipEast: string[];
shipSouth: string[];
shipWest: string[];
/** @deprecated 等同 shipSouth */
shipFront: string[];
/** @deprecated 等同 shipNorth */
shipBack: string[];
}
const THEME_TEXTURE_FOLDER: Record<string, string> = {
default: 'default',
silu: 'silu',
SILU: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
};
const THEME_CANONICAL_KEY: Record<string, string> = {
default: 'default',
silu: 'silu',
SILU: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
};
function shipFourWay(folder: string, prefix: string): Pick<
ThemeEntityTextureSet,
'shipNorth' | 'shipEast' | 'shipSouth' | 'shipWest' | 'shipFront' | 'shipBack'
> {
const base = `textures/${folder}/${prefix}`;
return {
shipNorth: [`${base}_N`, `${base}_B`],
shipEast: [`${base}_E`],
shipSouth: [`${base}_S`, `${base}_F`],
shipWest: [`${base}_W`],
shipFront: [`${base}_S`, `${base}_F`],
shipBack: [`${base}_N`, `${base}_B`],
};
}
const SILU_SHIP = shipFourWay('silu', 'siluShip');
const SILU_FALLBACK: ThemeEntityTextureSet = {
playerFront: ['textures/silu/skin/待机正面/1'],
playerBack: ['textures/silu/skin/待机背面/1'],
...SILU_SHIP,
};
export const THEME_ENTITY_TEXTURES: Record<string, ThemeEntityTextureSet> = {
default: {
playerFront: ['textures/default/player_F'],
playerBack: ['textures/default/player_B'],
...shipFourWay('default', 'ship'),
},
silu: { ...SILU_FALLBACK },
chinese: {
playerFront: ['textures/chinese/skin/待机正面/1', 'textures/chinese/chineseShip_F'],
playerBack: ['textures/chinese/skin/待机背面/1', 'textures/chinese/chineseShip_B'],
...shipFourWay('chinese', 'chineseShip'),
},
redArmy: {
playerFront: ['textures/redArmy/skin/待机正面/1'],
playerBack: ['textures/redArmy/skin/待机背面/1'],
...shipFourWay('redArmy', 'redArmyShip'),
},
numMan: {
playerFront: ['textures/numMan/skin/待机正面/1'],
playerBack: ['textures/numMan/skin/待机背面/1'],
...shipFourWay('numMan', 'numManShip'),
},
snow: {
playerFront: ['textures/snow/skin/待机正面/1'],
playerBack: ['textures/snow/skin/待机背面/1'],
...shipFourWay('snow', 'snowShip'),
},
sanxing: {
playerFront: ['textures/sanxing/skin/待机正面/1'],
playerBack: ['textures/sanxing/skin/待机背面/1'],
...shipFourWay('sanxing', 'sanxingShip'),
},
};
export function canonicalThemeKey(theme: string | undefined): string {
if (!theme) return 'silu';
return THEME_CANONICAL_KEY[theme] ?? theme;
}
export function resolveThemeFolder(theme: string | undefined): string {
if (!theme) return 'silu';
if (isThemeDatabaseReady()) return getThemeTextureFolder(theme);
return THEME_TEXTURE_FOLDER[theme] ?? theme;
}
export function getThemeEntityTextures(theme: string | undefined): ThemeEntityTextureSet {
const key = canonicalThemeKey(theme);
return THEME_ENTITY_TEXTURES[key] ?? SILU_FALLBACK;
}
export type VehicleSpriteRole = 'shipNorth' | 'shipEast' | 'shipSouth' | 'shipWest';
export type EntitySpriteRole = 'playerFront' | 'playerBack' | VehicleSpriteRole | 'shipFront' | 'shipBack';
export function vehicleSpriteRoleForDirection(direction: Direction): VehicleSpriteRole {
switch (direction) {
case Direction.North: return 'shipNorth';
case Direction.East: return 'shipEast';
case Direction.South: return 'shipSouth';
case Direction.West: return 'shipWest';
default: return 'shipNorth';
}
}
export function vehicleThemeFieldForDirection(direction: Direction): keyof import('../theme/ThemeTypes').ThemeEntityConfig {
switch (direction) {
case Direction.North: return 'vehicleNorth';
case Direction.East: return 'vehicleEast';
case Direction.South: return 'vehicleSouth';
case Direction.West: return 'vehicleWest';
default: return 'vehicleNorth';
}
}
export function entityTextureCandidates(
theme: string | undefined,
role: EntitySpriteRole,
): string[] {
const set = getThemeEntityTextures(theme);
const primary = [...(set[role] ?? [])];
const fb = SILU_FALLBACK[role as keyof ThemeEntityTextureSet];
if (fb) {
for (const p of fb) {
if (!primary.includes(p)) primary.push(p);
}
}
return primary;
}
/** 预加载某主题载具四向贴图主路径 */
export function allVehicleDirectionTextureCandidates(theme: string | undefined): string[] {
const out: string[] = [];
for (let d = Direction.North; d <= Direction.West; d++) {
for (const p of entityTextureCandidates(theme, vehicleSpriteRoleForDirection(d))) {
if (!out.includes(p)) out.push(p);
}
}
return out;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "37205258-4b45-4090-bc1b-e8178c3664f2",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,89 @@
import { JsonAsset, resources } from 'cc';
const META_PATH = 'theme/tile-display-meta';
export interface TileDisplayEntry {
width: number;
height: number;
pivotX: number;
pivotY: number;
ppu?: number;
/** 相对格子宽度的额外缩放(默认 1snow kuai11 可略大于 1 以贴满格) */
fitMul?: number;
}
type ThemeTileMap = Record<string, TileDisplayEntry>;
let themeTiles: Record<string, ThemeTileMap> = {};
let loadPromise: Promise<void> | null = null;
function ingest(data: { themes?: Record<string, ThemeTileMap> }) {
themeTiles = { ...(data.themes ?? {}) };
}
export function loadTileDisplayMeta(): Promise<void> {
if (loadPromise) return loadPromise;
loadPromise = new Promise((resolve) => {
resources.load(META_PATH, JsonAsset, (err, asset) => {
if (err || !asset?.json) {
console.warn('[TileDisplayMeta] 未找到 tile-display-meta.json', err);
themeTiles = {};
} else {
ingest(asset.json as { themes?: Record<string, ThemeTileMap> });
}
resolve();
});
});
return loadPromise;
}
export function reloadTileDisplayMeta(): Promise<void> {
themeTiles = {};
loadPromise = null;
return new Promise((resolve) => {
resources.release(META_PATH);
loadTileDisplayMeta().then(resolve);
});
}
function normalizeThemeKey(theme?: string): string | undefined {
if (theme == null) return undefined;
const t = (typeof theme === 'string' ? theme : String(theme)).trim();
if (!t) return undefined;
if (t === 'redArmy') return 'redarmy';
return t;
}
export function getThemeTileDisplayEntry(theme: string | undefined, tileName: string): TileDisplayEntry | null {
const key = normalizeThemeKey(theme);
if (!key) return null;
const map = themeTiles[key];
if (!map) return null;
return map[tileName] ?? null;
}
export function getThemeTileSize(
theme: string | undefined,
tileName: string,
fallback: { width: number; height: number },
): { width: number; height: number } {
const entry = getThemeTileDisplayEntry(theme, tileName);
if (!entry) return fallback;
return { width: entry.width, height: entry.height };
}
export function getThemeTilePivot(
theme: string | undefined,
tileName: string,
fallback: { x: number; y: number },
): { x: number; y: number } {
const entry = getThemeTileDisplayEntry(theme, tileName);
if (!entry) return fallback;
return { x: entry.pivotX, y: entry.pivotY };
}
export function getThemeTileFitMul(theme: string | undefined, tileName: string): number {
const entry = getThemeTileDisplayEntry(theme, tileName);
const mul = entry?.fitMul ?? 1;
return Number.isFinite(mul) && mul > 0 ? mul : 1;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "922a514b-1242-4a5e-be13-fac729161727",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,23 @@
import { getThemeTilePivot } from './TileDisplayMeta';
/** 与 Unity Texture/*.png.meta 中 spritePivot 一致(无主题数据时的回退) */
export interface TilePivot {
x: number;
y: number;
}
const DEFAULT_PIVOTS: Record<string, TilePivot> = {
Baseblock: { x: 0.5, y: 0.92 },
JumpBlock: { x: 0.5, y: 0.77 },
WallBlock: { x: 0.5, y: 0.67 },
kuai11: { x: 0.5, y: 1.01 },
Decor23: { x: 0.5, y: 0.92 },
'素材切图-23': { x: 0.5, y: 0.92 },
'素材切图2-23': { x: 0.5, y: 0.92 },
'小游戏素材红色_03': { x: 0.5, y: 0.92 },
};
export function getTilePivot(tileName: string, theme?: string): TilePivot {
const fallback = DEFAULT_PIVOTS[tileName] ?? { x: 0.5, y: 0.92 };
return getThemeTilePivot(theme, tileName, fallback);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "97cf4d35-2a8a-4e57-a6fc-a45061f9988a",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,64 @@
import { getThemeTileDisplayEntry, getThemeTileSize, getThemeTileFitMul } from './TileDisplayMeta';
import { SpriteFrame } from 'cc';
/** 与 Unity / Cocos sprite-frame 原始尺寸一致(无主题数据时的回退) */
export interface TileSize {
width: number;
height: number;
}
const DEFAULT_SIZES: Record<string, TileSize> = {
Baseblock: { width: 101, height: 80 },
JumpBlock: { width: 101, height: 99 },
WallBlock: { width: 101, height: 115 },
kuai11: { width: 101, height: 74 },
};
/** 等距菱形格宽度(与 grid-math CELL_PIXEL 一致) */
const CELL_W = 100;
export function getTileSize(tileName: string, theme?: string): TileSize {
const fallback = DEFAULT_SIZES[tileName] ?? { width: 101, height: 80 };
return getThemeTileSize(theme, tileName, fallback);
}
/** 优先主题 meta裁剪后尺寸避免 sprite originalSize 含透明边导致缩放偏小 */
export function resolveTilePixelSize(
tileName: string,
sf?: SpriteFrame | null,
theme?: string,
): TileSize {
if (theme && getThemeTileDisplayEntry(theme, tileName)) {
return getTileSize(tileName, theme);
}
if (sf) {
const rect = sf.rect;
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height };
}
const os = sf.originalSize;
if (os.width > 0 && os.height > 0) {
return { width: os.width, height: os.height };
}
}
return getTileSize(tileName, theme);
}
/** 等比缩放宽度贴满格子100px可叠加主题 fitMul */
export function getTileFitScale(tileName: string, width?: number, _height?: number, theme?: string): number {
const size = theme && getThemeTileDisplayEntry(theme, tileName)
? getTileSize(tileName, theme)
: { width: width ?? getTileSize(tileName, theme).width, height: _height ?? getTileSize(tileName, theme).height };
return (CELL_W / size.width) * getThemeTileFitMul(theme, tileName);
}
export function getTileDrawSize(tileName: string, width?: number, height?: number, theme?: string): TileSize {
const source = theme && getThemeTileDisplayEntry(theme, tileName)
? getTileSize(tileName, theme)
: {
width: width ?? getTileSize(tileName, theme).width,
height: height ?? getTileSize(tileName, theme).height,
};
const s = (CELL_W / source.width) * getThemeTileFitMul(theme, tileName);
return { width: source.width * s, height: source.height * s };
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "13aecf34-b260-4097-8817-6683b6ed2960",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,34 +1,161 @@
import {
Node, Sprite, SpriteFrame, UITransform, resources, Color, Graphics,
ImageAsset, Texture2D,
ImageAsset, Texture2D, Vec3,
} from 'cc';
import { Direction } from '../core/Define';
import { SpawnKind } from '../level/LevelTypes';
import { cellToWorld } from '../core/GridCoords';
import { LevelConfig, SpawnConfig, SpawnKind } from '../level/LevelTypes';
import { resolvePropPlacement } from '../level/EntitySpawnPlacement';
import { PropController } from '../controller/PropController';
import { PlayerController } from '../controller/PlayerController';
import { VehicleController } from '../controller/VehicleController';
import { PlayerActionAnimator } from './PlayerActionAnimator';
import { PlayerAction, resolvePlayerAnimPaths } from './PlayerAnimPaths';
import {
resolveEntityScaleMul,
} from '../level/EntitySpawnDefaults';
import { getTilePivot } from './TilePivots';
import { getTileDrawSize, resolveTilePixelSize } from './TileSizes';
import { alignTileNode, findLevelChildByName, forEachLevelEntityNode } from '../level/TileLayout';
import { ensureResourcesBundle } from '../core/ResourcesBundle';
import {
canonicalThemeKey,
entityTextureCandidates,
resolveThemeFolder,
} from './ThemeEntityTextures';
import { getThemeTilePath } from '../theme/ThemeRegistry';
import {
EntityVisualOptions,
collectLevelEntityTexturePaths,
entityFlipX,
normalizeTexturePath,
resolveEntityTexturePaths,
} from './EntityTextureResolver';
import {
EntityDisplayKind,
ensureSpriteFrameSize,
fitEntityDisplaySize,
} from './EntityDisplayRefs';
type SpriteKey = 'player_F' | 'player_B' | 'ship_F' | 'ship_B' | 'coin' | 'tile';
type SpriteKey = 'player_F' | 'player_B' | 'ship_F' | 'ship_B' | 'coin' | 'tile' | 'jump' | 'wall';
const PATHS: Record<SpriteKey, string> = {
player_F: 'textures/silu/player_F',
player_B: 'textures/silu/player_B',
ship_F: 'textures/silu/ship_F',
ship_B: 'textures/silu/ship_B',
coin: 'textures/ui/coin',
tile: 'textures/silu/Baseblock',
/** 与 Unity / GameController UIStyleNames 一致(仅用于 UI 主题别名) */
const UI_STYLE_TO_FOLDER: Record<string, string> = {
default: 'silu',
chinese: 'chinese',
redArmy: 'redArmy',
redarmy: 'redArmy',
numMan: 'numMan',
snow: 'snow',
sanxing: 'sanxing',
silu: 'silu',
};
export function normalizeTheme(uiStyle: string | undefined): string {
if (!uiStyle) return 'silu';
return UI_STYLE_TO_FOLDER[uiStyle] ?? canonicalThemeKey(uiStyle);
}
const FILE_BY_KEY: Record<SpriteKey, string> = {
player_F: 'player_F',
player_B: 'player_B',
ship_F: 'ship_F',
ship_B: 'ship_B',
coin: 'coin',
tile: 'Baseblock',
jump: 'JumpBlock',
wall: 'WallBlock',
};
export class VisualAssets {
private static frames = new Map<SpriteKey, SpriteFrame>();
private static loading: Promise<void> | null = null;
private static frames = new Map<string, SpriteFrame>();
/** 载具异步预载代次,避免 spawn 与 refresh 并发时旧任务覆盖贴图 */
private static vehicleVisualGeneration = new WeakMap<Node, number>();
/** 按 resources 路径缓存的贴图 */
private static pathFrames = new Map<string, SpriteFrame>();
/** 同路径并发加载去重,避免刷新时部分贴图加载失败 */
private static pathLoading = new Map<string, Promise<SpriteFrame | null>>();
/** 瓦片贴图:按「主题:瓦片名」缓存,多关卡多主题并存 */
private static namedFrames = new Map<string, SpriteFrame>();
static async preload(): Promise<void> {
/** 切关 / 贴图 meta 更新后清缓存,避免仍用旧 sprite-frame 尺寸 */
static clearNamedTileCache() {
this.namedFrames.clear();
}
/** 切关时清实体贴图路径缓存,避免 ImageAsset 直载帧尺寸陈旧 */
static clearPathFrameCache() {
this.pathFrames.clear();
this.pathLoading.clear();
}
private static loading: Promise<void> | null = null;
/** 角色 / UI 用主题(与关卡砖块主题独立) */
private static uiTheme = 'silu';
/** 设置角色/UI 主题;不清空已加载的关卡砖块贴图 */
static setTheme(uiStyle: string, force = false) {
const next = normalizeTheme(uiStyle);
if (!force && this.uiTheme === next) return;
this.uiTheme = next;
this.loading = null;
}
static getUiTheme(): string {
return this.uiTheme;
}
private static frameKey(key: SpriteKey, theme?: string): string {
const t = theme ? resolveThemeFolder(theme) : this.uiTheme;
return `${t}:${key}`;
}
private static resourcePath(key: SpriteKey, theme?: string): string {
const folder = resolveThemeFolder(theme ?? this.uiTheme);
if (key === 'coin') return `textures/${folder}/Prop_kuai1`;
const file = FILE_BY_KEY[key];
return `textures/${folder}/${file}`;
}
private static pathCandidates(key: SpriteKey, theme?: string): string[] {
const t = theme ?? this.uiTheme;
if (key === 'player_F') return entityTextureCandidates(t, 'playerFront');
if (key === 'player_B') return entityTextureCandidates(t, 'playerBack');
if (key === 'ship_F') return entityTextureCandidates(t, 'shipFront');
if (key === 'ship_B') return entityTextureCandidates(t, 'shipBack');
const folder = resolveThemeFolder(t);
if (key === 'coin') {
return [
`textures/${folder}/Prop_kuai1`,
`textures/${folder}/Prop_kuai2`,
`textures/${folder}/Prop_kuai`,
`textures/${folder}/Prop`,
`textures/${folder}/coin`,
'textures/ui/coin',
'textures/silu/Prop_kuai1',
];
}
const base = `textures/${folder}/${FILE_BY_KEY[key]}`;
const paths = [base];
if (folder !== 'silu') paths.push(`textures/silu/${FILE_BY_KEY[key]}`);
return paths;
}
static async preload(uiStyle?: string): Promise<void> {
if (uiStyle) this.setTheme(uiStyle);
if (this.loading) return this.loading;
this.loading = (async () => {
const keys = Object.keys(PATHS) as SpriteKey[];
const keys = Object.keys(FILE_BY_KEY) as SpriteKey[];
const results = await Promise.all(keys.map((k) => this.loadOne(k)));
await Promise.all([
this.loadOne('player_F'),
this.loadOne('player_B'),
this.loadOne('ship_F'),
this.loadOne('ship_B'),
]);
const ok = results.filter(Boolean).length;
console.log(`[VisualAssets] 贴图加载 ${ok}/${keys.length}`);
console.log(`[VisualAssets] UI 主题=${this.uiTheme} 贴图 ${ok}/${keys.length}`);
if (ok === 0) {
console.warn('[VisualAssets] 未加载到贴图,将使用色块。请确认 assets/resources/textures 已导入');
console.warn('[VisualAssets] 未加载到贴图,请运行 tools/import_unity_textures.py 并刷新资源');
}
})().catch((e) => {
console.error('[VisualAssets] preload failed', e);
@@ -37,86 +164,717 @@ export class VisualAssets {
return this.loading;
}
private static loadOne(key: SpriteKey): Promise<boolean> {
if (this.frames.has(key)) return Promise.resolve(true);
const base = PATHS[key];
return new Promise((resolve) => {
resources.load(`${base}/spriteFrame`, SpriteFrame, (err, sf) => {
private static loadOne(key: SpriteKey, theme?: string): Promise<boolean> {
const fk = this.frameKey(key, theme);
if (this.frames.has(fk)) return Promise.resolve(true);
const tryPaths = this.pathCandidates(key, theme);
const attempt = (path: string): Promise<SpriteFrame | null> => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
this.frames.set(key, sf);
resolve(true);
resolve(sf);
return;
}
resources.load(base, SpriteFrame, (err2, sf2) => {
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
this.frames.set(key, sf2);
resolve(true);
resolve(sf2);
return;
}
resources.load(base, ImageAsset, (err3, img) => {
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
this.frames.set(key, frame);
resolve(true);
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
console.warn(`[VisualAssets] 加载失败: ${base}`, err3 || err2 || err);
resolve(false);
resolve(null);
}
});
});
});
});
return (async () => {
await ensureResourcesBundle();
for (const p of tryPaths) {
const sf = await attempt(p);
if (sf) {
this.frames.set(fk, sf);
return true;
}
}
console.warn(`[VisualAssets] 加载失败: ${tryPaths[0]}`);
return false;
})();
}
static getFrame(key: SpriteKey): SpriteFrame | null {
return this.frames.get(key) ?? null;
static getFrame(key: SpriteKey, theme?: string): SpriteFrame | null {
return this.frames.get(this.frameKey(key, theme)) ?? null;
}
static applyPlayerSprite(node: Node, direction: Direction) {
const isFront = direction === Direction.South || direction === Direction.East;
const flipX = direction === Direction.West || direction === Direction.East;
const key: SpriteKey = isFront ? 'player_F' : 'player_B';
this.applySprite(node, key, flipX);
/** 按关卡配置预加载实体贴图(自定义路径 + theme 回退) */
static async preloadLevelEntities(config: LevelConfig | null | undefined): Promise<void> {
if (!config) return;
const paths = collectLevelEntityTexturePaths(
config.theme,
config.entityTextures,
config.spawns,
config,
);
await Promise.all(paths.map((p) => this.loadTexturePath(p)));
}
static applyVehicleSprite(node: Node, direction: Direction, uiStyle = 'default') {
void uiStyle;
const isFront = direction === Direction.South || direction === Direction.East;
const flipX = direction === Direction.West || direction === Direction.East;
const key: SpriteKey = isFront ? 'ship_F' : 'ship_B';
this.applySprite(node, key, flipX);
/** 切关 / 软重置前预加载本关瓦片 + 实体贴图,避免生成占位块 */
static async ensureLevelAssetsReady(
config: LevelConfig | null | undefined,
tileNames: string[],
): Promise<void> {
if (!config) return;
const theme = normalizeTheme(config.theme || 'silu');
await Promise.all([
this.preloadLevelTiles(theme, tileNames),
this.preloadLevelEntities(config),
]);
}
static setupEntityVisual(node: Node, kind: SpawnKind, direction?: Direction) {
/** 按路径顺序解析贴图(先缓存后异步加载) */
private static async resolveFirstTexture(paths: string[]): Promise<SpriteFrame | null> {
const validPaths = paths
.map((p) => normalizeTexturePath(p))
.filter((p): p is string => !!p);
for (const p of validPaths) {
const cached = this.getPathFrame(p);
if (cached) return cached;
}
for (const p of validPaths) {
const loaded = await this.loadTexturePath(p);
if (loaded) return loaded;
}
return null;
}
/** @deprecated 使用 preloadLevelEntities */
static async preloadEntityTheme(mapTheme: string): Promise<void> {
const keys: SpriteKey[] = ['player_F', 'player_B', 'ship_F', 'ship_B', 'coin'];
await Promise.all(keys.map((k) => this.loadOne(k, mapTheme)));
}
static getPathFrame(texPath: string): SpriteFrame | null {
const key = normalizeTexturePath(texPath);
if (!key) return null;
return this.pathFrames.get(key) ?? null;
}
private static pathCacheKey(texPath: unknown): string {
return normalizeTexturePath(texPath) ?? '';
}
static loadTexturePath(texPath: string): Promise<SpriteFrame | null> {
if (typeof texPath !== 'string') {
return Promise.resolve(null);
}
const key = normalizeTexturePath(texPath);
if (!key || key === '[object Set]' || key === '[object Object]') {
console.warn('[VisualAssets] 贴图路径无效:', texPath);
return Promise.resolve(null);
}
const cached = this.pathFrames.get(key);
if (cached) return Promise.resolve(cached);
const inflight = this.pathLoading.get(key);
if (inflight) return inflight;
const attempt = (path: string): Promise<SpriteFrame | null> => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
resolve(sf);
return;
}
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
resolve(sf2);
return;
}
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
resolve(null);
}
});
});
});
});
const job = (async () => {
await ensureResourcesBundle();
const sf = await attempt(key);
if (sf) {
this.pathFrames.set(key, sf);
return sf;
}
console.warn(`[VisualAssets] 贴图路径加载失败: ${key}`);
return null;
})().finally(() => {
this.pathLoading.delete(key);
});
this.pathLoading.set(key, job);
return job;
}
private static namedFrameKey(theme: string, tileName: string): string {
return `${resolveThemeFolder(theme)}:${tileName}`;
}
/** 按关卡主题目录加载瓦片theme 来自 levels-database.json与 UI 主题无关) */
static loadNamedFrame(tileName: string, theme: string): Promise<SpriteFrame | null> {
if (typeof tileName !== 'string') {
return Promise.resolve(null);
}
const tile = tileName.trim();
if (!tile) return Promise.resolve(null);
const folder = resolveThemeFolder(theme);
const cacheKey = this.namedFrameKey(folder, tile);
const cached = this.namedFrames.get(cacheKey);
if (cached) return Promise.resolve(cached);
const base = `textures/${folder}/${tile}`;
const tryPaths: string[] = [];
const themed = getThemeTilePath(theme, tile);
if (themed) tryPaths.push(themed);
tryPaths.push(base);
if (folder !== 'silu') tryPaths.push(`textures/silu/${tile}`);
const attempt = (path: string): Promise<SpriteFrame | null> => new Promise((resolve) => {
resources.load(`${path}/spriteFrame`, SpriteFrame, (err, sf) => {
if (!err && sf) {
resolve(sf);
return;
}
resources.load(path, SpriteFrame, (err2, sf2) => {
if (!err2 && sf2) {
resolve(sf2);
return;
}
resources.load(path, ImageAsset, (err3, img) => {
if (!err3 && img) {
const tex = new Texture2D();
tex.image = img;
const frame = new SpriteFrame();
frame.texture = tex;
ensureSpriteFrameSize(frame, img.width, img.height);
resolve(frame);
} else {
resolve(null);
}
});
});
});
});
return (async () => {
for (const p of tryPaths) {
const sf = await attempt(p);
if (sf) {
this.namedFrames.set(cacheKey, sf);
return sf;
}
}
console.warn(`[VisualAssets] 瓦片贴图缺失: ${base}`);
return null;
})();
}
static applyNamedTile(
node: Node,
tileName: string,
alpha = 255,
cellX?: number,
cellY?: number,
theme?: string,
) {
const folder = resolveThemeFolder(theme);
const cacheKey = this.namedFrameKey(folder, tileName);
const sf = this.namedFrames.get(cacheKey);
if (sf) {
this.applyTileFrame(node, sf, tileName, alpha, cellX, cellY, theme ?? folder);
return Promise.resolve();
}
return this.loadNamedFrame(tileName, theme ?? folder).then((loaded) => {
if (loaded && node?.isValid) {
this.applyTileFrame(node, loaded, tileName, alpha, cellX, cellY, theme ?? folder);
}
});
}
/** 预加载某关卡所需的全部瓦片贴图(可与其他主题缓存共存) */
static preloadLevelTiles(theme: string, tileNames: string[]): Promise<void> {
return Promise.all(tileNames.map((n) => this.loadNamedFrame(n, theme))).then(() => undefined);
}
/** 等距砖块:格子锚点 + 各瓦片 Unity pivot与 Tilemap 一致,不做 keep-position 补偿) */
private static applyTileFrame(
node: Node,
sf: SpriteFrame,
tileName: string,
alpha = 255,
cellX?: number,
cellY?: number,
theme?: string,
) {
let ui = node.getComponent(UITransform);
if (!ui) ui = node.addComponent(UITransform);
const spr = node.getComponent(Sprite) || node.addComponent(Sprite);
spr.enabled = true;
spr.spriteFrame = sf;
spr.sizeMode = Sprite.SizeMode.CUSTOM;
const source = resolveTilePixelSize(tileName, sf, theme);
const draw = getTileDrawSize(tileName, source.width, source.height, theme);
ui.setContentSize(draw.width, draw.height);
const pivot = getTilePivot(tileName, theme);
ui.setAnchorPoint(pivot.x, pivot.y);
if (cellX !== undefined && cellY !== undefined && !Number.isNaN(cellX) && !Number.isNaN(cellY)) {
alignTileNode(node, cellX, cellY, tileName, theme);
}
spr.color = new Color(255, 255, 255, alpha);
node.setScale(1, 1, 1);
}
private static applySpriteFrame(node: Node, sf: SpriteFrame, tileName: string, alpha = 255, theme?: string) {
this.applyTileFrame(node, sf, tileName, alpha, undefined, undefined, theme);
}
static applyPlayerSprite(
node: Node,
direction: Direction,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
const flipX = entityFlipX(direction);
const paths = resolveEntityTexturePaths('player', direction, options);
this.applyTexturePaths(node, paths, {
flipX,
displayKind: 'player',
displayMul: scaleMul,
alpha: 255,
theme: options.theme,
});
}
/** 载具四向贴图:按 direction 直接选图,不做 flipX */
static applyVehicleIconForDirection(
node: Node,
direction: Direction,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
const paths = resolveEntityTexturePaths('vehicle', direction, options);
this.applyTexturePaths(node, paths, {
flipX: false,
displayKind: 'vehicle',
displayMul: scaleMul,
alpha: 255,
theme: options.theme,
});
}
/** @deprecated 使用 applyVehicleIconForDirection */
static applyVehicleIcon(
node: Node,
isFront: boolean,
flipX: boolean,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
const direction = isFront ? Direction.South : Direction.North;
this.applyVehicleIconForDirection(node, direction, options, scaleMul);
}
static applyVehicleSprite(
node: Node,
direction: Direction,
options: EntityVisualOptions = {},
scaleMul = 1,
) {
this.applyVehicleIconForDirection(node, direction, options, scaleMul);
}
/** 预载载具四向贴图,显示由 VehicleController.setIcon 负责 */
private static async preloadVehicleTextures(
node: Node,
_direction: Direction,
options: EntityVisualOptions,
): Promise<void> {
const gen = (this.vehicleVisualGeneration.get(node) ?? 0) + 1;
this.vehicleVisualGeneration.set(node, gen);
const paths = new Set<string>();
for (let d = Direction.North; d <= Direction.West; d++) {
for (const p of resolveEntityTexturePaths('vehicle', d, options)) {
paths.add(p);
}
}
await Promise.all([...paths].map((p) => this.loadTexturePath(p)));
if (gen !== this.vehicleVisualGeneration.get(node)) return;
}
static setupEntityVisual(
node: Node,
kind: SpawnKind,
direction: Direction | undefined,
options: EntityVisualOptions,
scaleMul = 1,
) {
void this.setupEntityVisualAsync(node, kind, direction, options, scaleMul);
}
/** 等待贴图就绪后再挂载精灵,避免可拾取物显示为色块占位 */
static async setupEntityVisualAsync(
node: Node,
kind: SpawnKind,
direction: Direction | undefined,
options: EntityVisualOptions,
scaleMul = 1,
): Promise<void> {
const mul = resolveEntityScaleMul(kind, scaleMul);
const visualOpts: EntityVisualOptions = {
theme: options.theme,
entityTextures: options.entityTextures,
spawnTexture: options.spawnTexture,
propPlacement: options.propPlacement,
};
const mapTheme = options.theme;
if (kind === 'player') {
this.applyPlayerSprite(node, direction ?? Direction.South);
const animPaths = resolvePlayerAnimPaths(mapTheme);
const animator = node.getComponent(PlayerActionAnimator);
if (animPaths && animator) {
animator.configure(mapTheme, direction ?? Direction.South, mul);
animator.setAction(PlayerAction.Idle, true);
return;
}
const paths = resolveEntityTexturePaths('player', direction, visualOpts);
const flipX = entityFlipX(direction ?? Direction.South);
const ok = await this.applyTexturePathsAsync(node, paths, {
flipX,
displayKind: 'player',
displayMul: mul,
alpha: 255,
theme: mapTheme,
});
if (!ok) this.applyPlayerSprite(node, direction ?? Direction.South, visualOpts, mul);
return;
}
if (kind === 'vehicle') {
this.applyVehicleSprite(node, direction ?? Direction.North);
const dir = direction ?? Direction.North;
await this.preloadVehicleTextures(node, dir, visualOpts);
if (!node?.isValid) return;
node.getComponent(VehicleController)?.refreshIcon()
?? this.applyVehicleIconForDirection(node, dir, visualOpts, mul);
return;
}
if (kind === 'prop') {
this.applySprite(node, 'coin', false, 0.85);
const paths = resolveEntityTexturePaths('prop', direction, visualOpts);
const displayKind: EntityDisplayKind = visualOpts.propPlacement === 'ground' ? 'propGround' : 'prop';
const ok = await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind,
displayMul: mul,
alpha: 255,
theme: mapTheme,
});
if (!ok) {
await Promise.all(paths.map((p) => this.loadTexturePath(p)));
await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind,
displayMul: mul,
alpha: 255,
theme: mapTheme,
});
}
return;
}
if (kind === 'prop_decor') {
this.applySprite(node, 'coin', false, 0.6, 120);
const paths = resolveEntityTexturePaths('prop_decor', direction, visualOpts);
await this.applyTexturePathsAsync(node, paths, {
flipX: false,
displayKind: 'prop_decor',
displayMul: mul,
alpha: 120,
theme: mapTheme,
});
}
}
/** Sprite 与 Graphics 不能共存,二选一 */
static applySprite(node: Node, key: SpriteKey, flipX: boolean, scale = 1, alpha = 255) {
/** 软重置 / 同关刷新:优先用实体当前逻辑朝向,避免贴图回退到 spawn 方向 */
static async refreshLevelEntityVisuals(levelRoot: Node, config: LevelConfig): Promise<void> {
if (!levelRoot?.isValid || !config) return;
const theme = config.theme || 'silu';
const playerNode = findLevelChildByName(levelRoot, 'Player')
?? (() => {
let found: Node | null = null;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.getComponent(PlayerController)) found = c;
});
return found;
})();
const playerCtrl = playerNode?.getComponent(PlayerController) ?? null;
const jobs: Promise<void>[] = [];
for (const s of config.spawns ?? []) {
if (s.kind === 'player' && resolvePlayerAnimPaths(theme)) {
continue;
}
const node = this.findSpawnNode(levelRoot, s);
if (!node?.isValid) continue;
const dir = this.resolveRuntimeEntityDirection(s, node, playerCtrl);
let propPlacement = s.propPlacement;
if (s.kind === 'prop' && !propPlacement) {
propPlacement = resolvePropPlacement(s, config);
}
jobs.push(this.setupEntityVisualAsync(node, s.kind, dir, {
theme,
entityTextures: config.entityTextures,
spawnTexture: s.texture,
propPlacement,
}, s.scale));
}
await Promise.all(jobs);
for (const s of config.spawns ?? []) {
if (s.kind !== 'vehicle') continue;
const node = this.findSpawnNode(levelRoot, s);
node?.getComponent(VehicleController)?.refreshIcon();
}
}
private static resolveRuntimeEntityDirection(
s: SpawnConfig,
node: Node,
playerCtrl: PlayerController | null,
): Direction | undefined {
if (s.kind === 'player') {
return node.getComponent(PlayerController)?.direction
?? this.readSpawnDirection(s.playerDirection);
}
if (s.kind === 'vehicle') {
const vc = node.getComponent(VehicleController);
if (vc && playerCtrl) {
if (playerCtrl.getRideVehicle() === vc) {
return playerCtrl.direction;
}
const pCell = playerCtrl.getCommittedCell() ?? playerCtrl.getSpawnCell();
const vCell = vc.getCommittedCell() ?? vc.getSpawnCell()
?? new Vec3(s.x, s.y, 0);
if (pCell && pCell.x === vCell.x && pCell.y === vCell.y) {
return playerCtrl.direction;
}
}
return vc?.direction ?? this.readSpawnDirection(s.vehicleDirection);
}
return undefined;
}
private static readSpawnDirection(raw?: Direction | string): Direction | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw === 'number') return raw as Direction;
const name = String(raw).replace(/^Direction\./, '');
const key = name as keyof typeof Direction;
if (Object.prototype.hasOwnProperty.call(Direction, key)) {
const v = Direction[key];
if (typeof v === 'number') return v as Direction;
}
return undefined;
}
private static findSpawnNode(levelRoot: Node, s: SpawnConfig): Node | null {
if (s.kind === 'player') {
let found = findLevelChildByName(levelRoot, 'Player');
if (found) return found;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.getComponent(PlayerController)) found = c;
});
return found;
}
if (s.kind === 'vehicle') {
let found: Node | null = null;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.getComponent(VehicleController)) found = c;
});
return found;
}
if (s.kind === 'prop') {
let found = findLevelChildByName(levelRoot, `Prop_${s.x}_${s.y}`);
if (found) return found;
forEachLevelEntityNode(levelRoot, (c) => {
if (found) return;
const prop = c.getComponent(PropController);
const cell = prop?.getSpawnCell();
if (cell && cell.x === s.x && cell.y === s.y) found = c;
});
return found;
}
if (s.kind === 'prop_decor') {
return findLevelChildByName(levelRoot, 'PropDecor')
?? (() => {
let found: Node | null = null;
forEachLevelEntityNode(levelRoot, (c) => {
if (!found && c.name === 'PropDecor') found = c;
});
return found;
})();
}
return null;
}
private static async applyTexturePathsAsync(
node: Node,
paths: string[],
opts: {
flipX: boolean;
displayKind: EntityDisplayKind;
displayMul: number;
alpha: number;
theme?: string;
},
): Promise<boolean> {
const sf = await this.resolveFirstTexture(paths);
if (!sf || !node?.isValid) return false;
this.commitTexturePaths(node, sf, opts);
return true;
}
private static applyTexturePaths(
node: Node,
paths: string[],
opts: {
flipX: boolean;
displayKind: EntityDisplayKind;
displayMul: number;
alpha: number;
theme?: string;
},
) {
const validPaths = paths
.map((p) => normalizeTexturePath(p))
.filter((p): p is string => !!p);
let sf: SpriteFrame | null = null;
for (const p of validPaths) {
sf = this.getPathFrame(p);
if (sf) break;
}
if (sf) this.commitTexturePaths(node, sf, opts);
}
/** 贴图在根节点;四向贴图已含朝向,根节点 scale 恒为 1 */
private static prepareVehicleSpriteRoot(root: Node) {
const legacy = root.getChildByName('VehicleVisual');
if (legacy?.isValid) legacy.destroy();
root.setScale(1, 1, 1);
}
private static commitTexturePaths(
node: Node,
sf: SpriteFrame,
opts: {
flipX: boolean;
displayKind: EntityDisplayKind;
displayMul: number;
alpha: number;
theme?: string;
},
) {
const { flipX, displayKind, displayMul, alpha, theme } = opts;
const isVehicle = displayKind === 'vehicle';
if (isVehicle) this.prepareVehicleSpriteRoot(node);
const target = node;
let ui = target.getComponent(UITransform);
if (!ui) {
ui = target.addComponent(UITransform);
ui.setContentSize(48, 48);
}
const g = target.getComponent(Graphics);
if (g) g.destroy();
const sprite = target.getComponent(Sprite) || target.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const fit = fitEntityDisplaySize(displayKind, sf, displayMul, theme);
ui.setAnchorPoint(fit.anchorX, fit.anchorY);
ui.setContentSize(fit.width, fit.height);
sprite.color = new Color(255, 255, 255, alpha);
if (isVehicle) {
target.setScale(1, 1, 1);
} else {
target.setScale(flipX ? -1 : 1, 1, 1);
}
}
/** 旧接口:按 SpriteKey + theme 缓存UI 等仍可用) */
static applyPlayerSpriteLegacy(node: Node, direction: Direction, mapTheme?: string, scaleMul = 1) {
const isFront = direction === Direction.South || direction === Direction.East;
const flipX = direction === Direction.West || direction === Direction.East;
const sk: SpriteKey = isFront ? 'player_F' : 'player_B';
this.applySprite(node, sk, flipX, 1, 255, mapTheme, scaleMul, 'player');
}
static applyVehicleSpriteLegacy(node: Node, direction: Direction, mapTheme?: string, scaleMul = 1) {
this.applyVehicleSprite(node, direction, { theme: mapTheme }, scaleMul);
}
static setupEntityVisualLegacy(
node: Node,
kind: SpawnKind,
direction?: Direction,
mapTheme?: string,
scaleMul = 1,
) {
const mul = resolveEntityScaleMul(kind, scaleMul);
if (kind === 'player') {
this.applyPlayerSpriteLegacy(node, direction ?? Direction.South, mapTheme, mul);
return;
}
if (kind === 'vehicle') {
this.applyVehicleSpriteLegacy(node, direction ?? Direction.North, mapTheme, mul);
return;
}
if (kind === 'prop') {
this.applySprite(node, 'coin', false, mul, 255, mapTheme);
return;
}
if (kind === 'prop_decor') {
this.applySprite(node, 'coin', false, mul, 120, mapTheme);
}
}
static applySprite(
node: Node,
key: SpriteKey,
flipX: boolean,
scale = 1,
alpha = 255,
mapTheme?: string,
heightMul = 1,
heightKind?: 'player' | 'vehicle',
) {
let ui = node.getComponent(UITransform);
if (!ui) {
ui = node.addComponent(UITransform);
ui.setContentSize(48, 48);
}
const sf = this.getFrame(key);
const sf = this.getFrame(key, mapTheme);
if (!sf && mapTheme) {
void this.loadOne(key, mapTheme).then((ok) => {
if (ok && node?.isValid) {
this.applySprite(node, key, flipX, scale, alpha, mapTheme, heightMul, heightKind);
}
});
}
const spr = node.getComponent(Sprite);
const g = node.getComponent(Graphics);
@@ -125,12 +883,20 @@ export class VisualAssets {
const sprite = spr || node.addComponent(Sprite);
sprite.spriteFrame = sf;
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
const w = ui.contentSize.width * scale;
const h = ui.contentSize.height * scale;
ui.setContentSize(w, h);
const isPlayer = key === 'player_F' || key === 'player_B';
const isShip = key === 'ship_F' || key === 'ship_B';
if (isPlayer || isShip) {
const kind: EntityDisplayKind = isPlayer ? 'player' : 'vehicle';
const fit = fitEntityDisplaySize(kind, sf, heightMul, mapTheme);
ui.setAnchorPoint(fit.anchorX, fit.anchorY);
ui.setContentSize(fit.width, fit.height);
} else {
const fit = fitEntityDisplaySize('prop', sf, scale, mapTheme);
ui.setAnchorPoint(fit.anchorX, fit.anchorY);
ui.setContentSize(fit.width, fit.height);
}
sprite.color = new Color(255, 255, 255, alpha);
} else {
if (spr) spr.destroy();
} else if (!spr) {
const graphics = g || node.addComponent(Graphics);
graphics.fillColor = key === 'coin'
? new Color(255, 220, 0, alpha)
@@ -139,9 +905,11 @@ export class VisualAssets {
graphics.clear();
graphics.rect(-w, -w, w * 2, w * 2);
graphics.fill();
} else if (g) {
g.destroy();
}
const sx = flipX ? -Math.abs(scale) : Math.abs(scale);
node.setScale(sx, Math.abs(scale), 1);
const sx = flipX ? -1 : 1;
node.setScale(sx, 1, 1);
}
}