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:
@@ -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
9
assets/scripts/audio.meta
Normal file
9
assets/scripts/audio.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "87e5bae2-5b8d-4f00-964a-51a7b4576c20",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
123
assets/scripts/audio/GameAudio.ts
Normal file
123
assets/scripts/audio/GameAudio.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
157
assets/scripts/controller/RideLink.ts
Normal file
157
assets/scripts/controller/RideLink.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/controller/RideLink.ts.meta
Normal file
9
assets/scripts/controller/RideLink.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7152704a-c09b-4ea6-a8e5-291537bc7984",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
136
assets/scripts/controller/ViewController.ts
Normal file
136
assets/scripts/controller/ViewController.ts
Normal 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 ViewController:Orthographic 缩放与拖拽 */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/controller/ViewController.ts.meta
Normal file
9
assets/scripts/controller/ViewController.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e7bb6bb7-3908-413f-9664-06267b8e39fd",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
43
assets/scripts/core/EmbeddedView.ts
Normal file
43
assets/scripts/core/EmbeddedView.ts
Normal 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();
|
||||
}
|
||||
9
assets/scripts/core/EmbeddedView.ts.meta
Normal file
9
assets/scripts/core/EmbeddedView.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "44ae1fbb-1f7b-4afc-b4c7-66938cd25e5d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
33
assets/scripts/core/GridConstants.ts
Normal file
33
assets/scripts/core/GridConstants.ts
Normal 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;
|
||||
|
||||
/** 移动中拾取:步进进度达到该比例且进入道具格后才消失(0–1) */
|
||||
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 };
|
||||
}
|
||||
9
assets/scripts/core/GridConstants.ts.meta
Normal file
9
assets/scripts/core/GridConstants.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f0421cda-510d-4b04-9943-8ed38412858f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
88
assets/scripts/core/GridCoords.ts
Normal file
88
assets/scripts/core/GridCoords.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Vec3 } from 'cc';
|
||||
import { CELL_PIXEL } from './GridConstants';
|
||||
|
||||
/**
|
||||
* 与 Unity Grid 一致:CellLayout Isometric Z-as-Y,CellSize (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 };
|
||||
}
|
||||
9
assets/scripts/core/GridCoords.ts.meta
Normal file
9
assets/scripts/core/GridCoords.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4eaa5ece-8094-453e-a1e1-3b3bbe9162f0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
23
assets/scripts/core/ResourcesBundle.ts
Normal file
23
assets/scripts/core/ResourcesBundle.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/core/ResourcesBundle.ts.meta
Normal file
9
assets/scripts/core/ResourcesBundle.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "db5967b6-5ed9-4793-9b53-486850d4efce",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
206
assets/scripts/gameplay/LineGridRenderer.ts
Normal file
206
assets/scripts/gameplay/LineGridRenderer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
assets/scripts/gameplay/LineGridRenderer.ts.meta
Normal file
9
assets/scripts/gameplay/LineGridRenderer.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "53b54d98-b129-4a11-8891-528a4a62a302",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
56
assets/scripts/gameplay/MoveRules.ts
Normal file
56
assets/scripts/gameplay/MoveRules.ts
Normal 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.OnMoving:targetGridType === 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;
|
||||
}
|
||||
9
assets/scripts/gameplay/MoveRules.ts.meta
Normal file
9
assets/scripts/gameplay/MoveRules.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "261eb8b8-97f9-4df2-9de5-85bc83b9cf52",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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 → ExternalCall(Scratch/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;
|
||||
}
|
||||
|
||||
|
||||
25
assets/scripts/level/EntitySpawnDefaults.ts
Normal file
25
assets/scripts/level/EntitySpawnDefaults.ts
Normal 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,
|
||||
};
|
||||
9
assets/scripts/level/EntitySpawnDefaults.ts.meta
Normal file
9
assets/scripts/level/EntitySpawnDefaults.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "cc10897c-bd78-42c5-8443-ed54a4b1d461",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
185
assets/scripts/level/EntitySpawnPlacement.ts
Normal file
185
assets/scripts/level/EntitySpawnPlacement.ts
Normal 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≈100):Prop ≈ +14px,nProp ≈ -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 含主题站立 Y,vehicle 为 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';
|
||||
}
|
||||
9
assets/scripts/level/EntitySpawnPlacement.ts.meta
Normal file
9
assets/scripts/level/EntitySpawnPlacement.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "20691257-985a-4a52-96da-6696502e60ff",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
366
assets/scripts/level/GridSnapHelper.ts
Normal file
366
assets/scripts/level/GridSnapHelper.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/GridSnapHelper.ts.meta
Normal file
9
assets/scripts/level/GridSnapHelper.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "739b26eb-64a3-4dd7-a084-cbe854d98a36",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
69
assets/scripts/level/LevelConfigMerge.ts
Normal file
69
assets/scripts/level/LevelConfigMerge.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
assets/scripts/level/LevelConfigMerge.ts.meta
Normal file
9
assets/scripts/level/LevelConfigMerge.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4a788bad-35f7-49ff-9c2e-129ae9d74c7b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
282
assets/scripts/level/LevelDatabase.ts
Normal file
282
assets/scripts/level/LevelDatabase.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 关卡数据库 — 由 Cocos 工程导出(level-prefabs LevelMapData + 编辑器 spawns)
|
||||
*
|
||||
* - 编辑器 ▶ 预览:从 assets/level-data/ 加载(不在 resources bundle,fetch 不可用)
|
||||
* - 主站 / 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);
|
||||
}
|
||||
9
assets/scripts/level/LevelDatabase.ts.meta
Normal file
9
assets/scripts/level/LevelDatabase.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8e7c7877-6e68-405a-8f42-0219d15a1bb1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
188
assets/scripts/level/LevelDisplay.ts
Normal file
188
assets/scripts/level/LevelDisplay.ts
Normal 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} 块`);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/LevelDisplay.ts.meta
Normal file
9
assets/scripts/level/LevelDisplay.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "81f9083e-1e28-48b0-bf0e-4d3b5b6dce21",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
7
assets/scripts/level/LevelIds.ts
Normal file
7
assets/scripts/level/LevelIds.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** 与 Unity / 主站 config.js BEGINNING_REAL_LVID 一致;首关 91601 */
|
||||
export const LEVEL_ID_BASE = 91601;
|
||||
|
||||
/** 主站合法关卡 ID:从 BEGINNING_REAL_LVID(91601)起 */
|
||||
export function isGameLevelId(levelID: number): boolean {
|
||||
return Number.isFinite(levelID) && levelID >= LEVEL_ID_BASE;
|
||||
}
|
||||
9
assets/scripts/level/LevelIds.ts.meta
Normal file
9
assets/scripts/level/LevelIds.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f0858c47-3aeb-4416-997f-637c891c074c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
56
assets/scripts/level/LevelMapData.ts
Normal file
56
assets/scripts/level/LevelMapData.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/LevelMapData.ts.meta
Normal file
9
assets/scripts/level/LevelMapData.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
146
assets/scripts/level/LevelPrefabLoader.ts
Normal file
146
assets/scripts/level/LevelPrefabLoader.ts
Normal 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}`);
|
||||
}
|
||||
9
assets/scripts/level/LevelPrefabLoader.ts.meta
Normal file
9
assets/scripts/level/LevelPrefabLoader.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "18fe4dd5-2cb5-4201-ac1c-88b839c28747",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
13
assets/scripts/level/LevelPrefabPaths.ts
Normal file
13
assets/scripts/level/LevelPrefabPaths.ts
Normal 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}`;
|
||||
}
|
||||
9
assets/scripts/level/LevelPrefabPaths.ts.meta
Normal file
9
assets/scripts/level/LevelPrefabPaths.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2d4bcfb4-7972-4c8a-9f0e-a9d07948eb94",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
58
assets/scripts/level/LevelPrefabUuidIndex.ts
Normal file
58
assets/scripts/level/LevelPrefabUuidIndex.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/level/LevelPrefabUuidIndex.ts.meta
Normal file
9
assets/scripts/level/LevelPrefabUuidIndex.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "cd170235-d6e1-4de2-a497-4d24a9b53f34",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
assets/scripts/level/LevelRuntimeContext.ts
Normal file
18
assets/scripts/level/LevelRuntimeContext.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/level/LevelRuntimeContext.ts.meta
Normal file
9
assets/scripts/level/LevelRuntimeContext.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1343d397-b505-4672-b30b-83e1f1628772",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
53
assets/scripts/level/LevelTileLayout.ts
Normal file
53
assets/scripts/level/LevelTileLayout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/LevelTileLayout.ts.meta
Normal file
9
assets/scripts/level/LevelTileLayout.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "22737b72-530f-4caf-8222-1485dde33239",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
96
assets/scripts/level/LevelTileSync.ts
Normal file
96
assets/scripts/level/LevelTileSync.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/level/LevelTileSync.ts.meta
Normal file
9
assets/scripts/level/LevelTileSync.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "84e4a32b-5cd7-475c-9842-fd096a1c72fa",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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 Prop),ground=空地(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;
|
||||
}
|
||||
|
||||
472
assets/scripts/level/TileLayout.ts
Normal file
472
assets/scripts/level/TileLayout.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/level/TileLayout.ts.meta
Normal file
9
assets/scripts/level/TileLayout.ts.meta
Normal 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
@@ -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';
|
||||
|
||||
9
assets/scripts/theme.meta
Normal file
9
assets/scripts/theme.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "67fa2457-bacd-4989-820f-875de805f5b0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
82
assets/scripts/theme/ThemeBackground.ts
Normal file
82
assets/scripts/theme/ThemeBackground.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/theme/ThemeBackground.ts.meta
Normal file
9
assets/scripts/theme/ThemeBackground.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "83a0c5ff-dabf-4448-9aa9-e0f95caee32d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
438
assets/scripts/theme/ThemeDatabase.ts
Normal file
438
assets/scripts/theme/ThemeDatabase.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/theme/ThemeDatabase.ts.meta
Normal file
9
assets/scripts/theme/ThemeDatabase.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "ff990bd4-abf4-4e61-93a1-d602e00ea740",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
38
assets/scripts/theme/ThemeRegistry.ts
Normal file
38
assets/scripts/theme/ThemeRegistry.ts
Normal 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';
|
||||
9
assets/scripts/theme/ThemeRegistry.ts.meta
Normal file
9
assets/scripts/theme/ThemeRegistry.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "02caf4b6-3db1-455e-9293-6caad43db09f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
119
assets/scripts/theme/ThemeTypes.ts
Normal file
119
assets/scripts/theme/ThemeTypes.ts
Normal 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_PIXEL(100)的宽高比例(运行时由 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';
|
||||
9
assets/scripts/theme/ThemeTypes.ts.meta
Normal file
9
assets/scripts/theme/ThemeTypes.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8aa76f83-d412-4a87-9ac9-72080487110d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
151
assets/scripts/ui/GameplayDebugBar.ts
Normal file
151
assets/scripts/ui/GameplayDebugBar.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/ui/GameplayDebugBar.ts.meta
Normal file
9
assets/scripts/ui/GameplayDebugBar.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bdc2f2db-a739-4cc1-a5d8-84ca0303d703",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
161
assets/scripts/ui/LevelSwitchBar.ts
Normal file
161
assets/scripts/ui/LevelSwitchBar.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/ui/LevelSwitchBar.ts.meta
Normal file
9
assets/scripts/ui/LevelSwitchBar.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d0e55793-0083-4720-af9d-153fabbbc424",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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 UIMain,JS 可 SendMessage("UIMain", "SetText", ...) */
|
||||
type IconSlot = { node: Node; sprite: Sprite };
|
||||
type BtnLayout = { node: Node };
|
||||
|
||||
/** Unity UIMain/Right:VerticalLayout 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();
|
||||
};
|
||||
}
|
||||
|
||||
90
assets/scripts/ui/UIStyleAssets.ts
Normal file
90
assets/scripts/ui/UIStyleAssets.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/ui/UIStyleAssets.ts.meta
Normal file
9
assets/scripts/ui/UIStyleAssets.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bd61a294-d993-404c-8818-cd71d51557eb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
157
assets/scripts/visual/EntityDisplayRefs.ts
Normal file
157
assets/scripts/visual/EntityDisplayRefs.ts
Normal 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 补全 originalSize(ImageAsset 直载时 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));
|
||||
}
|
||||
9
assets/scripts/visual/EntityDisplayRefs.ts.meta
Normal file
9
assets/scripts/visual/EntityDisplayRefs.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c5ea903f-83fc-42e9-8721-5540fc38c79f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
318
assets/scripts/visual/EntityTextureResolver.ts
Normal file
318
assets/scripts/visual/EntityTextureResolver.ts
Normal 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_kuai2;Prop → 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;
|
||||
}
|
||||
9
assets/scripts/visual/EntityTextureResolver.ts.meta
Normal file
9
assets/scripts/visual/EntityTextureResolver.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f794ac4d-27dd-4b75-ac42-dabe97cdb679",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
335
assets/scripts/visual/PlayerActionAnimator.ts
Normal file
335
assets/scripts/visual/PlayerActionAnimator.ts
Normal 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 0–4)。
|
||||
* 无 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);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/visual/PlayerActionAnimator.ts.meta
Normal file
9
assets/scripts/visual/PlayerActionAnimator.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "bbfaa54a-c152-430e-bc7a-ad567f25bf85",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
65
assets/scripts/visual/PlayerAnimPaths.ts
Normal file
65
assets/scripts/visual/PlayerAnimPaths.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/visual/PlayerAnimPaths.ts.meta
Normal file
9
assets/scripts/visual/PlayerAnimPaths.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "5c42d6d0-2388-470a-a738-9e2640194d51",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
163
assets/scripts/visual/ThemeEntityTextures.ts
Normal file
163
assets/scripts/visual/ThemeEntityTextures.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/visual/ThemeEntityTextures.ts.meta
Normal file
9
assets/scripts/visual/ThemeEntityTextures.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "37205258-4b45-4090-bc1b-e8178c3664f2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
89
assets/scripts/visual/TileDisplayMeta.ts
Normal file
89
assets/scripts/visual/TileDisplayMeta.ts
Normal 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;
|
||||
/** 相对格子宽度的额外缩放(默认 1,snow 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;
|
||||
}
|
||||
9
assets/scripts/visual/TileDisplayMeta.ts.meta
Normal file
9
assets/scripts/visual/TileDisplayMeta.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "922a514b-1242-4a5e-be13-fac729161727",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
23
assets/scripts/visual/TilePivots.ts
Normal file
23
assets/scripts/visual/TilePivots.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/visual/TilePivots.ts.meta
Normal file
9
assets/scripts/visual/TilePivots.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "97cf4d35-2a8a-4e57-a6fc-a45061f9988a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
64
assets/scripts/visual/TileSizes.ts
Normal file
64
assets/scripts/visual/TileSizes.ts
Normal 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 };
|
||||
}
|
||||
9
assets/scripts/visual/TileSizes.ts.meta
Normal file
9
assets/scripts/visual/TileSizes.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "13aecf34-b260-4097-8817-6683b6ed2960",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user