重构: 合成大作战改为拖拽合成机制

- 全部代码重写,改为拖拽合成(drag-and-drop merge)
- 拖拽方块到相邻同等级方块上完成合成
- 拖到空格移动,拖到无效位置弹回
- Cocos Creator 3.8.8 全面适配
- 移除 GameManager.ts → 改为 GameController.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 12:12:39 +08:00
parent a90a8c7aed
commit e9e6ddb8a7
11 changed files with 594 additions and 1434 deletions

6
.gitignore vendored
View File

@@ -1,12 +1,10 @@
# Cocos Creator project
# Cocos Creator
.DS_Store
*.local.json
library/
local/
temp/
build/
node_modules/
*.log
# Claude Code
*.local.json
.claude/

View File

@@ -1,345 +0,0 @@
# 合成大作战 - Cocos Creator 3.8.8 教程
## 目录
1. [环境准备](#1-环境准备)
2. [打开项目](#2-打开项目)
3. [创建场景(首次必需)](#3-创建场景首次必需)
4. [运行游戏](#4-运行游戏)
5. [游戏玩法说明](#5-游戏玩法说明)
6. [项目结构](#6-项目结构)
7. [代码开发流程](#7-代码开发流程)
8. [发布游戏](#8-发布游戏)
9. [常见问题](#9-常见问题)
---
## 1. 环境准备
### 下载安装 Cocos Creator 3.8.8
从 [Cocos 官网](https://www.cocos.com/creator-download) 下载 **Cocos Creator 3.8.8** 版本并安装。
> 注意:需要 3.8.x 系列版本,推荐 3.8.8。
安装完成后,启动 Cocos Creator你会看到 Dashboard仪表盘界面。
---
## 2. 打开项目
### 方式一:通过 Dashboard
1. 打开 Cocos Creator
2. 在 Dashboard 中点击 **打开其他项目**
3. 浏览到本项目的根目录 `G:\code\cocos-hehe`
4. 点击 **打开**
### 方式二:通过菜单
1. 在 Cocos Creator 中点击 **项目 → 打开项目**(或快捷键 Ctrl+O
2. 选择本项目的根目录
3. 点击打开
### 等待导入
首次打开时,编辑器会自动:
- 扫描 `assets/` 目录下的所有资源
- 为每个资源生成 `.meta` 文件UUID 映射)
- 编译 TypeScript 脚本
- 这个过程可能需要 10-30 秒,请耐心等待
导入完成后,你会在 **资源管理器** (Assets) 面板中看到以下结构:
```
assets
└── Scripts
├── BlockConfig.ts # 方块配置(颜色、等级、尺寸)
├── Block.ts # 方块组件(渲染、动画)
├── GameBoard.ts # 棋盘组件(网格、合并逻辑)
└── GameManager.ts # 主控制器UI、输入、游戏流程
```
---
## 3. 创建场景(首次必需)
项目代码中**不包含场景文件**(因为场景格式是版本特定的),你需要手动创建一个:
### 步骤
1.**资源管理器** 中右键点击 `assets` 文件夹
2. 选择 **创建 → Scene**(或 Ctrl+N
3. 将场景命名为 `Game`(双击可重命名)
4. 双击打开场景 `Game`
### 设置场景
1.**层级管理器** (Hierarchy) 中,你会看到一个 `Scene` 节点
2.`Scene` 节点上右键,选择 **创建 → UI 组件 → Canvas**
- 此时会创建一个 `Canvas` 节点(包含 Canvas 和 UITransform 组件)
3. 点击选中 `Canvas` 节点
4.**属性检查器** (Inspector) 中找到 Canvas 组件
5.**设计分辨率 (Design Resolution)** 设为:
- **Width**: 580
- **Height**: 960
6. **Fit Height**: 勾选 ✅
### 附加脚本
1.**资源管理器** 中找到 `Scripts/GameManager.ts`
2. 拖拽到 `Canvas` 节点上(或者在 Canvas 的 Inspector 中点击 **添加组件 → 自定义脚本 → GameManager**
3. 确认 GameManager 组件已出现在 Canvas 的 Inspector 中
### 保存场景
**Ctrl+S** 保存场景。
---
## 4. 运行游戏
### 在编辑器中预览
点击编辑器顶部的 **播放 ▶** 按钮(或按 Ctrl+P
游戏将在 **Game 预览窗口** 中启动。
### 模拟手机设备
1. 在 Game 窗口顶部的下拉菜单中,选择一个移动设备分辨率
- 推荐: **iPhone 6/7/8 (375×667)****Galaxy S5 (360×640)**
2. 点击播放按钮
### 浏览器中预览
编辑器预览默认使用内置浏览器窗口。你也可以:
1. 点击 **播放 → 在浏览器中预览**
2. 选择 Chrome 或其他浏览器
---
## 5. 游戏玩法说明
### 核心规则
| 项目 | 说明 |
|------|------|
| 棋盘 | 8列 × 12行 |
| 方块等级 | 1~8级共8种颜色 |
| 合成规则 | 两个**相邻同等级**方块合成,等级+1 |
| 方块生成 | 每3秒在随机空格生成一个新方块 |
| 游戏结束 | 棋盘满时游戏结束 |
### 等级与颜色对照
| 等级 | 颜色 | 名称 |
|:----:|:----:|:----:|
| 1 | 浅绿 `#A8E6CF` | 草 |
| 2 | 深绿 `#4CAF50` | 木 |
| 3 | 蓝色 `#2196F3` | 水 |
| 4 | 紫色 `#9C27B0` | 风 |
| 5 | 橙色 `#FF9800` | 火 |
| 6 | 红色 `#F44336` | 雷 |
| 7 | 粉色 `#E91E63` | 光 |
| 8 | 金色 `#FFD700` | 神 |
### 操作方法
| 操作 | 说明 |
|------|------|
| **点击方块** | 选中方块(白色高亮边框) |
| **点击相邻同等级方块** | 合成升级 |
| **点击空白格** | 移动选中方块到该位置 |
| **点击其他方块** | 切换选中目标 |
### 计分规则
- 合成出 **Lv.N** 的方块 = N × 10 分
- 例如:合成出 Lv.5(火)= 50 分
- 链式合成:一次移动可能触发多次合成,分数累加
### 策略技巧
1. **优先合成高等级方块**:高等级方块得分更高
2. **清理低级方块**:低级太多会快速填满棋盘
3. **规划移动路径**:利用空格作为临时存放区
4. **链式合成**:一次移动触发多次合并是关键得分手段
---
## 6. 项目结构
```
cocos-hehe/
├── assets/ # 游戏资源目录(编辑器主要工作区)
│ └── Scripts/
│ ├── BlockConfig.ts # 方块颜色与等级配置
│ ├── Block.ts # 方块可视化组件
│ ├── GameBoard.ts # 棋盘网格管理
│ └── GameManager.ts # 游戏主控制器
├── settings/ # 编辑器设置(自动生成)
├── index.html # 独立可玩 HTML 版(无编辑器也可运行)
├── package.json # 项目描述文件
├── tsconfig.json # TypeScript 编译配置
└── TUTORIAL.md # 本教程
```
### 脚本职责
| 文件 | 主要职责 | 关键方法 |
|------|---------|---------|
| `BlockConfig.ts` | 配置常量、工具函数 | `getBlockConfig()`, `getRandomSpawnLevel()` |
| `Block.ts` | 方块外观、动画 | `init()`, `upgradeTo()`, `setSelected()` |
| `GameBoard.ts` | 网格管理、合并逻辑 | `spawnRandom()`, `tryMerge()`, `triggerMergeCheck()` |
| `GameManager.ts` | 游戏流程、UI、输入 | `onTouchEnd()`, `startGame()`, `gameOver()` |
### 数据流
```
GameManager (主控)
├── GameBoard (网格 + 合并)
│ └── Block[] (方块实例)
├── Label (UI)
└── Node (GameOver 遮罩)
```
---
## 7. 代码开发流程
### 修改代码后
1. 保存 `.ts` 文件
2. **Ctrl+S** 保存
3. Cocos Creator 会自动检测并重新编译
4.**Ctrl+P** 重新预览
> 如果编译报错,请在 **控制台** (Console) 面板查看错误信息
### 添加新功能示例:增加方块品类
`BlockConfig.ts``BLOCK_CONFIGS` 数组中添加:
```typescript
{ level: 9, color: '#00BCD4', name: '龙' },
{ level: 10, color: '#FF4081', name: '圣' },
```
同时修改 `MERGE_MAX_LEVEL` 逻辑(如果有上限校验)。
### 调整游戏难度
`BlockConfig.ts` 中修改:
```typescript
export const SPAWN_INTERVAL = 3.0; // 改成 2.0 更快4.0 更慢
```
修改 `getRandomSpawnLevel()` 中的概率分布:
```typescript
export function getRandomSpawnLevel(): number {
const roll = Math.random();
if (roll < 0.30) return 1; // Lv1 概率降低
if (roll < 0.60) return 2; // Lv2 增加
if (roll < 0.80) return 3;
if (roll < 0.93) return 4;
return 5;
}
```
---
## 8. 发布游戏
### 发布为 Web 版HTML5
1. 点击 **项目 → 构建发布**
2. 平台选择 **Web 移动端****Web 桌面端**
3. 设置输出路径(如 `build/web-mobile`
4. 点击 **构建**
5. 构建完成后,打开 `build/web-mobile/index.html` 即可游玩
### 发布为 Android 版本
1. 安装 Android Studio 并配置 NDK/SDK
2. 在 Cocos Creator 中点击 **项目 → 构建发布**
3. 平台选择 **Android**
4. 配置包名(如 `com.lywdev.mergegame`
5. 点击 **构建 → 编译**
6. 生成的 APK 在 `build/android/` 目录
### 发布为 iOS 版本
1. 需要 macOS 和 Xcode
2. 构建时选择 **iOS** 平台
3. 用 Xcode 打开生成的项目
4. 配置签名后发布到 App Store
### 发布为微信小游戏
1. 安装微信开发者工具
2. 在 Cocos Creator 中 **项目 → 构建发布 → 微信小游戏**
3. 填入 AppID
4. 构建后,用微信开发者工具打开生成目录
5. 上传代码
---
## 9. 常见问题
### Q: 编辑器报 "Cannot find module 'cc'"
**原因**: TypeScript 语言服务未正确配置。
**解决**:
1. 关闭 Cocos Creator
2. 删除项目根目录的 `temp/``library/` 文件夹
3. 重新打开 Cocos Creator
### Q: 方块显示为白色方块
**原因**: Sprite 组件缺少纹理spriteFrame
**解决**: Cocos Creator 3.8+ 中 `Sprite` 不设置 `spriteFrame` 会使用内置白色纹理。如果没有自动显示颜色,可以在 `Block.ts``createVisuals()` 中添加:
```typescript
import { SpriteFrame, Texture2D } from 'cc';
// ...
const sprite = bgNode.addComponent(Sprite);
sprite.type = Sprite.Type.SIMPLE;
sprite.color = ...; // 你的颜色
```
### Q: 预览时点击无效
**原因**: 场景中可能缺少 UITransform 组件。
**解决**:
1. 选中 Canvas 节点
2. 检查 Inspector 中是否有 **UITransform** 组件
3. 如果没有,点击 **添加组件 → UI → UITransform**
### Q: 背景颜色不显示
**原因**: Sprite 没有纹理时某些版本不渲染。
**解决**: 确保所有 Sprite 组件都正确设置了 `Type = SIMPLE` 并赋予了 `color` 值。
### Q: 如何导出并在手机上测试?
1. 点击 **项目 → 构建发布**
2. 选择 **Web 移动端**
3. 构建后,将 `build/web-mobile` 目录部署到本地服务器(如用 `npm serve``python -m http.server`
4. 在同一 WiFi 下的手机浏览器中访问
### Q: 编辑器中显示 "Failed to load scene"
**原因**: 场景文件损坏或版本不兼容。
**解决**: 删除旧的场景文件(在 `assets/` 中),按 [第3节](#3-创建场景首次必需) 重新创建场景。
---
> 如有其他问题,请参考 [Cocos Creator 官方文档](https://docs.cocos.com/creator/3.8/manual/zh/)

View File

@@ -1,120 +1,116 @@
/**
* 方块组件 - 渲染方块外观、处理选中/升级动画
* Cocos Creator 3.8.8
* 方块组件 — 视觉、动画、拖拽状态
*/
import { _decorator, Component, Node, Sprite, Color, UITransform, Label, tween, v3 } from 'cc';
import { getBlockConfig, CELL_SIZE } from './BlockConfig';
import { CELL, conf } from './BlockConfig';
const { ccclass, property } = _decorator;
const { ccclass } = _decorator;
@ccclass('Block')
export class Block extends Component {
// ---- 公开属性 ----
public row: number = -1;
public col: number = -1;
public level: number = 1;
public isMerging: boolean = false;
// ---- 网格位置 ----
public row = -1;
public col = -1;
public level = 1;
public isMerging = false;
// ---- 内部引用 ----
private _bgSprite: Sprite | null = null;
private _levelLabel: Label | null = null;
// ---- 内部 ----
private _sprite!: Sprite;
private _label!: Label;
// ============================================================
// 生命周期
// ============================================================
// ============================================================
// 生命周期
// ============================================================
/** 组件添加到节点时调用,创建视觉元素(此时 level=1 默认值) */
onLoad() {
this.createVisuals();
}
onLoad() {
this.node.addComponent(UITransform).setContentSize(CELL, CELL);
// ============================================================
// 公共方法
// ============================================================
// 方块底色
const bg = new Node('bg');
this._sprite = bg.addComponent(Sprite);
this._sprite.type = Sprite.Type.SIMPLE;
bg.addComponent(UITransform).setContentSize(CELL - 4, CELL - 4);
this.node.addChild(bg);
/** 初始化方块的行列和等级 */
public init(row: number, col: number, level: number) {
this.row = row;
this.col = col;
this.level = level;
this.isMerging = false;
this.refreshAllVisuals();
}
// 等级文字
const lb = new Node('lb');
this._label = lb.addComponent(Label);
this._label.fontSize = 24;
this._label.lineHeight = 28;
this._label.color = Color.WHITE.clone();
lb.addComponent(UITransform).setContentSize(CELL - 4, CELL - 4);
this.node.addChild(lb);
/** 设置/清除选中高亮 */
public setSelected(selected: boolean) {
if (!this._bgSprite) return;
const cfg = getBlockConfig(this.level);
const c = this.hexToColor(cfg.color);
if (selected) c.a = 160; // 选中时半透明
this._bgSprite.color = c;
}
this.applyVisuals();
}
/** 升级到新等级(合并后调用) */
public upgradeTo(newLevel: number) {
this.level = newLevel;
this.isMerging = false;
this.refreshAllVisuals();
// ============================================================
// 公开方法
// ============================================================
// 弹性放大动画
this.node.setScale(v3(1.3, 1.3, 1));
tween(this.node)
.to(0.3, { scale: v3(1, 1, 1) }, { easing: 'elasticOut' })
.start();
}
/** 设置网格位置和等级 */
init(row: number, col: number, level: number) {
this.row = row;
this.col = col;
this.level = level;
this.isMerging = false;
this.applyVisuals();
this.playSpawnAnim();
}
// ============================================================
// 内部方法
// ============================================================
/** 合并升级 */
upgradeTo(lv: number) {
this.level = lv;
this.isMerging = false;
this.applyVisuals();
this.playMergeAnim();
}
/** 初始化时创建一次视觉元素 */
private createVisuals() {
// 方块底色
const bgNode = new Node('Bg');
this._bgSprite = bgNode.addComponent(Sprite);
this._bgSprite.type = Sprite.Type.SIMPLE;
bgNode.addComponent(UITransform).setContentSize(CELL_SIZE - 4, CELL_SIZE - 4);
this.node.addChild(bgNode);
/** 高亮(拖拽选中) */
setHighlight(on: boolean) {
const c = this._color();
if (on) c.a = 160;
this._sprite.color = c;
}
// 等级文字
const labelNode = new Node('LvLabel');
this._levelLabel = labelNode.addComponent(Label);
this._levelLabel.fontSize = 24;
this._levelLabel.lineHeight = 28;
this._levelLabel.color = Color.WHITE.clone();
labelNode.addComponent(UITransform).setContentSize(CELL_SIZE - 4, CELL_SIZE - 4);
this.node.addChild(labelNode);
// ============================================================
// 动画
// ============================================================
// 节点碰撞尺寸
this.node.addComponent(UITransform).setContentSize(CELL_SIZE, CELL_SIZE);
private playSpawnAnim() {
this.node.setScale(v3(0, 0, 1));
tween(this.node).to(0.2, { scale: v3(1, 1, 1) }, { easing: 'backOut' }).start();
}
// 入场缩放动画
this.node.setScale(v3(0, 0, 1));
tween(this.node)
.to(0.2, { scale: v3(1, 1, 1) }, { easing: 'backOut' })
.start();
}
private playMergeAnim() {
this.node.setScale(v3(1.4, 1.4, 1));
tween(this.node).to(0.35, { scale: v3(1, 1, 1) }, { easing: 'elasticOut' }).start();
}
/** 根据当前等级刷新所有视觉 */
private refreshAllVisuals() {
const cfg = getBlockConfig(this.level);
if (this._bgSprite) {
this._bgSprite.color = this.hexToColor(cfg.color);
}
if (this._levelLabel) {
this._levelLabel.string = `${this.level}`;
// 高等级文字稍大
this._levelLabel.fontSize = 24 + Math.min(this.level * 2, 10);
}
}
/** 弹回动画(拖拽到无效位置) */
playSnapBack(from: { x: number; y: number }, to: { x: number; y: number }) {
this.node.setPosition(from.x, from.y, 0);
tween(this.node).to(0.18, { position: v3(to.x, to.y, 0) }, { easing: 'sineOut' }).start();
}
/** 十六进制字符串 → Color */
private hexToColor(hex: string): Color {
return new Color(
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
255
);
}
// ============================================================
// 内部
// ============================================================
private applyVisuals() {
const c = this._color();
this._sprite.color = c;
this._label.string = `${this.level}`;
this._label.fontSize = 24 + Math.min(this.level * 2, 10);
}
private _color(): Color {
const hex = conf(this.level).color;
return new Color(
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
255,
);
}
}

View File

@@ -1,52 +1,48 @@
/**
* 方块配置 - 8 个等级,不同颜色
* Cocos Creator 3.8.8
* 方块与棋盘配置
*/
export interface LevelConfig {
level: number;
color: string; // 十六进制颜色
name: string; // 等级名称
level: number;
color: string;
name: string;
}
export const BLOCK_CONFIGS: LevelConfig[] = [
{ level: 1, color: '#A8E6CF', name: '草' },
{ level: 2, color: '#4CAF50', name: '' },
{ level: 3, color: '#2196F3', name: '' },
{ level: 4, color: '#9C27B0', name: '' },
{ level: 5, color: '#FF9800', name: '' },
{ level: 6, color: '#F44336', name: '' },
{ level: 7, color: '#E91E63', name: '' },
{ level: 8, color: '#FFD700', name: '' },
/** 8 个等级 — 颜色由浅到深,名称由低到高 */
export const LEVELS: LevelConfig[] = [
{ level: 1, color: '#A8E6CF', name: '' },
{ level: 2, color: '#4CAF50', name: '' },
{ level: 3, color: '#2196F3', name: '' },
{ level: 4, color: '#9C27B0', name: '' },
{ level: 5, color: '#FF9800', name: '' },
{ level: 6, color: '#F44336', name: '' },
{ level: 7, color: '#E91E63', name: '' },
{ level: 8, color: '#FFD700', name: '神' },
];
// 棋盘尺寸
/** 棋盘尺寸 */
export const ROWS = 12;
export const COLS = 8;
export const CELL_SIZE = 64;
export const BOARD_PADDING = 8;
export const CELL = 64; // 格子像素
export const PAD = 6; // 格子间距
export const MAX_LEVEL = 8; // 最高等级
// 自动生成间隔(秒)
/** 自动生成间隔(秒) */
export const SPAWN_INTERVAL = 3.0;
// 合并分数基数
export const MERGE_SCORE = 10;
/** 合并基础分 */
export const MERGE_BASE_SCORE = 10;
/**
* 获取指定等级的配置
*/
export function getBlockConfig(level: number): LevelConfig {
const idx = Math.min(Math.max(level - 1, 0), BLOCK_CONFIGS.length - 1);
return BLOCK_CONFIGS[idx];
/** 获取等级配置 */
export function conf(level: number): LevelConfig {
return LEVELS[Math.min(Math.max(level - 1, 0), LEVELS.length - 1)];
}
/**
* 随机生成一个初始等级(低等级概率高)
*/
export function getRandomSpawnLevel(): number {
const roll = Math.random();
if (roll < 0.45) return 1;
if (roll < 0.75) return 2;
if (roll < 0.90) return 3;
if (roll < 0.97) return 4;
return 5;
/** 随机生成初始等级(低等级高概率) */
export function randLevel(): number {
const r = Math.random();
if (r < 0.45) return 1;
if (r < 0.75) return 2;
if (r < 0.90) return 3;
if (r < 0.97) return 4;
return 5;
}

View File

@@ -1,231 +1,208 @@
/**
* 棋盘组件 - 管理 8x12 方格的生成、移动、合并
* Cocos Creator 3.8.8
* 棋盘 8×12 网格管理、生成、移动、合并
*/
import { _decorator, Component, Node, UITransform, Color, Sprite, Vec3, v3 } from 'cc';
import { ROWS, COLS, CELL_SIZE, BOARD_PADDING, MERGE_SCORE } from './BlockConfig';
import { ROWS, COLS, CELL, PAD, MAX_LEVEL, MERGE_BASE_SCORE } from './BlockConfig';
import { Block } from './Block';
const { ccclass } = _decorator;
@ccclass('GameBoard')
export class GameBoard extends Component {
// 二维网格引用
private grid: (Block | null)[][] = [];
private grid: (Block | null)[][] = [];
private originX = 0;
private originY = 0;
// 棋盘几何参数
private startX = 0;
private startY = 0;
/** 合并得分回调 */
onScore: ((pts: number) => void) | null = null;
// 外部回调
public onScoreUpdate: ((pts: number) => void) | null = null;
// ============================================================
// 初始化
// ============================================================
// ============================================================
// 生命周期
// ============================================================
onLoad() {
const w = COLS * (CELL + PAD) + PAD;
const h = ROWS * (CELL + PAD) + PAD;
this.originX = -w / 2 + PAD;
this.originY = -h / 2 + PAD;
onLoad() {
const boardW = COLS * (CELL_SIZE + BOARD_PADDING) + BOARD_PADDING;
const boardH = ROWS * (CELL_SIZE + BOARD_PADDING) + BOARD_PADDING;
this.startX = -boardW / 2 + BOARD_PADDING;
this.startY = -boardH / 2 + BOARD_PADDING;
this.initGrid();
this.drawBg(w, h);
}
this.buildGrid();
this.drawBackground(boardW, boardH);
private initGrid() {
this.grid = [];
for (let r = 0; r < ROWS; r++) {
this.grid[r] = [];
for (let c = 0; c < COLS; c++) {
this.grid[r][c] = null;
this.drawCell(r, c);
}
}
}
// ============================================================
// 棋盘初始化
// ============================================================
/** 每个格子的半透明底纹 */
private drawCell(r: number, c: number) {
const n = new Node('cell');
const s = n.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(60, 60, 60, 80);
n.addComponent(UITransform).setContentSize(CELL, CELL);
n.setPosition(this.toWorld(r, c));
this.node.addChild(n);
}
/** 创建空网格 + 格子背景 */
private buildGrid() {
this.grid = [];
for (let r = 0; r < ROWS; r++) {
this.grid[r] = [];
for (let c = 0; c < COLS; c++) {
this.grid[r][c] = null;
this.createCellBackdrop(r, c);
}
}
/** 棋盘背景面板 */
private drawBg(w: number, h: number) {
const bg = new Node('bg');
const s = bg.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(28, 28, 40, 220);
bg.addComponent(UITransform).setContentSize(w + 16, h + 16);
bg.setPosition(0, 0, -1);
this.node.addChild(bg);
}
// ============================================================
// 坐标
// ============================================================
toWorld(r: number, c: number): Vec3 {
return v3(
this.originX + c * (CELL + PAD) + CELL / 2,
this.originY + r * (CELL + PAD) + CELL / 2,
0,
);
}
/** 世界坐标 → 网格坐标,超出范围返回 null */
toGrid(x: number, y: number): { r: number; c: number } | null {
const c = Math.round((x - this.originX) / (CELL + PAD));
const r = Math.round((y - this.originY) / (CELL + PAD));
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return null;
return { r, c };
}
// ============================================================
// 查询
// ============================================================
isEmpty(r: number, c: number) {
return r >= 0 && r < ROWS && c >= 0 && c < COLS && !this.grid[r][c];
}
get(r: number, c: number): Block | null {
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return null;
return this.grid[r][c];
}
/** 四方向邻居 */
neighbors(r: number, c: number): Block[] {
const out: Block[] = [];
for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
const b = this.get(r + dr, c + dc);
if (b) out.push(b);
}
return out;
}
/** 一个格子的半透明背景 */
private createCellBackdrop(row: number, col: number) {
const n = new Node('Cell');
const s = n.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(60, 60, 60, 80);
n.addComponent(UITransform).setContentSize(CELL_SIZE, CELL_SIZE);
n.setPosition(this.gridToWorld(row, col));
this.node.addChild(n);
// ============================================================
// 方块操作
// ============================================================
/** 在随机空位生成新方块 */
spawn(level: number): Block | null {
const empty: { r: number; c: number }[] = [];
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
if (this.isEmpty(r, c)) empty.push({ r, c });
if (!empty.length) return null;
const g = empty[Math.floor(Math.random() * empty.length)];
return this._create(g.r, g.c, level);
}
/** 在指定位置创建方块 */
_create(r: number, c: number, level: number): Block {
const n = new Node('block');
const b = n.addComponent(Block);
b.init(r, c, level);
n.setPosition(this.toWorld(r, c));
this.node.addChild(n);
this.grid[r][c] = b;
return b;
}
/** 移动方块到新位置(目标必须为空) */
move(block: Block, nr: number, nc: number): boolean {
if (!this.isEmpty(nr, nc)) return false;
this.grid[block.row][block.col] = null;
block.row = nr;
block.col = nc;
this.grid[nr][nc] = block;
block.node.setPosition(this.toWorld(nr, nc));
return true;
}
/** 移除方块 */
remove(block: Block) {
this.grid[block.row][block.col] = null;
block.node.destroy();
}
// ============================================================
// 合并
// ============================================================
/** 尝试合并 a 和 b必须相邻同等级 */
tryMerge(a: Block, b: Block): boolean {
if (a.level !== b.level || a.isMerging || b.isMerging) return false;
if (Math.abs(a.row - b.row) + Math.abs(a.col - b.col) !== 1) return false;
this._doMerge(a, b);
return true;
}
/** 触发某个方块周围的合并检测 */
checkMerge(block: Block) {
if (block.isMerging) return;
for (const nb of this.neighbors(block.row, block.col)) {
if (nb.level === block.level && !nb.isMerging) {
this._doMerge(block, nb);
return;
}
}
}
/** 整个棋盘的深色背景 */
private drawBackground(w: number, h: number) {
const bg = new Node('BoardBg');
const s = bg.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(30, 30, 40, 200);
bg.addComponent(UITransform).setContentSize(w + 16, h + 16);
bg.setPosition(v3(0, 0, -1));
this.node.addChild(bg);
}
private _doMerge(a: Block, b: Block) {
a.isMerging = true;
b.isMerging = true;
// ============================================================
// 坐标转换
// ============================================================
const lv = Math.min(a.level + 1, MAX_LEVEL);
this.remove(b);
a.upgradeTo(lv);
public gridToWorld(row: number, col: number): Vec3 {
return v3(
this.startX + col * (CELL_SIZE + BOARD_PADDING) + CELL_SIZE / 2,
this.startY + row * (CELL_SIZE + BOARD_PADDING) + CELL_SIZE / 2,
0
);
}
if (this.onScore) this.onScore(lv * MERGE_BASE_SCORE);
public worldToGrid(wx: number, wy: number): { row: number; col: number } | null {
const col = Math.round((wx - this.startX) / (CELL_SIZE + BOARD_PADDING));
const row = Math.round((wy - this.startY) / (CELL_SIZE + BOARD_PADDING));
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return null;
return { row, col };
}
// 链式合并
this.scheduleOnce(() => this.checkMerge(a), 0.25);
}
// ============================================================
// 网格查询
// ============================================================
// ============================================================
// 全局
// ============================================================
public isEmpty(row: number, col: number): boolean {
return row >= 0 && row < ROWS && col >= 0 && col < COLS && this.grid[row][col] === null;
}
isFull(): boolean {
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
if (!this.grid[r][c]) return false;
return true;
}
public getBlock(row: number, col: number): Block | null {
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return null;
return this.grid[row][col];
}
/** 获取四方向的相邻方块 */
public getAdjacent(row: number, col: number): Block[] {
const result: Block[] = [];
for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
const b = this.getBlock(row + dr, col + dc);
if (b) result.push(b);
}
return result;
}
// ============================================================
// 方块操作
// ============================================================
/** 在随机空格子上生成一个方块 */
public spawnRandom(level: number): Block | null {
const empty: { r: number; c: number }[] = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (this.isEmpty(r, c)) empty.push({ r, c });
}
}
if (empty.length === 0) return null;
const cell = empty[Math.floor(Math.random() * empty.length)];
return this._createBlockAt(cell.r, cell.c, level);
}
/** 创建方块节点并放入网格 */
private _createBlockAt(row: number, col: number, level: number): Block {
const node = new Node('Block');
const block = node.addComponent(Block);
block.init(row, col, level);
node.setPosition(this.gridToWorld(row, col));
this.node.addChild(node);
this.grid[row][col] = block;
return block;
}
/** 移动方块到新位置(空格) */
public moveBlock(block: Block, newRow: number, newCol: number): boolean {
if (!this.isEmpty(newRow, newCol)) return false;
this.grid[block.row][block.col] = null;
block.row = newRow;
block.col = newCol;
this.grid[newRow][newCol] = block;
block.node.setPosition(this.gridToWorld(newRow, newCol));
return true;
}
/** 从网格移除并销毁 */
public removeBlock(block: Block) {
this.grid[block.row][block.col] = null;
block.node.destroy();
}
// ============================================================
// 合并逻辑
// ============================================================
/** 尝试合并两个相邻同等级方块 */
public tryMerge(a: Block, b: Block): boolean {
if (a.level !== b.level || a.isMerging || b.isMerging) return false;
if (!this._isAdjacent(a, b)) return false;
this._doMerge(a, b);
return true;
}
/** 触发一个方块周围的合并检测 */
public triggerMergeCheck(block: Block) {
if (block.isMerging) return;
for (const other of this.getAdjacent(block.row, block.col)) {
if (other.level === block.level && !other.isMerging) {
this._doMerge(block, other);
return;
}
}
}
private _isAdjacent(a: Block, b: Block): boolean {
const dr = Math.abs(a.row - b.row);
const dc = Math.abs(a.col - b.col);
return (dr === 1 && dc === 0) || (dr === 0 && dc === 1);
}
private _doMerge(a: Block, b: Block) {
a.isMerging = true;
b.isMerging = true;
const newLevel = Math.min(a.level + 1, 8);
this.removeBlock(b);
a.upgradeTo(newLevel);
const pts = newLevel * MERGE_SCORE;
if (this.onScoreUpdate) this.onScoreUpdate(pts);
// 链式合并(延迟检测)
this.scheduleOnce(() => this.triggerMergeCheck(a), 0.25);
}
// ============================================================
// 全局
// ============================================================
public isBoardFull(): boolean {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (this.isEmpty(r, c)) return false;
}
}
return true;
}
/** 清除所有方块 */
public clearAll() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const b = this.grid[r][c];
if (b) {
b.node.destroy();
this.grid[r][c] = null;
}
}
}
}
clearAll() {
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++) {
const b = this.grid[r][c];
if (b) { b.node.destroy(); this.grid[r][c] = null; }
}
}
}

View File

@@ -0,0 +1,283 @@
/**
* 游戏主控 — UI、游戏流程、拖拽输入
* 拖拽机制:按下拾取 → 拖拽跟随 → 释放判定合并/移动/弹回
*/
import {
_decorator, Component, Node, Label, Color, Sprite, UITransform, Vec3, v3,
EventTouch,
} from 'cc';
import { GameBoard } from './GameBoard';
import { Block } from './Block';
import { ROWS, CELL, PAD, SPAWN_INTERVAL, randLevel } from './BlockConfig';
const { ccclass } = _decorator;
// 普通枚举(不被 isolatedModules 限制)
enum GState { Idle, Playing, Over }
@ccclass('GameController')
export class GameController extends Component {
private board!: GameBoard;
private boardNode!: Node;
private state = GState.Idle;
private score = 0;
private spawnTimer = 0;
// ---- 拖拽 ----
private dragging: Block | null = null;
private dragHome = v3(); // 拖拽前的位置board 空间)
private dragOff = v3(); // 手指到方块中心偏移
// ---- UI ----
private scoreLabel!: Label;
private overlay!: Node;
private finalScore!: Label;
// ============================================================
// 生命周期
// ============================================================
onLoad() {
this.buildUI();
this.buildBoard();
}
start() {
this.startGame();
}
update(dt: number) {
if (this.state !== GState.Playing) return;
this.spawnTimer += dt;
if (this.spawnTimer >= SPAWN_INTERVAL) {
this.spawnTimer = 0;
this.trySpawn();
}
}
// ============================================================
// UI
// ============================================================
private buildUI() {
// 背景
this.addSprite('bg', new Color(22, 22, 34, 255), 600, 1200, v3(0, 0, -3));
const bh = ROWS * (CELL + PAD); // 棋盘像素高度
const barY = Math.floor(bh / 2) + 68; // 顶栏 Y
// 顶栏背景
this.addSprite('bar', new Color(38, 38, 52, 230), 480, 52, v3(0, barY, 0));
// 标题
this.addLabel('title', '合成大作战', 18, new Color(180, 180, 190, 255), v3(0, barY + 42, 0));
// 得分
const sn = this.addLabel('score', '得分: 0', 30, Color.WHITE, v3(0, barY, 0));
this.scoreLabel = sn.getComponent(Label)!;
// ---- 游戏结束 ----
this.overlay = this.addSprite('overlay', new Color(0, 0, 0, 180), 600, 1200, v3(0, 0, 10));
this.overlay.active = false;
this.addLabel('go', '游戏结束', 48, new Color(255, 200, 50, 255), v3(0, 70, 11), this.overlay);
const fn = this.addLabel('final', '最终得分: 0', 32, Color.WHITE, v3(0, 0, 11), this.overlay);
this.finalScore = fn.getComponent(Label)!;
// 重新开始按钮
const btn = this.addSprite('restart', new Color(66, 165, 77, 255), 200, 56, v3(0, -90, 11), this.overlay);
this.addLabel('bl', '重新开始', 26, Color.WHITE, v3(0, 0, 12), btn);
btn.on(Node.EventType.TOUCH_END, () => this.onRestart());
}
// ============================================================
// 棋盘
// ============================================================
private buildBoard() {
this.boardNode = new Node('board');
this.board = this.boardNode.addComponent(GameBoard);
this.board.onScore = (p) => this.addScore(p);
this.node.addChild(this.boardNode);
// 拖拽事件注册在根节点 (Canvas),确保全屏响应
this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
this.node.on(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this);
}
// ============================================================
// 游戏流程
// ============================================================
private startGame() {
this.score = 0;
this.spawnTimer = 0;
this.dragging = null;
this.state = GState.Playing;
this.scoreLabel.string = '得分: 0';
this.overlay.active = false;
for (let i = 0; i < 3; i++) this.board.spawn(randLevel());
}
private trySpawn() {
if (!this.board.spawn(randLevel()) && this.board.isFull()) this.gameOver();
}
private addScore(p: number) {
this.score += p;
this.scoreLabel.string = `得分: ${this.score}`;
}
private gameOver() {
this.state = GState.Over;
this.overlay.active = true;
this.finalScore.string = `最终得分: ${this.score}`;
}
private onRestart() {
this.board.clearAll();
this.startGame();
}
// ============================================================
// 拖拽事件
// ============================================================
/** UI 坐标 → Canvas 本地坐标 */
private toLocal(t: EventTouch): Vec3 {
const p = t.getUILocation();
const tr = this.node.getComponent(UITransform)!;
return tr.convertToNodeSpaceAR(v3(p.x, p.y, 0));
}
private onTouchStart(evt: EventTouch) {
if (this.state !== GState.Playing) return;
const local = this.toLocal(evt);
const g = this.board.toGrid(local.x, local.y);
if (!g) return;
const block = this.board.get(g.r, g.c);
if (!block || block.isMerging) return;
// 拾取
this.dragging = block;
this.dragHome = block.node.getPosition().clone();
this.dragOff = v3(local.x - this.dragHome.x, local.y - this.dragHome.y, 0);
// 临时挂到 Canvas 根节点,确保渲染在最上层
block.node.parent = this.node;
block.node.setPosition(this.dragHome.x, this.dragHome.y, 0);
block.setHighlight(true);
}
private onTouchMove(evt: EventTouch) {
if (!this.dragging) return;
const local = this.toLocal(evt);
this.dragging.node.setPosition(
local.x - this.dragOff.x,
local.y - this.dragOff.y,
0,
);
}
private onTouchEnd(_evt: EventTouch) {
if (!this.dragging) return;
this.resolveDrop();
}
private onTouchCancel() {
if (!this.dragging) return;
this.resolveDrop();
}
// ============================================================
// 释放判定
// ============================================================
private resolveDrop() {
const block = this.dragging!;
const pos = block.node.getPosition();
// 挂回棋盘节点(坐标系相同,位置不变)
block.node.parent = this.boardNode;
const g = this.board.toGrid(pos.x, pos.y);
let ok = false;
if (g) {
const { r, c } = g;
const target = this.board.get(r, c);
if (target && target !== block && !target.isMerging) {
// ---- 目标是个方块 ----
if (this.isAdj(block, target) && block.level === target.level) {
// 相邻同等级 → 合并
ok = this.board.tryMerge(block, target);
}
} else if (!target && (r !== block.row || c !== block.col)) {
// ---- 目标为空位 → 移动 ----
ok = this.board.move(block, r, c);
if (ok) {
// 移动后检查合并
this.scheduleOnce(() => {
if (block.isValid) this.board.checkMerge(block);
}, 0.1);
}
}
}
if (!ok) {
// 弹回
block.playSnapBack(
{ x: pos.x, y: pos.y },
{ x: this.dragHome.x, y: this.dragHome.y },
);
}
block.setHighlight(false);
this.dragging = null;
}
/** 两方块是否相邻(四方向之一) */
private isAdj(a: Block, b: Block): boolean {
return Math.abs(a.row - b.row) + Math.abs(a.col - b.col) === 1;
}
// ============================================================
// 工具
// ============================================================
private addSprite(
name: string, color: Color, w: number, h: number,
pos: Vec3, parent?: Node,
): Node {
const n = new Node(name);
const s = n.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = color;
n.addComponent(UITransform).setContentSize(w, h);
n.setPosition(pos);
(parent ?? this.node).addChild(n);
return n;
}
private addLabel(
name: string, text: string, size: number, color: Color,
pos: Vec3, parent?: Node,
): Node {
const n = new Node(name);
const l = n.addComponent(Label);
l.string = text;
l.fontSize = size;
l.color = color;
const ut = n.addComponent(UITransform);
// 估算宽高让点击区域适用
ut.setContentSize(text.length * (size * 0.6) + 20, size + 10);
n.setPosition(pos);
(parent ?? this.node).addChild(n);
return n;
}
}

View File

@@ -1,290 +0,0 @@
/**
* 游戏主控制器 - UI、计分、输入、游戏流程
* Cocos Creator 3.8.8
*/
import { _decorator, Component, Node, Label, Color, Sprite, UITransform, Vec3, v3, EventTouch } from 'cc';
import { GameBoard } from './GameBoard';
import { Block } from './Block';
import { ROWS, COLS, CELL_SIZE, BOARD_PADDING, SPAWN_INTERVAL, getRandomSpawnLevel } from './BlockConfig';
const { ccclass, property } = _decorator;
const enum GameState { IDLE, PLAYING, GAME_OVER }
@ccclass('GameManager')
export class GameManager extends Component {
// Cocos Creator 属性面板可见(可在此处拖拽引用)
@property({ displayName: '生成间隔(秒)', min: 1, max: 10 })
private spawnInterval: number = SPAWN_INTERVAL;
// ---- 内部状态 ----
private board: GameBoard | null = null;
private boardNode: Node | null = null;
private score = 0;
private spawnTimer = 0;
private selected: Block | null = null;
private state: GameState = GameState.IDLE;
// ---- UI 节点 ----
private scoreLabel: Label | null = null;
private gameOverNode: Node | null = null;
private finalScoreLabel: Label | null = null;
// ============================================================
// 生命周期
// ============================================================
onLoad() {
this.buildUI();
this.buildBoard();
}
start() {
this.startGame();
}
update(dt: number) {
if (this.state !== GameState.PLAYING) return;
this.spawnTimer += dt;
if (this.spawnTimer >= this.spawnInterval) {
this.spawnTimer = 0;
this.spawnBlock();
}
}
// ============================================================
// UI 构建
// ============================================================
private buildUI() {
const root = this.node;
const rh = 960; // Canvas 设计高度
// ---- 背景 ----
{
const bg = new Node('Bg');
const s = bg.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(25, 25, 35, 255);
bg.addComponent(UITransform).setContentSize(600, 1200);
bg.setPosition(0, 0, -2);
root.addChild(bg);
}
const boardH = ROWS * (CELL_SIZE + BOARD_PADDING);
const topY = boardH / 2 + 60; // 棋盘中点之上的偏移
// ---- 记分板底 ----
{
const sb = new Node('ScoreBg');
const s = sb.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(40, 40, 50, 220);
sb.addComponent(UITransform).setContentSize(480, 60);
sb.setPosition(0, topY, 0);
root.addChild(sb);
}
// ---- 标题 ----
{
const t = new Node('Title');
const l = t.addComponent(Label);
l.string = '合成大作战';
l.fontSize = 20;
l.color = new Color(200, 200, 200, 255);
t.addComponent(UITransform).setContentSize(160, 30);
t.setPosition(0, topY + 45, 0);
root.addChild(t);
}
// ---- 分数 ----
{
const sn = new Node('ScoreLabel');
this.scoreLabel = sn.addComponent(Label);
this.scoreLabel.string = '得分: 0';
this.scoreLabel.fontSize = 28;
this.scoreLabel.color = Color.WHITE.clone();
sn.addComponent(UITransform).setContentSize(300, 40);
sn.setPosition(0, topY, 0);
root.addChild(sn);
}
// ---- 游戏结束遮罩 ----
{
const go = new Node('GameOver');
const s = go.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(0, 0, 0, 180);
go.addComponent(UITransform).setContentSize(600, 1200);
go.setPosition(0, 0, 10);
go.active = false;
root.addChild(go);
this.gameOverNode = go;
// "游戏结束"
{
const n = new Node('GoLabel');
const l = n.addComponent(Label);
l.string = '游戏结束';
l.fontSize = 48;
l.color = new Color(255, 200, 50, 255);
n.addComponent(UITransform).setContentSize(300, 80);
n.setPosition(0, 80, 11);
go.addChild(n);
}
// 最终得分
{
const n = new Node('FinalScore');
this.finalScoreLabel = n.addComponent(Label);
this.finalScoreLabel.string = '最终得分: 0';
this.finalScoreLabel.fontSize = 32;
this.finalScoreLabel.color = Color.WHITE.clone();
n.addComponent(UITransform).setContentSize(300, 50);
n.setPosition(0, 10, 11);
go.addChild(n);
}
// 重新开始按钮
{
const btn = new Node('RestartBtn');
const s = btn.addComponent(Sprite);
s.type = Sprite.Type.SIMPLE;
s.color = new Color(76, 175, 80, 255);
btn.addComponent(UITransform).setContentSize(200, 60);
btn.setPosition(0, -80, 11);
const ln = new Node('BtnLabel');
const l = ln.addComponent(Label);
l.string = '重新开始';
l.fontSize = 28;
l.color = Color.WHITE.clone();
ln.addComponent(UITransform).setContentSize(200, 40);
ln.setPosition(0, 0, 12);
btn.addChild(ln);
btn.on(Node.EventType.TOUCH_END, () => this.restartGame());
go.addChild(btn);
}
}
}
// ============================================================
// 棋盘
// ============================================================
private buildBoard() {
this.boardNode = new Node('Board');
this.board = this.boardNode.addComponent(GameBoard);
this.board.onScoreUpdate = (pts) => this.addScore(pts);
this.node.addChild(this.boardNode);
}
// ============================================================
// 游戏流程
// ============================================================
private startGame() {
this.score = 0;
this.spawnTimer = 0;
this.selected = null;
this.state = GameState.PLAYING;
if (this.scoreLabel) this.scoreLabel.string = '得分: 0';
if (this.gameOverNode) this.gameOverNode.active = false;
// 初始生成 3 个
if (this.board) {
for (let i = 0; i < 3; i++) {
this.board.spawnRandom(getRandomSpawnLevel());
}
}
// 注册触摸
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
}
private spawnBlock() {
if (!this.board) return;
if (!this.board.spawnRandom(getRandomSpawnLevel()) && this.board.isBoardFull()) {
this.gameOver();
}
}
private addScore(pts: number) {
this.score += pts;
if (this.scoreLabel) this.scoreLabel.string = `得分: ${this.score}`;
}
private gameOver() {
this.state = GameState.GAME_OVER;
if (this.gameOverNode) this.gameOverNode.active = true;
if (this.finalScoreLabel) this.finalScoreLabel.string = `最终得分: ${this.score}`;
}
private restartGame() {
if (this.board) this.board.clearAll();
this.startGame();
}
// ============================================================
// 输入处理
// ============================================================
private onTouchEnd(evt: EventTouch) {
if (this.state !== GameState.PLAYING || !this.board) return;
// 屏幕坐标 → Canvas 节点空间坐标
const uiPos = evt.getUILocation();
const uiTransform = this.node.getComponent(UITransform);
if (!uiTransform) return;
const local = uiTransform.convertToNodeSpaceAR(v3(uiPos.x, uiPos.y, 0));
const g = this.board.worldToGrid(local.x, local.y);
if (!g) { this.clearSelected(); return; }
const { row, col } = g;
const tapped = this.board.getBlock(row, col);
if (tapped) {
// 点击了方块
if (this.selected) {
const sel = this.selected;
if (this.isAdj(sel, tapped) && sel.level === tapped.level) {
this.board.tryMerge(sel, tapped);
this.clearSelected();
} else {
this.clearSelected();
tapped.setSelected(true);
this.selected = tapped;
}
} else {
tapped.setSelected(true);
this.selected = tapped;
}
} else if (this.selected && this.board.isEmpty(row, col)) {
// 点击空格 → 移动选中方块
if (this.board.moveBlock(this.selected, row, col)) {
const block = this.selected;
this.clearSelected();
this.scheduleOnce(() => {
if (block.isValid) this.board!.triggerMergeCheck(block);
}, 0.15);
} else {
this.clearSelected();
}
} else {
this.clearSelected();
}
}
private isAdj(a: Block, b: Block): boolean {
return (Math.abs(a.row - b.row) === 1 && a.col === b.col) ||
(Math.abs(a.col - b.col) === 1 && a.row === b.row);
}
private clearSelected() {
if (this.selected) {
this.selected.setSelected(false);
this.selected = null;
}
}
}

View File

@@ -1,448 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>合成大作战</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; -webkit-user-select: none; }
body {
background: #191923;
display: flex;
justify-content: center;
align-items: center;
min-height: 100dvh;
font-family: 'Segoe UI', 'PingFang SC', sans-serif;
touch-action: manipulation;
}
#gameContainer {
position: relative;
width: 480px;
max-width: 100vw;
aspect-ratio: 480 / 860;
}
canvas {
display: block;
width: 100%;
height: 100%;
border-radius: 12px;
background: #191923;
touch-action: none;
}
@media (max-width: 500px) {
#gameContainer { width: 100vw; }
}
</style>
</head>
<body>
<div id="gameContainer">
<canvas id="gameCanvas"></canvas>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// ============ 配置 ============
const ROWS = 12;
const COLS = 8;
const CELL = 52;
const GAP = 6;
const PAD = 10;
const BOARD_W = COLS * (CELL + GAP) + GAP;
const BOARD_H = ROWS * (CELL + GAP) + GAP;
const UI_TOP = 80;
const CANVAS_W = 480;
const CANVAS_H = 860;
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
const BLOCK_COLORS = [
'#A8E6CF', // Lv1 草
'#4CAF50', // Lv2 木
'#2196F3', // Lv3 水
'#9C27B0', // Lv4 风
'#FF9800', // Lv5 火
'#F44336', // Lv6 雷
'#E91E63', // Lv7 光
'#FFD700', // Lv8 神
];
const LEVEL_NAMES = ['草','木','水','风','火','雷','光','神'];
const SPAWN_INTERVAL = 3500; // ms
const COLORS_TEXT = '#ffffff';
const COLORS_BG = '#191923';
// ============ 游戏状态 ============
let grid = [];
let score = 0;
let gameOver = false;
let selected = null; // {row, col}
let lastSpawn = 0;
let animating = false;
let boardX, boardY;
function initBoard() {
grid = [];
for (let r = 0; r < ROWS; r++) {
grid[r] = [];
for (let c = 0; c < COLS; c++) {
grid[r][c] = null;
}
}
}
// ============ 渲染 ============
function resize() {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
boardX = (CANVAS_W - BOARD_W) / 2;
boardY = UI_TOP;
}
function draw() {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
// 背景装饰
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
// 顶栏背景
ctx.fillStyle = '#2a2a3e';
ctx.beginPath();
roundRect(ctx, boardX - 10, 8, BOARD_W + 20, 60, 10);
ctx.fill();
// 标题
ctx.fillStyle = '#888';
ctx.font = '16px "PingFang SC", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('合成大作战', CANVAS_W / 2, 28);
// 分数
ctx.fillStyle = '#fff';
ctx.font = 'bold 26px "PingFang SC", sans-serif';
ctx.fillText(`得分: ${score}`, CANVAS_W / 2, 56);
// 棋盘背景
ctx.fillStyle = '#22223a';
ctx.beginPath();
roundRect(ctx, boardX - 6, boardY - 6, BOARD_W + 12, BOARD_H + 12, 8);
ctx.fill();
// 格子背景
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = boardX + GAP + c * (CELL + GAP);
const y = boardY + GAP + r * (CELL + GAP);
ctx.fillStyle = '#2a2a42';
ctx.beginPath();
roundRect(ctx, x, y, CELL, CELL, 6);
ctx.fill();
}
}
// 方块
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const block = grid[r][c];
if (block) drawBlock(r, c, block);
}
}
// 选中高亮
if (selected) {
const { row, col } = selected;
const x = boardX + GAP + col * (CELL + GAP);
const y = boardY + GAP + row * (CELL + GAP);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.beginPath();
roundRect(ctx, x - 2, y - 2, CELL + 4, CELL + 4, 8);
ctx.stroke();
}
// 游戏结束遮罩
if (gameOver) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
ctx.fillStyle = '#ffc832';
ctx.font = 'bold 40px "PingFang SC", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('游戏结束', CANVAS_W / 2, CANVAS_H / 2 - 60);
ctx.fillStyle = '#fff';
ctx.font = '28px "PingFang SC", sans-serif';
ctx.fillText(`最终得分: ${score}`, CANVAS_W / 2, CANVAS_H / 2);
// 重新开始按钮
const bx = CANVAS_W / 2 - 90, by = CANVAS_H / 2 + 50, bw = 180, bh = 54;
ctx.fillStyle = '#4CAF50';
ctx.beginPath();
roundRect(ctx, bx, by, bw, bh, 12);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = '22px "PingFang SC", sans-serif';
ctx.fillText('重新开始', CANVAS_W / 2, by + bh / 2);
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawBlock(r, c, block) {
const x = boardX + GAP + c * (CELL + GAP);
const y = boardY + GAP + r * (CELL + GAP);
const lvl = block.level;
const color = BLOCK_COLORS[Math.min(lvl - 1, BLOCK_COLORS.length - 1)];
// 方块主体
ctx.fillStyle = color;
ctx.beginPath();
roundRect(ctx, x, y, CELL, CELL, 8);
ctx.fill();
// 高光
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath();
roundRect(ctx, x + 4, y + 4, CELL - 8, CELL / 3, 6);
ctx.fill();
// 等级文字
ctx.fillStyle = lvl >= 7 ? '#333' : '#fff';
ctx.font = `bold ${20 + lvl}px "PingFang SC", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${lvl}`, x + CELL / 2, y + CELL / 2 - 4);
// 等级名称小字
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '10px "PingFang SC", sans-serif';
const name = LEVEL_NAMES[Math.min(lvl - 1, LEVEL_NAMES.length - 1)];
ctx.fillText(name, x + CELL / 2, y + CELL / 2 + 16);
}
// ============ 游戏逻辑 ============
function getRandomLevel() {
const roll = Math.random();
if (roll < 0.45) return 1;
if (roll < 0.75) return 2;
if (roll < 0.90) return 3;
if (roll < 0.97) return 4;
return 5;
}
function findEmptyCells() {
const cells = [];
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!grid[r][c]) cells.push({row: r, col: c});
}
}
return cells;
}
function spawnBlock() {
const empty = findEmptyCells();
if (empty.length === 0) {
gameOver = true;
return null;
}
const cell = empty[Math.floor(Math.random() * empty.length)];
grid[cell.row][cell.col] = { level: getRandomLevel() };
return cell;
}
function isAdjacent(r1, c1, r2, c2) {
const dr = Math.abs(r1 - r2);
const dc = Math.abs(c1 - c2);
return (dr === 1 && dc === 0) || (dr === 0 && dc === 1);
}
function getAdjacentBlocks(row, col) {
const result = [];
const dirs = [[-1,0],[1,0],[0,-1],[0,1]];
for (const [dr, dc] of dirs) {
const r = row + dr, c = col + dc;
if (r >= 0 && r < ROWS && c >= 0 && c < COLS && grid[r][c]) {
result.push({row: r, col: c, block: grid[r][c]});
}
}
return result;
}
function doMerge(r1, c1, r2, c2) {
const lvl = Math.min(grid[r1][c1].level + 1, 8);
score += lvl * 10;
grid[r2][c2] = null;
grid[r1][c1] = { level: lvl };
selected = null;
// 链式合并检测
setTimeout(() => {
const adj = getAdjacentBlocks(r1, c1);
for (const a of adj) {
if (a.block.level === lvl) {
doMerge(r1, c1, a.row, a.col);
return;
}
}
}, 200);
}
function tryMerge(r1, c1, r2, c2) {
if (!grid[r1][c1] || !grid[r2][c2]) return false;
if (grid[r1][c1].level !== grid[r2][c2].level) return false;
if (!isAdjacent(r1, c1, r2, c2)) return false;
doMerge(r1, c1, r2, c2);
return true;
}
function moveBlock(fromR, fromC, toR, toC) {
if (grid[toR][toC]) return false;
grid[toR][toC] = grid[fromR][fromC];
grid[fromR][fromC] = null;
return true;
}
// ============ 输入处理 ============
function getGridPos(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (clientX - rect.left) * scaleX;
const my = (clientY - rect.top) * scaleY;
const gx = mx - boardX;
const gy = my - boardY;
const col = Math.floor((gx - GAP) / (CELL + GAP));
const row = Math.floor((gy - GAP) / (CELL + GAP));
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return null;
return {row, col};
}
function isRestartHit(clientX, clientY) {
if (!gameOver) return false;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (clientX - rect.left) * scaleX;
const my = (clientY - rect.top) * scaleY;
const bx = CANVAS_W / 2 - 90;
const by = CANVAS_H / 2 + 50;
const bw = 180, bh = 54;
return mx >= bx && mx <= bx + bw && my >= by && my <= by + bh;
}
function handleTap(clientX, clientY) {
if (gameOver) {
if (isRestartHit(clientX, clientY)) {
restartGame();
}
return;
}
const pos = getGridPos(clientX, clientY);
if (!pos) { selected = null; return; }
const {row, col} = pos;
const tapped = grid[row][col];
if (tapped) {
if (selected) {
const s = selected;
if (tryMerge(s.row, s.col, row, col)) return;
selected = {row, col};
} else {
selected = {row, col};
}
} else {
if (selected) {
const s = selected;
if (s.row !== row || s.col !== col) {
// 尝试移动
if (isAdjacent(s.row, s.col, row, col)) {
if (moveBlock(s.row, s.col, row, col)) {
selected = null;
// 检查移动后是否有合并
setTimeout(() => {
const adj = getAdjacentBlocks(row, col);
for (const a of adj) {
if (a.block.level === grid[row][col].level) {
tryMerge(row, col, a.row, a.col);
return;
}
}
}, 150);
return;
}
}
}
selected = null;
} else {
selected = null;
}
}
}
// ============ 游戏循环 ============
let lastTime = 0;
function gameLoop(timestamp) {
if (!lastTime) lastTime = timestamp;
if (!gameOver) {
if (timestamp - lastSpawn > SPAWN_INTERVAL) {
lastSpawn = timestamp;
spawnBlock();
}
}
draw();
requestAnimationFrame(gameLoop);
}
function restartGame() {
initBoard();
score = 0;
gameOver = false;
selected = null;
lastSpawn = performance.now();
// 初始生成3个
for (let i = 0; i < 3; i++) spawnBlock();
}
// ============ 事件绑定 ============
canvas.addEventListener('click', (e) => {
handleTap(e.clientX, e.clientY);
});
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.changedTouches[0];
handleTap(touch.clientX, touch.clientY);
}, {passive: false});
// ============ 启动 ============
resize();
initBoard();
lastSpawn = performance.now();
for (let i = 0; i < 3; i++) spawnBlock();
requestAnimationFrame(gameLoop);
</script>
</body>
</html>

View File

@@ -1,10 +1,6 @@
{
"name": "merge-game",
"version": "1.0.0",
"description": "合成大作战 - Cocos Creator 3.8.8 合成小游戏",
"author": "lywdev",
"scripts": {
"build": "cocos build",
"run": "cocos run"
}
"description": "合成大作战 - Cocos Creator 3.8.8 拖拽合成小游戏",
"author": "lywdev"
}

View File

@@ -2,6 +2,5 @@
"name": "合成大作战",
"platform": "mobile",
"version": "3.8.8",
"description": "8x12 棋盘合成小游戏",
"author": "lywdev"
"description": "8x12 棋盘拖拽合成小游戏"
}

View File

@@ -13,7 +13,5 @@
"sourceMap": true,
"types": []
},
"include": [
"assets/**/*"
]
"include": ["assets/**/*"]
}