469 lines
12 KiB
TypeScript
469 lines
12 KiB
TypeScript
|
|
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");
|
|||
|
|
}
|
|||
|
|
}
|