// assets/scripts/GameManager.ts import { _decorator, Component, Node, Prefab, instantiate, NodePool, UITransform, Vec3, v3, input, Input, EventTouch, SpriteFrame, Sprite, Label, RigidBody2D, Vec2, tween, AudioSource, sys, math, } from 'cc'; import { FRUITS, clampLevel, randomStartLevel } from './FruitConfig'; import { Fruit } from './Fruit'; const { ccclass, property } = _decorator; @ccclass('GameManager') export class GameManager extends Component { @property(Prefab) fruitPrefab!: Prefab; // 统一水果预制 @property([SpriteFrame]) fruitSprites: SpriteFrame[] = []; // 长度需 >= FRUITS.length,按等级顺序 @property(Node) fruitRoot!: Node; @property(Node) preview!: Node; // 顶部预览点,挂 Sprite @property(Node) topSensor!: Node; // 仅用于场景绑定与可视化 @property(Node) walls!: Node; // Left/Right/Floor(用于取边界宽度,可留空) @property(Label) scoreLabel!: Label; @property(Label) bestLabel!: Label; @property(Node) dropLine!: Node; // 危险红线(可选) @property(AudioSource) sfx!: AudioSource; // 可选 // —— 可调参数 —— @property previewY = 560; // 预览/下落的 Y(根据分辨率调整) @property spawnCooldown = 0.2; // 连续投掷冷却 @property previewClampPadding = 40; // 贴边留白 @property randomStartMaxLevel = 5; // 随机初级水果最大等级 // —— 运行时状态 —— nextLevel = 1; // 将要投的等级 queuedLevel = 1; // 下一颗的预告 canDrop = true; isGameOver = false; dangerOn = false; score = 0; best = 0; private pool = new NodePool(); onLoad() { // 把 SpriteFrame 注入配置(可选) for (let i = 0; i < FRUITS.length; i++) { FRUITS[i].sprite = this.fruitSprites[i] || FRUITS[i].sprite; } this.best = Number(sys.localStorage.getItem('best') || 0); if (this.bestLabel) this.bestLabel.string = `${this.best}`; if (this.scoreLabel) this.scoreLabel.string = `0`; } start() { this.bindInput(); this.reset(); } protected onDestroy() { this.unbindInput(); } bindInput() { input.on(Input.EventType.TOUCH_MOVE, this.onMove, this); input.on(Input.EventType.TOUCH_START, this.onMove, this); // 触摸开始也更新预览位置 input.on(Input.EventType.TOUCH_END, this.onDrop, this); } unbindInput() { input.off(Input.EventType.TOUCH_MOVE, this.onMove, this); input.off(Input.EventType.TOUCH_START, this.onMove, this); input.off(Input.EventType.TOUCH_END, this.onDrop, this); } reset() { this.isGameOver = false; this.canDrop = true; this.updateScore(0); // 清场(放回对象池而非直接删) const children = [...this.fruitRoot.children]; for (const c of children) this.recycleFruit(c); // 初始化两个预报等级 this.nextLevel = randomStartLevel(1, this.randomStartMaxLevel); this.queuedLevel = randomStartLevel(1, this.randomStartMaxLevel); this.refreshPreview(); this.setDanger(false); if (this.dropLine) this.dropLine.active = false; } refreshPreview() { if (!this.preview) return; const sp = this.preview.getComponent(Sprite)!; const def = FRUITS[this.nextLevel - 1]; sp.spriteFrame = def.sprite!; const ui = this.preview.getComponent(UITransform)!; ui.setContentSize(def.radius * 2, def.radius * 2); // 初始位置居中(仅设置 X,Y 固定在预览线) const p = this.preview.position; this.preview.active = true; this.preview.setPosition(v3(0, this.previewY, p.z)); } onMove(e: EventTouch) { if (this.isGameOver) return; const loc = e.getUILocation(); // 将屏幕坐标转本地 Canvas 坐标(父节点是 Canvas) const ui = this.node.getComponent(UITransform)!; const local = ui.convertToNodeSpaceAR(new Vec3(loc.x, loc.y, 0)); // 约束 X const halfW = ui.contentSize.width / 2; const padding = this.previewClampPadding; const x = math.clamp(local.x, -halfW + padding, halfW - padding); const p = this.preview.position; this.preview.setPosition(v3(x, this.previewY, p.z)); } onDrop() { if (this.isGameOver || !this.canDrop) return; this.canDrop = false; const dropPos = this.preview.worldPosition; this.spawnFruit(this.nextLevel, dropPos); // 队列前移 & 随机新队列 this.nextLevel = this.queuedLevel; this.queuedLevel = randomStartLevel(1, this.randomStartMaxLevel); this.refreshPreview(); // 冷却 this.scheduleOnce(() => (this.canDrop = true), this.spawnCooldown); } spawnFruit(level: number, worldPos: Vec3, inheritVel?: Vec2) { const node = this.pool.size() > 0 ? this.pool.get()! : instantiate(this.fruitPrefab); node.parent = this.fruitRoot; node.active = true; // 转换到 FruitRoot 的本地坐标 const local = this.fruitRoot.getComponent(UITransform)!.convertToNodeSpaceAR(worldPos.clone()); node.setPosition(local); node.setScale(1, 1, 1); node.setRotationFromEuler(0, 0, 0); const fruit = node.getComponent(Fruit)!; const index = Math.max(0, Math.min(level - 1, FRUITS.length - 1)); const sf = FRUITS[index].sprite!; fruit.init(level, sf, this); // 让它有一点随机水平初速度,手感更“弹” const rb = node.getComponent(RigidBody2D)!; const vx = Math.random() * 200 - 100; rb.linearVelocity = inheritVel || new Vec2(vx, 0); // 轻微缩放弹出效果 node.setScale(0.9, 0.9, 1); tween(node).to(0.1, { scale: v3(1, 1, 1) }).start(); // 可选音效:下落 // this.playSfx(0.1); return fruit; } // 被 Fruit 调用:把两颗同级水果合成到 nextLevel spawnMerged(nextLevel: number, worldMid: Vec3, a: Fruit, b: Fruit) { if (this.isGameOver) return; // 读两者速度,合成后给一个小的向上速度,避免“坍塌” const va = a.getComponent(RigidBody2D)!.linearVelocity; const vb = b.getComponent(RigidBody2D)!.linearVelocity; const inherit = new Vec2((va.x + vb.x) * 0.25, Math.max(120, (va.y + vb.y) * 0.25)); // 回收/删除旧的 this.recycleFruit(a.node); this.recycleFruit(b.node); // 生成新的 const f = this.spawnFruit(clampLevel(nextLevel), worldMid, inherit); // 计分:按目标等级给分 this.updateScore(this.score + FRUITS[clampLevel(nextLevel) - 1].score); // 合成特效:放大闪烁 const n = f.node; const s0 = n.scale.clone(); tween(n) .to(0.06, { scale: v3(s0.x * 1.15, s0.y * 1.15, 1) }) .to(0.08, { scale: v3(1, 1, 1) }) .start(); // 音效:合成 this.playSfx(0.2); } recycleFruit(node: Node) { const fr = node.getComponent(Fruit); if (fr) fr.removed = true; node.removeFromParent(); this.pool.put(node); } updateScore(v: number) { this.score = v; if (this.scoreLabel) this.scoreLabel.string = `${this.score}`; if (this.score > this.best) { this.best = this.score; sys.localStorage.setItem('best', String(this.best)); } if (this.bestLabel) this.bestLabel.string = `${this.best}`; } setDanger(on: boolean) { this.dangerOn = on; if (this.dropLine) this.dropLine.active = on; } onGameOver() { if (this.isGameOver) return; this.isGameOver = true; this.canDrop = false; this.setDanger(true); // 所有水果轻微变暗/缩放(可选) this.fruitRoot.children.forEach((n) => tween(n).to(0.2, { scale: v3(0.95, 0.95, 1) }).start() ); // 失败音 this.playSfx(0.35); } restart() { this.reset(); this.canDrop = true; } playSfx(vol = 0.2) { if (!this.sfx || !this.sfx.clip) return; try { this.sfx.volume = vol; this.sfx.playOneShot(this.sfx.clip, vol); } catch {} } }