290 lines
8.3 KiB
TypeScript
290 lines
8.3 KiB
TypeScript
// 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 {}
|
||
}
|
||
}
|
||
|