import { _decorator, Component, Node, Prefab, SpriteFrame, input, Input, NodePool, Script, Sprite, UITransform, v3, Vec3, math, Vec2, instantiate, RigidBody2D, tween, ERigidBody2DType, CircleCollider2D, PhysicsSystem2D, sys, Label, UIOpacity, director, ParticleSystem2D, Color, EventTouch, view, AudioClip, } from "cc"; import { AudioManager, BGMType, SFXType } from "./AudioManager"; import { Fruit } from "./Fruit"; const { ccclass, property } = _decorator; import { FRUITS, clampLevel, randomStartLevel } from "./FruitConfig"; @ccclass("GameManager") export class GameManager extends Component { @property(Prefab) fruitPrefab!: Prefab; // 统一水果预制 @property([SpriteFrame]) fruitSprites: SpriteFrame[] = []; // 长度需 >= FRUITS.length,按等级顺序 @property(Node) fruitRoot!: Node; @property(Node) dropLine!: Node; @property(Node) preview!: Node; // 顶部预览点,挂 Sprite @property(Node) nextPreview!: Node; @property(Node) topSensor!: Node; @property(Node) resultNode: Node = null; @property(Label) resultLabel: Label = null; @property(Label) scoreLabel!: Label; @property(Label) bestLabel!: Label; @property(Prefab) explosionPrefab: Prefab = null!; @property(AudioClip) mergeSFX: AudioClip = null!; @property(AudioClip) failSFX: AudioClip = null!; @property(AudioClip) victorySFX: AudioClip = null!; @property(AudioClip) playBgm: AudioClip = null; // —— 可调参数 —— @property previewY = 560; // 预览/下落的 Y(根据分辨率调整) @property randomStartMaxLevel = 5; // 随机初级水果最大等级 @property previewClampPadding = 40; // 贴边留白 @property spawnCooldown = 1; // 连续投掷冷却 dangerOn = false; // —— 运行时状态 —— nextLevel = 1; // 将要投的等级 queuedLevel = 1; // 下一颗的预告 canDrop = true; isGameOver = false; score = 0; best = 0; private pool = new NodePool(); onLoad() { AudioManager.instance.registerBGM(BGMType.Play, this.playBgm); AudioManager.instance.registerSFX(SFXType.Merge, this.mergeSFX); AudioManager.instance.registerSFX(SFXType.Fail, this.failSFX); AudioManager.instance.registerSFX(SFXType.Victory, this.victorySFX); AudioManager.instance.setCurBgm(BGMType.Play); AudioManager.instance.playBGM(BGMType.Play); 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`; } protected onDestroy() { this.unbindInput(); } start() { this.bindInput(); this.reset(); PhysicsSystem2D.instance.gravity = new Vec2(0, -800); } 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; // TODO 分数 this.updateScore(0); // 清场(放回对象池而非直接删) const children = [...this.fruitRoot.children]; for (const c of children) this.recycleFruit(c); // 初始化两个预报等级 this.nextLevel = randomStartLevel(1, this.randomStartMaxLevel, this.score); this.queuedLevel = randomStartLevel( 1, this.randomStartMaxLevel, this.score ); this.refreshPreview(); this.refreshNextPreview(); this.setDanger(false); } setDanger(on: boolean) { this.dangerOn = on; } recycleFruit(node: Node) { const fr = node.getComponent(Fruit); if (fr) fr.removed = true; node.removeFromParent(); this.pool.put(node); } refreshPreview() { 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)); } refreshNextPreview() { const sp = this.nextPreview.getComponent(Sprite); const def = FRUITS[this.queuedLevel - 1]; sp.spriteFrame = def.sprite!; const ui = this.nextPreview.getComponent(UITransform)!; ui.setContentSize(def.radius * 2, def.radius * 2); // 初始位置居中(仅设置 X,Y 固定在预览线) const p = this.nextPreview.position; this.nextPreview.active = true; // this.nextPreview.setPosition(v3(100, this.previewY, p.z)); } onMove(e: EventTouch) { if (this.isGameOver) return; const screenPos = e.getUILocation(); const canvasUI = this.node.getComponent(UITransform)!; const localPos = canvasUI.convertToNodeSpaceAR( new Vec3(screenPos.x, screenPos.y, 0) ); // 获取预览水果世界宽度 const fruitWorldWidth = this.preview.getComponent(UITransform)!.contentSize.width * this.preview.worldScale.x; // 获取 Canvas 世界半宽 const canvasWorldHalfWidth = view.getVisibleSize().width / 2; const clampedX = math.clamp( localPos.x, -canvasWorldHalfWidth + fruitWorldWidth / 2, canvasWorldHalfWidth - fruitWorldWidth / 2 ); this.preview.setPosition( v3(clampedX, this.previewY, this.preview.position.z) ); } onDrop() { if (this.isGameOver || !this.canDrop) return; // 禁止再次投掷 this.canDrop = false; // 当前水果下落位置 const dropPos = this.preview.worldPosition; // 1️⃣ 生成当前水果 this.spawnFruit(this.nextLevel, dropPos); // 2️⃣ 隐藏预览,防止连续操作 this.preview.active = false; // 3️⃣ 延迟 1 秒刷新队列和显示预览 this.scheduleOnce(() => { // 队列前移 this.nextLevel = this.queuedLevel; this.queuedLevel = randomStartLevel( 1, this.randomStartMaxLevel, this.score ); // 刷新预览 this.refreshPreview(); this.refreshNextPreview(); this.preview.active = true; // 恢复投掷 this.canDrop = true; }, 0.6); // 延迟 1 秒 } spawnFruit(level: number, worldPos: Vec3, inheritVel?: Vec2) { // 1️⃣ 获取节点:从对象池或者实例化 const node = this.pool.size() > 0 ? this.pool.get()! : instantiate(this.fruitPrefab); node.parent = this.fruitRoot; node.active = true; // 2️⃣ 获取水果半宽(世界显示宽度) const fruitRadius = FRUITS[level - 1].radius; const fruitUI = node.getComponent(UITransform)!; const fruitHalfWidth = (fruitUI.contentSize.width * node.worldScale.x) / 2; // 3️⃣ 获取 Canvas / Root 容器缩放比例 const rootUI = this.fruitRoot.getComponent(UITransform)!; const canvasScaleX = view.getVisibleSize().width / rootUI.contentSize.width; const halfRootW = (rootUI.contentSize.width * canvasScaleX) / 2; // 4️⃣ 转换到 FruitRoot 坐标系 const local = rootUI.convertToNodeSpaceAR(worldPos.clone()); // 5️⃣ 限制 X 坐标不超出屏幕边界 local.x = math.clamp( local.x, -halfRootW + fruitHalfWidth, halfRootW - fruitHalfWidth ); // 6️⃣ 设置初始位置、缩放、旋转 node.setPosition(local); node.setScale(1, 1, 1); node.setRotationFromEuler(0, 0, 0); // 7️⃣ 初始化组件 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); // 8️⃣ 物理恢复 const rb = node.getComponent(RigidBody2D)!; rb.enabled = true; rb.linearVelocity = inheritVel || new Vec2(0, -10); rb.angularVelocity = 0; const col = node.getComponent(CircleCollider2D)!; if (col) col.enabled = true; // 9️⃣ 出场缩放动画 node.setScale(0.9, 0.9, 1); tween(node) .to(0.1, { scale: v3(1, 1, 1) }) .start(); return fruit; } 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}`; } // 被 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) // ); const inherit = new Vec2( (va.x + vb.x) * 0.25, Math.max(3, (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 explosion = instantiate(this.explosionPrefab); const canvas = director.getScene().getChildByName("Canvas")!; canvas.addChild(explosion); explosion.setWorldPosition(worldMid); explosion.setSiblingIndex(canvas.children.length - 1); const ps = explosion.getComponent(ParticleSystem2D); if (ps) { ps.resetSystem(); const brightColors = [ new Color(255, 255, 0), // 黄色 new Color(255, 128, 0), // 橙色 new Color(255, 0, 0), // 红色 new Color(0, 255, 255), // 青色 new Color(128, 0, 255), // 紫色 ]; // 随机给粒子选择颜色 ps.startColor = brightColors[Math.floor(Math.random() * brightColors.length)]; // 可选:结束颜色渐变为透明 ps.endColor = new Color( ps.startColor.r, ps.startColor.g, ps.startColor.b, 0 ); } this.scheduleOnce(() => { explosion.destroy(); }, 1.5); // 粒子持续时间 const circle = new Node(); const sp = circle.addComponent(Sprite); sp.spriteFrame = this.fruitSprites[nextLevel - 1]; // 你可以换一张发光圈贴图 circle.parent = this.fruitRoot; circle.setPosition(worldMid); circle.setScale(0, 0, 1); // 添加透明度组件 const uiOpacity = circle.addComponent(UIOpacity); uiOpacity.opacity = 255; // 并行动画:缩放 + 渐隐 tween(circle) .to(0.3, { scale: v3(2, 2, 1) }) .call(() => circle.destroy()) .start(); tween(uiOpacity).to(0.3, { opacity: 0 }).start(); // 合成特效:放大闪烁 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(); AudioManager.instance.playSFX(SFXType.Merge); // const nextFruit = FRUITS[nextLevel+1].name // if(nextFruit === '西瓜') this.gameOver(true) // 音效:合成 // this.playSfx(0.2); } gameOver(success = false) { 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() // ); // AudioManager.instance.playSFX(SFXType.Fail); this.resultShow(success); // 失败音 // this.playSfx(0.35); } resultShow(success: boolean) { this.resultNode.active = true; if (!success) { AudioManager.instance.playSFX(SFXType.Fail); this.resultLabel.string = "哎萌巴巴"; } else { AudioManager.instance.playSFX(SFXType.Victory); this.resultLabel.string = "恭喜哎萌"; } } startNewGame() { director.loadScene("game"); } }