first commit

This commit is contained in:
zzc
1970-01-01 08:23:24 +08:00
commit 40987bb7fd
121 changed files with 10935 additions and 0 deletions

77
assets/scripts/Board.ts Normal file
View File

@@ -0,0 +1,77 @@
import { _decorator, Node, Prefab, instantiate, SpriteFrame, tween, Vec3, Sprite } from "cc";
import { Fruit } from "./Fruit";
import { FruitNode } from "./FruitNode";
const { ccclass, property } = _decorator;
@ccclass("Board")
export class Board extends Node {
@property(Prefab) fruitPrefab: Prefab = null!;
@property([SpriteFrame]) fruitSprites: SpriteFrame[] = [];
board: (Fruit | null)[][] = [];
rows = 4;
cols = 4;
node: Node = null!;
onLoad() {
this.initBoard();
}
initBoard() {
this.board = [];
for (let r = 0; r < this.rows; r++) {
this.board[r] = [];
for (let c = 0; c < this.cols; c++) {
this.board[r][c] = null;
}
}
this.spawnFruit();
this.spawnFruit();
}
spawnFruit() {
let emptyCells: { r: number; c: number }[] = [];
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
if (!this.board[r][c]) emptyCells.push({ r, c });
}
}
if (emptyCells.length === 0) return;
let index = Math.floor(Math.random() * emptyCells.length);
let cell = emptyCells[index];
let node = instantiate(this.fruitPrefab);
node.setParent(this.node);
node.setPosition(new Vec3(cell.c * 100, cell.r * -100, 0));
let fruit = new Fruit(node, cell.r, cell.c, 1);
node.getComponent(FruitNode)!.init(fruit, this);
node.getComponent(Sprite)!.spriteFrame = this.fruitSprites[0];
this.board[cell.r][cell.c] = fruit;
}
merge(f1: Fruit, f2: Fruit) {
if (f1.level !== f2.level) return;
f1.level += 1;
if (f1.level - 1 < this.fruitSprites.length)
f1.node.getComponent(Sprite)!.spriteFrame = this.fruitSprites[f1.level - 1];
this.playMergeAnimation(f1.node, f2.node);
this.board[f2.row][f2.col] = null;
f2.node.destroy();
this.spawnFruit();
}
playMergeAnimation(n1: Node, n2: Node) {
// tween(n1)
// .to(0.2, { scale: 1.2 })
// .to(0.1, { scale: 1 })
// .start();
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "6308cbf7-e95c-403f-922c-d21de1892e99",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,23 @@
import { _decorator, Node, Sprite } from "cc";
const { ccclass, property } = _decorator;
@ccclass("Fruit")
export class Fruit {
id: number = 0;
type: string = 'watermelon';
level: number = 1;
node: Node = null!;
row: number = 0;
col: number = 0;
constructor(node: Node, row: number, col: number, level: number = 1) {
this.node = node;
this.row = row;
this.col = col;
this.level = level;
}
setSprite(sprite: Sprite) {
this.node.getComponent(Sprite)!.spriteFrame = sprite.spriteFrame;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ff426be0-7138-4c1a-ae7a-1e8fbe8ecf48",
"files": [],
"subMetas": {},
"userData": {}
}

74
assets/scripts/Fruit.ts Normal file
View File

@@ -0,0 +1,74 @@
import { _decorator, Component, Sprite, SpriteFrame, UITransform, Size, CircleCollider2D, Contact2DType, IPhysics2DContact, RigidBody2D, Vec2, Node } from 'cc';
import { FRUITS, MAX_LEVEL } from './FruitConfig';
import { GameManager } from './GameManager';
const { ccclass, property } = _decorator;
@ccclass('Fruit')
export class Fruit extends Component {
@property
level = 1; // 1..MAX_LEVEL
@property(Sprite)
sprite!: Sprite;
game!: GameManager;
collider!: CircleCollider2D;
body!: RigidBody2D;
// 防止同一对重复合成
merging = false;
removed = false;
init(level: number, spriteFrame: SpriteFrame, game: GameManager) {
this.level = level;
this.game = game;
if (!this.sprite) this.sprite = this.getComponent(Sprite)!;
this.sprite.spriteFrame = spriteFrame;
const def = FRUITS[this.level - 1];
const ui = this.getComponent(UITransform)!;
ui.setContentSize(new Size(def.radius * 2, def.radius * 2));
this.collider = this.getComponent(CircleCollider2D)!;
this.collider.radius = def.radius;
this.body = this.getComponent(RigidBody2D)!;
this.body.enabled = true;
this.merging = false;
this.removed = false;
}
onEnable() {
const col = this.getComponent(CircleCollider2D);
if (col) col.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
}
onDisable() {
const col = this.getComponent(CircleCollider2D);
if (col) col.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
}
private onBeginContact(selfCol: CircleCollider2D, otherCol: CircleCollider2D, _contact: IPhysics2DContact | null) {
if (this.removed || this.merging) return;
const otherNode = otherCol.node as Node;
const otherFruit = otherNode.getComponent(Fruit);
if (!otherFruit || otherFruit.removed || otherFruit.merging) return;
// 仅同级可合成,且不能超过最大级
if (otherFruit.level !== this.level || this.level >= MAX_LEVEL) return;
// 为避免两者都合成,使用 uuid 决定触发者(只让“较小”的触发)
if (this.node.uuid > otherNode.uuid) return;
this.merging = true;
otherFruit.merging = true;
// 合成到下一级
const nextLevel = this.level + 1;
const wpA = this.node.worldPosition;
const wpB = otherNode.worldPosition;
const mid = wpA.clone().add(wpB).multiplyScalar(0.5);
this.game.spawnMerged(nextLevel, mid, this, otherFruit);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "396f863c-03c8-48a2-970c-d60402224f68",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,37 @@
// assets/scripts/FruitConfig.ts
import { SpriteFrame } from 'cc';
export interface FruitDef {
level: number; // 1..MAX
name: string;
radius: number; // 物理半径(像素)
score: number; // 合成到该等级获得的分数
sprite?: SpriteFrame; // 运行时由 GameManager 注入
}
export const MAX_LEVEL = 11; // 西瓜
// 半径与分数可按手感微调(半径越大越难堆)
export const FRUITS: FruitDef[] = [
{ level: 1, name: '樱桃', radius: 26, score: 1 },
{ level: 2, name: '草莓', radius: 30, score: 2 },
{ level: 3, name: '葡萄', radius: 34, score: 4 },
{ level: 4, name: '橘子', radius: 38, score: 8 },
{ level: 5, name: '柠檬', radius: 44, score: 16 },
{ level: 6, name: '猕猴桃', radius: 50, score: 24 },
{ level: 7, name: '番茄', radius: 58, score: 36 },
{ level: 8, name: '桃子', radius: 66, score: 48 },
{ level: 9, name: '菠萝', radius: 74, score: 64 },
{ level: 10, name: '哈密瓜', radius: 84, score: 96 },
{ level: 11, name: '西瓜', radius: 96, score: 128 },
];
export function clampLevel(lv: number) {
if (lv < 1) return 1; if (lv > MAX_LEVEL) return MAX_LEVEL; return lv;
}
export function randomStartLevel(min=1, max=5) {
const a = Math.max(1, Math.min(MAX_LEVEL-1, min));
const b = Math.max(a, Math.min(MAX_LEVEL-1, max));
return Math.floor(Math.random() * (b - a + 1)) + a;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a88fb8bc-e48e-4301-82ba-479028f64ba3",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,43 @@
import { _decorator, Component, Node, Vec3, EventTouch, tween } from "cc";
import { Fruit } from "./Fruit";
import { Board } from "./Board";
const { ccclass, property } = _decorator;
@ccclass("FruitNode")
export class FruitNode extends Component {
public fruitId: number = 0
// fruit: Fruit = null!;
// board: Board = null!;
// init(fruit: Fruit, board: Board) {
// this.fruit = fruit;
// this.board = board;
// this.node.on(Node.EventType.TOUCH_MOVE, this.onDragMove, this);
// this.node.on(Node.EventType.TOUCH_END, this.onDragEnd, this);
// }
// onDragMove(event: EventTouch) {
// let delta = event.getDelta();
// this.node.position = this.node.position.add(new Vec3(delta.x, delta.y, 0));
// }
// onDragEnd() {
// for (let r = 0; r < this.board.rows; r++) {
// for (let c = 0; c < this.board.cols; c++) {
// let target = this.board.board[r][c];
// if (target && target !== this.fruit) {
// let dist = this.node.position.subtract(target.node.position).length();
// if (dist < 50) {
// this.board.merge(this.fruit, target);
// this.node.setPosition(target.node.position);
// return;
// }
// }
// }
// }
// // 回到原位
// this.node.setPosition(new Vec3(this.fruit.col * 100, this.fruit.row * -100, 0));
// }
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "39730913-959d-42d1-ba71-c6e467df3725",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,73 @@
import { _decorator, Component, Prefab,Node, instantiate, RigidBody2D, input, Input, EventTouch, UITransform, v3, Collider2D, Contact2DType } from "cc";
import { Board } from "./Board";
import { FruitNode } from "./FruitNode";
const { ccclass, property } = _decorator;
@ccclass("GameManager")
export class GameManager extends Component {
@property(Node)
fruitStart: Node = null;
@property(Node)
fruitParent: Node = null;
@property(Prefab)
fruitPrefabs: Prefab[] = [];
private _curFruit: Node = null;
start() {
this.createFruit()
input.on(Input.EventType.TOUCH_START, this.handelTouchStart, this)
input.on(Input.EventType.TOUCH_MOVE, this.handelTouchMove, this)
input.on(Input.EventType.TOUCH_END, this.handelTouchEnd, this)
}
handelTouchStart(event: EventTouch) {
}
handelTouchMove(event) {
const curPos = event.getUILocation();
const parentPos = this.fruitParent.getComponent(UITransform).convertToNodeSpaceAR(v3(curPos.x, curPos.y, 0));
// 当前水果坐标
const fruitPos = this._curFruit.getPosition();
fruitPos.x = parentPos.x
this._curFruit.setPosition(fruitPos);
}
handelTouchEnd(event) {
if (!this._curFruit) return;
this._curFruit.getComponent(RigidBody2D).enabled = true;
this._curFruit = null;
this.scheduleOnce(this.createFruit, 2)
}
private createFruit() {
const fruitId = Math.floor(Math.random() * 3);
const fruitNode = instantiate(this.fruitPrefabs[fruitId]);
fruitNode.setPosition(this.fruitStart.position);
fruitNode.getComponent(RigidBody2D).enabled = false;
fruitNode.getComponent(FruitNode).fruitId = fruitId
// 监听水果碰撞事件
const collider = fruitNode.getComponent(Collider2D);
collider.on(Contact2DType.BEGIN_CONTACT, this.handelBeginContact, this)
fruitNode.getComponent(RigidBody2D).gravityScale = 3; // 重力
this.fruitParent.addChild(fruitNode);
this._curFruit = fruitNode;
}
handelBeginContact(selfContact: Collider2D, otherContact: Collider2D) {
if(otherContact.group !== Math.pow(2, 1)) return;
console.log(111)
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "08c0e1a0-ea3f-4fd8-a1e7-d9a8db0ca468",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,290 @@
// 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);
// 初始位置居中(仅设置 XY 固定在预览线)
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 {}
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "28f0c3a2-ddde-4889-b568-9e2648bd280d",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,48 @@
import { _decorator, Component, BoxCollider2D, Contact2DType, IPhysics2DContact, Node, Color } from 'cc';
import { Fruit } from './Fruit';
import { GameManager } from './GameManager';
const { ccclass, property } = _decorator;
@ccclass('TopSensor')
export class TopSensor extends Component {
@property(GameManager)
game!: GameManager;
@property
holdSeconds = 2.0; // 触顶持续秒数
private touching = new Map<Node, number>(); // node -> enterTime
onEnable() {
const col = this.getComponent(BoxCollider2D)!;
col.sensor = true;
col.on(Contact2DType.BEGIN_CONTACT, this.onBegin, this);
col.on(Contact2DType.END_CONTACT, this.onEnd, this);
}
onDisable() {
const col = this.getComponent(BoxCollider2D)!;
col.off(Contact2DType.BEGIN_CONTACT, this.onBegin, this);
col.off(Contact2DType.END_CONTACT, this.onEnd, this);
}
private onBegin(_self: BoxCollider2D, other: any, _c: IPhysics2DContact | null) {
const f = (other.node as Node).getComponent(Fruit);
if (f) this.touching.set(other.node, performance.now() / 1000);
}
private onEnd(_self: BoxCollider2D, other: any) {
this.touching.delete(other.node);
}
update() {
if (!this.game || this.game.isGameOver) return;
const now = performance.now() / 1000;
for (const [node, t] of this.touching) {
if (now - t >= this.holdSeconds) {
this.game.onGameOver();
break;
}
}
// 可选:把状态交给 GameManager 显示红线闪烁
this.game.setDanger(this.touching.size > 0);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "945e3d70-b62b-4d2b-a9f0-a595ef1021b6",
"files": [],
"subMetas": {},
"userData": {}
}