重构: 合成大作战改为拖拽合成机制
- 全部代码重写,改为拖拽合成(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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
345
TUTORIAL.md
345
TUTORIAL.md
@@ -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/)
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
283
assets/Scripts/GameController.ts
Normal file
283
assets/Scripts/GameController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
448
index.html
448
index.html
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,5 @@
|
||||
"name": "合成大作战",
|
||||
"platform": "mobile",
|
||||
"version": "3.8.8",
|
||||
"description": "8x12 棋盘合成小游戏",
|
||||
"author": "lywdev"
|
||||
"description": "8x12 棋盘拖拽合成小游戏"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,5 @@
|
||||
"sourceMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"assets/**/*"
|
||||
]
|
||||
"include": ["assets/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user