Commit c66d9c92 authored by 廖洪发's avatar 廖洪发

init: 弹跳篮球 v2 项目骨架与核心玩法代码

parents
[InternetShortcut]
URL=https://docs.cocos.com/creator/manual/en/scripting/setup.html#custom-script-template
\ No newline at end of file
# Cocos Creator 生成目录
/library/
/temp/
/profiles/
/local/
/package-lock.json
# 构建产物
/build/
# 编辑器/系统文件
.DS_Store
Thumbs.db
*.log
# Node
/node_modules/
# 单挑篮球 v2 · Cocos Creator 3.8
模仿微信小游戏《单挑篮球》的 **1v1 街头斗牛** 小游戏(设计底稿见 `../单挑篮球-玩法与特点.md`)。
技术栈:**Cocos Creator 3.8(TypeScript)** → 构建发布**微信小游戏**(也可预览/构建其它平台)。
> v2 = 干净工程重写:场景与元数据用编辑器已验证的标准格式;脚本全部重写并
> 按底稿补齐**假动作投篮**(投篮键:轻点=虚晃骗防守,长按=出手,右侧拖拽=甩动真投)。
---
## 一、环境要求与打开方式
- **Cocos Creator 3.8.x**(本机实测 3.8.8;脚本按编辑器 strict 编译,改动后跑 `node tools/typecheck.js` 自查)
- 打开:Cocos Dashboard → 导入项目 → 选择本目录 → 打开后双击 `assets/scenes/main.scene`
**把 `assets/scripts/Main.ts` 挂到 GameRoot 节点**(详见 `挂载说明.md`)→ 保存 → 预览 / 构建微信小游戏
> 场景为 `Canvas + Camera + GameRoot[Main]` 标准结构。若编辑器提示脚本缺失:
> 右键 `assets` → 重新导入资源 → 重启编辑器;仍不行就按 `挂载说明.md` 自己新建场景挂 `Main`。
## 二、操作方式(v2 交互)
### 移动
- 左半区**按下即出浮动摇杆**:死区 + 幂响应曲线(小幅细腻、推满冲刺),松手消失
- 加速/急停物理(地面加速 1500、急停 1800、空中衰减);甩动瞄准时自动站稳
- 键盘:`←→` 移动;双人 P2:`A/D`
### 投篮(三种方式)
| 操作 | 效果 |
|---|---|
| 右侧区域**拖拽甩动** | 实时抛物线预览(落点压筐=必进),松手按方向+力度出手,完美出手有反馈 |
| 投篮键**轻点** | **假动作虚晃**:AI 防守会被骗起跳,窗口内再出手 = 空位加成(盖帽率大降) |
| 投篮键**长按 ≥0.22s** | 快捷出手:近筐上篮 / 起跳近筐暴扣;防守时按钮自动变[抢断] |
| 跳跃键 | 起跳 / 封盖 |
- 键盘:`X` = 快捷出手;鼠标拖拽 = 甩动
- 反馈:得分/暴扣**震屏**;完美出手/得分/盖帽/抢断**震动**(微信);程序化音效,`M` 静音
## 三、玩法(对齐设计底稿)
- 180 秒 1v1,平分自动加时 30 秒;三分线外 3 分、线内 2 分
- 跳投 / 上篮 / 暴扣 / 盖帽 / 抢断 / 干扰 / **假动作骗跳** 全套路
- 模式:快速匹配(AI 三档)/ 双人对战(同屏键盘)/ 投篮练习
- 金币结算 + 激励广告桩(失败看广告加时 30 秒、看广告双倍金币)
- 广告位:`assets/scripts/Ads.ts``adunit-0000000000000000` 上线前替换
## 四、代码结构
```
assets/
├─ scenes/main.scene # 场景骨架(Canvas + Camera + GameRoot;Main 需手动挂载)
└─ scripts/
├─ Main.ts # 根组件:状态机 + 输入分发 + 广告接入(可挂 Canvas 或子节点)
├─ InputManager.ts # ★ 交互:浮动摇杆 / 甩动出手 / 轻点假动作 / 长按出手 / 键盘
├─ MatchManager.ts # 规则:球权/计时/计分/弹道/扣篮/盖帽/抢断/假动作/AI/加时/结算
├─ WorldView.ts # 渲染:球场/球员/篮球/瞄准轨迹/震屏
├─ HudView.ts # 对局 HUD
├─ MenuView.ts / OverView.ts # 菜单 / 结算
├─ Utils.ts # 数学/平台/音效/震动/存档
└─ Ads.ts # 激励视频广告桩
settings/ # 项目设置(设计分辨率 800x450,信箱模式)
tools/
├─ check.js # 静态校验(JSON/结构/引用/UUID)
├─ typecheck.js # 编辑器同款 strict 类型检查(改脚本后必跑)
└─ logic-test.js # 弹道/甩动公式数值验证(19 项)
```
> 逻辑层沿用"y 向下 800x450"内部坐标系,渲染层统一转换,移植风险低。
## 五、验证
```bash
node tools/typecheck.js # 编辑器同款 strict 检查,0 错误
node tools/check.js # 静态校验 13 项
node tools/logic-test.js # 弹道/甩动数值测试 19 项
```
## 六、已知限制
- 音效为程序合成、美术为 Graphics 程序化绘制——可替换精灵图/预制体
- 第一版不做真人联网排位/全国赛(联机是另一套工程);球员养成简化为角色差异
- 双人模式 P2 仅支持键盘(手机端后续可做第二套触屏布局)
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"files": [],
"subMetas": {},
"userData": {}
}
This diff is collapsed.
{
"ver": "1.1.50",
"importer": "scene",
"imported": true,
"uuid": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"files": [
".json"
],
"subMetas": {},
"userData": {}
}
[
{
"__type__": "cc.SceneAsset",
"_name": "traj-test",
"_objFlags": 0,
"__editorExtras__": {},
"_native": "",
"scene": {
"__id__": 1
}
},
{
"__type__": "cc.Scene",
"_name": "traj-test",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": null,
"_children": [
{
"__id__": 2
}
],
"_active": true,
"_components": [],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"autoReleaseAssets": false,
"_globals": {
"__id__": 10
},
"_id": "a7b8c9d0-1e2f-3a4b-5c6d-7e8f90a1b2c3"
},
{
"__type__": "cc.Node",
"_name": "Canvas",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 1
},
"_children": [
{
"__id__": 3
},
{
"__id__": 5
}
],
"_active": true,
"_components": [
{
"__id__": 6
},
{
"__id__": 7
},
{
"__id__": 8
},
{
"__id__": 9
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 400,
"y": 225.00000000000003,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 33554432,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "beT1V6jXpBEaJgYJ1mX9Kf"
},
{
"__type__": "cc.Node",
"_name": "Camera",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [
{
"__id__": 4
}
],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 1000
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 1073741824,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "cK2uM8pQrS5tV7wX9yZ1aB3cD"
},
{
"__type__": "cc.Camera",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 3
},
"_enabled": true,
"__prefab": null,
"_projection": 0,
"_priority": 0,
"_fov": 45,
"_fovAxis": 0,
"_orthoHeight": 278.448275862069,
"_near": 0,
"_far": 1000,
"_color": {
"__type__": "cc.Color",
"r": 12,
"g": 18,
"b": 38,
"a": 255
},
"_depth": 1,
"_stencil": 0,
"_clearFlags": 7,
"_rect": {
"__type__": "cc.Rect",
"x": 0,
"y": 0,
"width": 1,
"height": 1
},
"_aperture": 19,
"_shutter": 7,
"_iso": 0,
"_screenScale": 1,
"_visibility": 41943040,
"_targetTexture": null,
"_postProcess": null,
"_usePostProcess": false,
"_cameraType": -1,
"_trackingType": 0,
"_id": "hV7rT0wX2yZ4aB6cD8eF9gH1iJ"
},
{
"__type__": "cc.Node",
"_name": "GameRoot",
"_objFlags": 0,
"__editorExtras__": {},
"_parent": {
"__id__": 2
},
"_children": [],
"_active": true,
"_components": [],
"_prefab": null,
"_lpos": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_lrot": {
"__type__": "cc.Quat",
"x": 0,
"y": 0,
"z": 0,
"w": 1
},
"_lscale": {
"__type__": "cc.Vec3",
"x": 1,
"y": 1,
"z": 1
},
"_mobility": 0,
"_layer": 33554432,
"_euler": {
"__type__": "cc.Vec3",
"x": 0,
"y": 0,
"z": 0
},
"_id": "dR3nP6sT8uV0wX2yZ4aB5cD7eF"
},
{
"__type__": "cc.Canvas",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"_cameraComponent": {
"__id__": 4
},
"_alignCanvasWithScreen": true,
"_id": "eS4oQ7tU9vW1xY3zA5bC6dE8fG"
},
{
"__type__": "cc.UITransform",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"_contentSize": {
"__type__": "cc.Size",
"width": 800,
"height": 450.00000000000006
},
"_anchorPoint": {
"__type__": "cc.Vec2",
"x": 0.5,
"y": 0.5
},
"_id": "fT5pR8uV0wX2yZ4aB6cD7eF9gH"
},
{
"__type__": "cc.Widget",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"_alignFlags": 45,
"_target": null,
"_left": 0,
"_right": 0,
"_top": 0,
"_bottom": 0,
"_horizontalCenter": 0,
"_verticalCenter": 0,
"_isAbsLeft": true,
"_isAbsRight": true,
"_isAbsTop": true,
"_isAbsBottom": true,
"_isAbsHorizontalCenter": true,
"_isAbsVerticalCenter": true,
"_originalWidth": 0,
"_originalHeight": 0,
"_alignMode": 2,
"_lockFlags": 0,
"_id": "gU6qS9vW1xY3zA5bC7dE8fG0hI"
},
{
"__type__": "9a0b1wtPk9aa3yNng8aKzxN",
"_name": "",
"_objFlags": 0,
"__editorExtras__": {},
"node": {
"__id__": 2
},
"_enabled": true,
"__prefab": null,
"_id": "7a4eCpRMtMfp7EleYWAJqC"
},
{
"__type__": "cc.SceneGlobals",
"ambient": {
"__id__": 11
},
"shadows": {
"__id__": 12
},
"_skybox": {
"__id__": 13
},
"fog": {
"__id__": 14
},
"octree": {
"__id__": 15
},
"skin": {
"__id__": 16
},
"lightProbeInfo": {
"__id__": 17
},
"postSettings": {
"__id__": 18
},
"bakedWithStationaryMainLight": false,
"bakedWithHighpLightmap": false
},
{
"__type__": "cc.AmbientInfo",
"_skyColorHDR": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.5,
"z": 0.8,
"w": 0.520833125
},
"_skyColor": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.5,
"z": 0.8,
"w": 0.520833125
},
"_skyIllumHDR": 20000,
"_skyIllum": 20000,
"_groundAlbedoHDR": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.2,
"z": 0.2,
"w": 1
},
"_groundAlbedo": {
"__type__": "cc.Vec4",
"x": 0.2,
"y": 0.2,
"z": 0.2,
"w": 1
},
"_skyColorLDR": {
"__type__": "cc.Vec4",
"x": 0.452588,
"y": 0.607642,
"z": 0.755699,
"w": 0
},
"_skyIllumLDR": 0.8,
"_groundAlbedoLDR": {
"__type__": "cc.Vec4",
"x": 0.618555,
"y": 0.577848,
"z": 0.544564,
"w": 0
}
},
{
"__type__": "cc.ShadowsInfo",
"_enabled": false,
"_type": 0,
"_normal": {
"__type__": "cc.Vec3",
"x": 0,
"y": 1,
"z": 0
},
"_distance": 0,
"_planeBias": 1,
"_shadowColor": {
"__type__": "cc.Color",
"r": 76,
"g": 76,
"b": 76,
"a": 255
},
"_maxReceived": 4,
"_size": {
"__type__": "cc.Vec2",
"x": 1024,
"y": 1024
}
},
{
"__type__": "cc.SkyboxInfo",
"_envLightingType": 0,
"_envmapHDR": null,
"_envmap": null,
"_envmapLDR": null,
"_diffuseMapHDR": null,
"_diffuseMapLDR": null,
"_enabled": false,
"_useHDR": true,
"_editableMaterial": null,
"_reflectionHDR": null,
"_reflectionLDR": null,
"_rotationAngle": 0
},
{
"__type__": "cc.FogInfo",
"_type": 0,
"_fogColor": {
"__type__": "cc.Color",
"r": 200,
"g": 200,
"b": 200,
"a": 255
},
"_enabled": false,
"_fogDensity": 0.3,
"_fogStart": 0.5,
"_fogEnd": 300,
"_fogAtten": 5,
"_fogTop": 1.5,
"_fogRange": 1.2,
"_accurate": false
},
{
"__type__": "cc.OctreeInfo",
"_enabled": false,
"_minPos": {
"__type__": "cc.Vec3",
"x": -1024,
"y": -1024,
"z": -1024
},
"_maxPos": {
"__type__": "cc.Vec3",
"x": 1024,
"y": 1024,
"z": 1024
},
"_depth": 8
},
{
"__type__": "cc.SkinInfo",
"_enabled": true,
"_blurRadius": 0.01,
"_sssIntensity": 3
},
{
"__type__": "cc.LightProbeInfo",
"_giScale": 1,
"_giSamples": 1024,
"_bounces": 2,
"_reduceRinging": 0,
"_showProbe": true,
"_showWireframe": true,
"_showConvex": false,
"_data": null,
"_lightProbeSphereVolume": 1
},
{
"__type__": "cc.PostSettingsInfo",
"_toneMappingType": 0
}
]
\ No newline at end of file
{
"ver": "1.1.50",
"importer": "scene",
"imported": true,
"uuid": "a7b8c9d0-1e2f-3a4b-5c6d-7e8f90a1b2c3",
"files": [
".json"
],
"subMetas": {},
"userData": {}
}
\ No newline at end of file
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "c3d4e5f6-a7b8-9c0d-1e2f-3a4b5c6d7e8f",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// Ads.ts —— 激励视频广告桩(IAA 商业化)
// 微信环境接入真实激励视频;浏览器/编辑器预览用延时模拟。
// 上线前请将 adUnitId 替换为微信广告后台的真实广告位 ID。
// ============================================================
export function showRewardAd(onReward: () => void, onFail?: () => void): void {
const g = globalThis as any;
if (g.wx && g.wx.createRewardedVideoAd) {
try {
const ad = g.wx.createRewardedVideoAd({
adUnitId: 'adunit-0000000000000000' // TODO: 替换为真实广告位
});
ad.onClose((res: any) => {
if (res && res.isEnded) {
if (onReward) onReward();
} else {
if (onFail) onFail();
}
});
ad.show().catch(() => {
ad.load().then(() => ad.show()).catch(() => {
if (onFail) onFail();
});
});
} catch (e) {
if (onFail) onFail();
}
} else {
// 浏览器 / 编辑器预览:模拟看完广告
if (onReward) setTimeout(onReward, 350);
}
}
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8192a3b4-c5d6-e7f8-a9b1-c2d3e4f50617",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// HudView.ts —— 对局 HUD:记分牌/计时/横幅/浮动提示/虚拟按键/摇杆
// ============================================================
import { Node, Graphics, Label, Color, UITransform } from 'cc';
import { DESIGN_W, DESIGN_H } from './Utils';
import { MatchState } from './MatchManager';
import { InputFrame } from './InputManager';
function makeLabel(parent: Node, name: string, size: number): { node: Node; label: Label } {
const n = new Node(name);
n.addComponent(UITransform);
const l = n.addComponent(Label);
l.fontSize = size;
l.color = new Color(255, 255, 255, 255);
l.horizontalAlign = Label.HorizontalAlign.CENTER;
l.verticalAlign = Label.VerticalAlign.CENTER;
parent.addChild(n);
return { node: n, label: l };
}
export class HudView {
readonly node: Node;
private nameL: Label[] = [];
private scoreL!: Label;
private timerL!: Label;
private bannerL!: Label;
private bannerSubL!: Label;
private hintL!: Label;
private toastNodes: { node: Node; label: Label }[] = [];
private ctrlG: Graphics;
private btnJumpL!: Label;
private btnShootL!: Label;
constructor(parent: Node) {
this.node = new Node('Hud');
this.node.addComponent(UITransform);
parent.addChild(this.node);
// 记分牌
const bg = new Node('ScoreBg');
bg.addComponent(UITransform);
this.node.addChild(bg);
const bgG = bg.addComponent(Graphics);
bgG.fillColor = new Color(0, 0, 0, 115);
bgG.roundRect(-250, 158, 500, 44, 12);
bgG.fill();
const n1 = makeLabel(this.node, 'Name1', 26);
n1.node.setPosition(-160, 180);
n1.label.color = new Color(125, 211, 252, 255);
this.nameL.push(n1.label);
const n2 = makeLabel(this.node, 'Name2', 26);
n2.node.setPosition(160, 180);
n2.label.color = new Color(253, 164, 175, 255);
this.nameL.push(n2.label);
const sc = makeLabel(this.node, 'Score', 26);
sc.node.setPosition(0, 181);
this.scoreL = sc.label;
const tm = makeLabel(this.node, 'Timer', 22);
tm.node.setPosition(300, 180);
this.timerL = tm.label;
// 横幅
const bn = makeLabel(this.node, 'Banner', 42);
bn.node.setPosition(0, 30);
bn.label.color = new Color(253, 224, 71, 255);
(bn.label as any).isBold = true;
this.bannerL = bn.label;
this.bannerL.node.active = false;
const bs = makeLabel(this.node, 'BannerSub', 20);
bs.node.setPosition(0, -6);
this.bannerSubL = bs.label;
this.bannerSubL.node.active = false;
// 操作提示(甩动 / 假动作窗口)
const hint = makeLabel(this.node, 'FlickHint', 16);
hint.node.setPosition(0, 120);
hint.label.color = new Color(255, 255, 255, 220);
this.hintL = hint.label;
this.hintL.node.active = false;
// 虚拟按键
const ctrl = new Node('Controls');
ctrl.addComponent(UITransform);
this.node.addChild(ctrl);
this.ctrlG = ctrl.addComponent(Graphics);
const jb = makeLabel(this.node, 'JumpBtn', 16);
jb.node.setPosition(this.btnJumpPos().x, this.btnJumpPos().y);
this.btnJumpL = jb.label;
this.btnJumpL.string = '跳跃';
const sb = makeLabel(this.node, 'ShootBtn', 16);
sb.node.setPosition(this.btnShootPos().x, this.btnShootPos().y);
this.btnShootL = sb.label;
}
private btnJumpPos() { return { x: DESIGN_W / 2 - 168, y: -(DESIGN_H / 2 - 116) }; }
private btnShootPos() { return { x: DESIGN_W / 2 - 62, y: -(DESIGN_H / 2 - 66) }; }
reset(match: MatchState): void {
this.nameL[0].string = match.players[0].name;
this.nameL[1].string = match.players[1].name;
this.bannerL.node.active = false;
this.bannerSubL.node.active = false;
this.hintL.node.active = false;
for (const t of this.toastNodes) t.node.active = false;
}
update(match: MatchState, frame: InputFrame): void {
this.scoreL.string = match.score[0] + ' : ' + match.score[1];
const m = Math.floor(match.timer / 60);
const s = Math.floor(match.timer % 60);
this.timerL.string = (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
this.timerL.color = match.timer <= 10 ? new Color(248, 113, 113, 255) : new Color(255, 255, 255, 255);
// 横幅
if (match.phase === 'banner') {
this.bannerL.string = match.bannerText;
this.bannerL.node.active = true;
this.bannerSubL.string = match.bannerSub;
this.bannerSubL.node.active = match.bannerSub !== '';
} else {
this.bannerL.node.active = false;
this.bannerSubL.node.active = false;
}
// 操作提示:甩动中 → 松开出手;假动作窗口 → 趁起跳出手
if (frame.flick.active) {
this.hintL.string = '松开出手';
this.hintL.node.active = true;
} else if (match.fakeT > 0 && match.ball.held && match.ball.held.idx === 0) {
this.hintL.string = '骗起跳了! 出手!';
this.hintL.node.active = true;
} else {
this.hintL.node.active = false;
}
// 浮动提示
let ti = 0;
for (const t of match.toasts) {
const tn = this.getToast(ti++);
tn.node.active = true;
tn.node.setPosition(matchToDesignX(t.x), matchToDesignY(t.y) - t.t * 40);
tn.label.string = t.text;
tn.label.color = new Color(253, 224, 71, Math.round(255 * (1 - t.t)));
}
for (; ti < this.toastNodes.length; ti++) this.toastNodes[ti].node.active = false;
// 虚拟按键
this.drawControls(frame);
}
private getToast(i: number): { node: Node; label: Label } {
while (this.toastNodes.length <= i) {
const t = makeLabel(this.node, 'Toast' + this.toastNodes.length, 22);
(t.label as any).isBold = true;
this.toastNodes.push(t);
}
return this.toastNodes[i];
}
private drawControls(frame: InputFrame): void {
const g = this.ctrlG;
g.clear();
// 摇杆
const joy = frame.joyUI;
if (joy.active) {
g.strokeColor = new Color(255, 255, 255, 60);
g.lineWidth = 2;
g.circle(joy.baseX, joy.baseY, 46);
g.stroke();
g.fillColor = new Color(255, 255, 255, 70);
g.circle(joy.knobX, joy.knobY, 18);
g.fill();
}
// 投篮/抢断 按钮
const bs = frame.btnShoot;
g.fillColor = new Color(244, 63, 94, 90);
g.circle(bs.x, bs.y, bs.r);
g.fill();
g.strokeColor = new Color(255, 255, 255, 180);
g.lineWidth = 2;
g.circle(bs.x, bs.y, bs.r);
g.stroke();
// 跳跃按钮
const bj = frame.btnJump;
g.fillColor = new Color(56, 189, 248, 90);
g.circle(bj.x, bj.y, bj.r);
g.fill();
g.strokeColor = new Color(255, 255, 255, 180);
g.lineWidth = 2;
g.circle(bj.x, bj.y, bj.r);
g.stroke();
}
setShootBtnLabel(text: string): void {
this.btnShootL.string = text;
}
}
function matchToDesignX(x: number): number { return x - DESIGN_W / 2; }
function matchToDesignY(y: number): number { return DESIGN_H / 2 - y; }
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4d5e6f70-8192-a3b4-c5d6-e7f8a9b1c2d3",
"files": [],
"subMetas": {},
"userData": {}
}
This diff is collapsed.
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// Main.ts —— 根组件:场景状态机(菜单 → 对局 → 结算)+ 输入分发
// 场景中只需要把本组件挂到一个节点上:推荐 Canvas 下的空子节点(GameRoot);
// 直接挂在 Canvas 上也可以(脚本会自动创建 GameRoot 子节点)。
// 所有界面与视图均在运行时由代码创建,无需在检查器配置任何属性。
// ============================================================
import { _decorator, Component, Node, Canvas, UITransform, view } from 'cc';
import {
DESIGN_W, DESIGN_H, getCoins, addCoins, setMuted, isMuted, sfxClick
} from './Utils';
import { InputManager, InputFrame } from './InputManager';
import {
createMatch, updateMatch, resolveFlick, computeTrajectory,
addOvertime, doubleCoins, MatchState
} from './MatchManager';
import { WorldView } from './WorldView';
import { HudView } from './HudView';
import { MenuView } from './MenuView';
import { OverView } from './OverView';
import { showRewardAd } from './Ads';
const { ccclass } = _decorator;
@ccclass('Main')
export class Main extends Component {
private inputMgr!: InputManager;
private world!: WorldView;
private hud!: HudView;
private menu!: MenuView;
private over!: OverView;
private match: MatchState | null = null;
private screen: string = 'menu';
private searchingT = 0;
private lastMode = 'ai';
private lastDiff = 2;
private lastPhase = '';
start(): void {
// 确定根节点:直接挂在 Canvas 上时自建 GameRoot 子节点,否则以自身为根
let root = this.node;
if (this.node.getComponent(Canvas)) {
const g = new Node('GameRoot');
g.layer = this.node.layer;
this.node.addChild(g);
root = g;
}
root.addComponent(UITransform);
// 设计分辨率 800x450 的自适应缩放
const vs = view.getVisibleSize();
const s = Math.min(vs.width / DESIGN_W, vs.height / DESIGN_H);
root.setScale(s, s, 1);
this.inputMgr = new InputManager(root);
this.world = new WorldView(root);
this.hud = new HudView(root);
this.menu = new MenuView(root);
this.over = new OverView(root);
this.hud.node.active = false;
this.over.hide();
this.menu.show(getCoins());
this.inputMgr.setUiMode(true); // 初始为菜单界面
}
private startMatch(mode: string, diff: number): void {
this.lastMode = mode;
this.lastDiff = diff;
this.match = createMatch(mode, diff);
this.screen = 'game';
this.lastPhase = '';
this.menu.hide();
this.over.hide();
this.hud.node.active = true;
this.hud.reset(this.match);
this.hud.setShootBtnLabel('投篮');
this.inputMgr.setUiMode(false); // 对局模式:启用摇杆
}
update(dt: number): void {
const f = this.inputMgr.update(dt);
if (f.muteEdge) setMuted(!isMuted());
if (this.screen === 'menu') {
if (this.searchingT > 0) {
this.searchingT -= dt;
if (this.searchingT <= 0) {
this.menu.setSearching(false);
this.startMatch('ai', 2);
}
}
} else if (this.screen === 'game') {
if (!this.match) return;
const m = this.match;
// 投篮按钮语义随攻防切换
const holder = m.ball.held;
this.hud.setShootBtnLabel(holder && holder.idx === 1 ? '抢断' : '投篮');
const before = m.phase;
updateMatch(m, f, dt);
this.world.update(dt);
this.world.drawActors(m);
this.hud.update(m, f);
// 震屏:得分小震,暴扣大震
if (m.phase !== before && m.phase === 'banner') {
if (m.bannerText.indexOf('暴扣') >= 0) this.world.shake(8, 0.30);
else this.world.shake(4, 0.20);
}
// 拖拽瞄准 → 抛物线预览(仅玩家持球时)
if (f.flick.active && m.ball.held && m.ball.held.idx === 0) {
const h = m.ball.held;
const v = resolveFlick(m, h, f.flick);
const pts = computeTrajectory(h.x + h.facing * 16, h.y - 60, v.vx, v.vy, 26);
this.world.drawAim(pts, f.flick.startX, f.flick.startY);
} else {
this.world.clearAim();
}
if (m.phase === 'over') {
this.screen = 'over';
this.world.clearAim();
this.over.show(m);
this.inputMgr.setUiMode(true); // 结算界面
}
} else if (this.screen === 'over') {
if (f.confirm) this.startMatch(this.lastMode, this.lastDiff);
}
// UI 点击
for (const t of f.taps) this.onTap(t.x, t.y);
}
private onTap(x: number, y: number): void {
sfxClick();
if (this.screen === 'menu') {
if (this.searchingT > 0) return;
const id = this.menu.hitTest(x, y);
if (id === 'ai') {
this.searchingT = 1.0;
this.menu.setSearching(true);
} else if (id === '2p') {
this.startMatch('local2p', 2);
} else if (id === 'practice') {
this.startMatch('practice', 0);
}
} else if (this.screen === 'over') {
if (!this.match) return;
const m = this.match;
const id = this.over.hitTest(x, y);
if (id === 'restart') {
this.startMatch(this.lastMode, this.lastDiff);
} else if (id === 'menu') {
this.screen = 'menu';
this.hud.node.active = false;
this.over.hide();
this.menu.show(getCoins());
} else if (id === 'adot') {
showRewardAd(() => {
addOvertime(m);
this.screen = 'game';
this.over.hide();
this.hud.node.active = true;
this.hud.reset(m);
this.inputMgr.setUiMode(false);
}, () => { /* 未看完广告,无奖励 */ });
} else if (id === 'addouble') {
showRewardAd(() => {
doubleCoins(m);
addCoins(m.baseCoins);
this.over.show(m);
}, () => { /* 未看完广告,无奖励 */ });
}
}
}
onDestroy(): void {
if (this.inputMgr) this.inputMgr.destroy();
}
}
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8f2a1b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"files": [],
"subMetas": {},
"userData": {}
}
This diff is collapsed.
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2b3c4d5e-6f70-8192-a3b4-c5d6e7f8a91b",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// MenuView.ts —— 主菜单(标题 / 模式按钮 / 金币 / 操作提示)
// ============================================================
import { Node, Graphics, Label, Color, UITransform } from 'cc';
import { DESIGN_W, DESIGN_H } from './Utils';
export interface MenuButton {
id: string;
text: string;
sub: string;
rect: { x: number; y: number; w: number; h: number };
}
function makeLabel(parent: Node, name: string, size: number): Label {
const n = new Node(name);
n.addComponent(UITransform);
const l = n.addComponent(Label);
l.fontSize = size;
l.color = new Color(255, 255, 255, 255);
l.horizontalAlign = Label.HorizontalAlign.CENTER;
l.verticalAlign = Label.VerticalAlign.CENTER;
parent.addChild(n);
return l;
}
export class MenuView {
readonly node: Node;
private coinL!: Label;
private hintL!: Label;
private searchL!: Label;
readonly buttons: MenuButton[] = [
{ id: 'ai', text: '快速匹配', sub: '对战 AI 对手 · 3分钟1局', rect: { x: -170, y: -40, w: 340, h: 62 } },
{ id: '2p', text: '双人对战', sub: '同屏双人(P2 键盘)', rect: { x: -170, y: -118, w: 340, h: 62 } },
{ id: 'practice', text: '投篮练习', sub: '无对抗练手感', rect: { x: -170, y: -196, w: 340, h: 62 } }
];
constructor(parent: Node) {
this.node = new Node('Menu');
this.node.addComponent(UITransform);
parent.addChild(this.node);
const bg = this.node.addComponent(Graphics);
bg.fillColor = new Color(11, 21, 38, 255);
bg.rect(-DESIGN_W / 2, -DESIGN_H / 2, DESIGN_W, DESIGN_H);
bg.fill();
// 装饰篮球
bg.fillColor = new Color(249, 115, 22, 26);
bg.circle(0, 130, 74);
bg.fill();
const title = makeLabel(this.node, 'Title', 56);
title.string = '单挑篮球';
title.color = new Color(253, 224, 71, 255);
(title as any).isBold = true;
title.node.setPosition(0, 135);
const sub = makeLabel(this.node, 'Sub', 16);
sub.string = '1v1 街头斗牛 · v2';
sub.color = new Color(255, 255, 255, 190);
sub.node.setPosition(0, 102);
const coin = makeLabel(this.node, 'Coins', 18);
coin.color = new Color(251, 191, 36, 255);
coin.node.setPosition(DESIGN_W / 2 - 100, DESIGN_H / 2 - 30);
this.coinL = coin;
const hint = makeLabel(this.node, 'Hint', 13);
hint.color = new Color(255, 255, 255, 130);
hint.string = '左摇杆移动 · 投篮键 轻点=假动作 长按=出手 · 右侧拖拽=甩动 | 桌面:←→移动 ↑跳跃 X出手';
hint.node.setPosition(0, -DESIGN_H / 2 + 20);
this.hintL = hint;
const search = makeLabel(this.node, 'Searching', 30);
search.color = new Color(253, 224, 71, 255);
(search as any).isBold = true;
search.node.setPosition(0, 0);
search.string = '匹配对手中…';
search.node.active = false;
this.searchL = search;
// 按钮
const g = this.node.addComponent(Graphics);
for (const b of this.buttons) {
this.drawBtn(g, b.rect, b.id === 'ai');
const tl = makeLabel(this.node, 'Btn' + b.id, 22);
tl.string = b.text;
tl.node.setPosition(b.rect.x + b.rect.w / 2, b.rect.y + b.rect.h / 2 + 6);
const sl = makeLabel(this.node, 'BtnSub' + b.id, 13);
sl.string = b.sub;
sl.color = new Color(255, 255, 255, 150);
sl.node.setPosition(b.rect.x + b.rect.w / 2, b.rect.y + b.rect.h / 2 - 14);
}
}
private drawBtn(g: Graphics, r: { x: number; y: number; w: number; h: number }, accent: boolean): void {
g.fillColor = accent ? new Color(250, 204, 21, 55) : new Color(255, 255, 255, 25);
g.roundRect(r.x, r.y, r.w, r.h, 12);
g.fill();
g.strokeColor = accent ? new Color(250, 204, 21, 200) : new Color(255, 255, 255, 90);
g.lineWidth = 2;
g.roundRect(r.x, r.y, r.w, r.h, 12);
g.stroke();
}
show(coins: number): void {
this.node.active = true;
this.coinL.string = '金币 ' + coins;
this.searchL.node.active = false;
}
hide(): void { this.node.active = false; }
setSearching(on: boolean): void {
this.searchL.node.active = on;
}
hitTest(x: number, y: number): string | null {
for (const b of this.buttons) {
const r = b.rect;
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return b.id;
}
return null;
}
}
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5e6f7081-92a3-b4c5-d6e7-f8a9b1c2d3e4",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// OverView.ts —— 结算界面(胜负/比分/金币/再来一局/广告入口)
// ============================================================
import { Node, Graphics, Label, Color, UITransform } from 'cc';
import { DESIGN_W, DESIGN_H } from './Utils';
import { MatchState } from './MatchManager';
export interface OverButton {
id: string;
text: string;
rect: { x: number; y: number; w: number; h: number };
}
function makeLabel(parent: Node, name: string, size: number): Label {
const n = new Node(name);
n.addComponent(UITransform);
const l = n.addComponent(Label);
l.fontSize = size;
l.color = new Color(255, 255, 255, 255);
l.horizontalAlign = Label.HorizontalAlign.CENTER;
l.verticalAlign = Label.VerticalAlign.CENTER;
parent.addChild(n);
return l;
}
export class OverView {
readonly node: Node;
private resultL!: Label;
private scoreL!: Label;
private coinL!: Label;
private hintL!: Label;
buttons: OverButton[] = [];
private match: MatchState | null = null;
private btnG!: Graphics;
constructor(parent: Node) {
this.node = new Node('Over');
this.node.addComponent(UITransform);
parent.addChild(this.node);
const bg = this.node.addComponent(Graphics);
bg.fillColor = new Color(2, 6, 17, 210);
bg.rect(-DESIGN_W / 2, -DESIGN_H / 2, DESIGN_W, DESIGN_H);
bg.fill();
this.resultL = makeLabel(this.node, 'Result', 52);
(this.resultL as any).isBold = true;
this.resultL.node.setPosition(0, 140);
this.scoreL = makeLabel(this.node, 'Score', 30);
this.scoreL.node.setPosition(0, 82);
this.coinL = makeLabel(this.node, 'Coins', 18);
this.coinL.color = new Color(251, 191, 36, 255);
this.coinL.node.setPosition(0, 42);
this.hintL = makeLabel(this.node, 'Hint', 13);
this.hintL.color = new Color(255, 255, 255, 130);
this.hintL.string = '广告按钮为激励视频桩,接入真实广告位后生效';
this.hintL.node.setPosition(0, -DESIGN_H / 2 + 20);
// 按钮由 show() 按比赛结果重建
const g = this.node.addComponent(Graphics);
this.btnG = g;
}
show(match: MatchState): void {
this.match = match;
this.node.active = true;
if (match.result === 'win') {
this.resultL.string = '胜利!';
this.resultL.color = new Color(74, 222, 128, 255);
} else if (match.result === 'lose') {
this.resultL.string = '惜败…';
this.resultL.color = new Color(248, 113, 113, 255);
} else {
this.resultL.string = '平局';
this.resultL.color = new Color(253, 224, 71, 255);
}
this.scoreL.string = match.score[0] + ' : ' + match.score[1];
this.coinL.string = '获得金币 +' + match.coinsEarned;
// 重建按钮
this.rebuildButtons(match);
}
hide(): void { this.node.active = false; }
private rebuildButtons(match: MatchState): void {
const btns: OverButton[] = [
{ id: 'restart', text: '再来一局', rect: { x: -170, y: -20, w: 340, h: 54 } },
{ id: 'menu', text: '返回菜单', rect: { x: -170, y: -84, w: 340, h: 54 } }
];
if (match.result !== 'win') {
btns.push({ id: 'adot', text: '看广告 · 加时30秒', rect: { x: -170, y: -148, w: 340, h: 54 } });
}
btns.push({ id: 'addouble', text: '看广告 · 双倍金币', rect: { x: -170, y: -212, w: 340, h: 54 } });
this.buttons = btns;
// 重绘(先清掉旧节点)
const old = this.node.getChildByName('Btns');
if (old) old.destroy();
const btnsNode = new Node('Btns');
btnsNode.addComponent(UITransform);
this.node.addChild(btnsNode);
const g = btnsNode.addComponent(Graphics);
for (const b of btns) {
const accent = b.id.indexOf('ad') === 0;
g.fillColor = accent ? new Color(250, 204, 21, 55) : new Color(255, 255, 255, 25);
g.roundRect(b.rect.x, b.rect.y, b.rect.w, b.rect.h, 12);
g.fill();
g.strokeColor = accent ? new Color(250, 204, 21, 200) : new Color(255, 255, 255, 90);
g.lineWidth = 2;
g.roundRect(b.rect.x, b.rect.y, b.rect.w, b.rect.h, 12);
g.stroke();
const tl = makeLabel(btnsNode, 'B' + b.id, 20);
tl.string = b.text;
tl.node.setPosition(b.rect.x + b.rect.w / 2, b.rect.y + b.rect.h / 2);
}
}
hitTest(x: number, y: number): string | null {
for (const b of this.buttons) {
const r = b.rect;
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) return b.id;
}
return null;
}
}
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "6f708192-a3b4-c5d6-e7f8-a9b1c2d3e4f5",
"files": [],
"subMetas": {},
"userData": {}
}
This diff is collapsed.
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// Utils.ts —— 通用工具:数学 / 平台 / 程序化音效 / 震动 / 存档
// 坐标系约定:游戏逻辑内部使用"y 向下"的 800x450 坐标(与早期原型一致),
// 渲染层(WorldView)负责转换为 Cocos 的 y 向上坐标。
// ============================================================
import { sys } from 'cc';
export const DESIGN_W = 800;
export const DESIGN_H = 450;
// ---------- 数学 ----------
export function clamp(v: number, a: number, b: number): number {
return v < a ? a : (v > b ? b : v);
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function rand(a: number, b: number): number {
return a + Math.random() * (b - a);
}
export function dist(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1, dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
export function sign(v: number): number {
return v > 0 ? 1 : (v < 0 ? -1 : 0);
}
// ---------- 平台 ----------
export function isWX(): boolean {
return !!(globalThis as any).wx;
}
// ---------- 程序化音效(WebAudio,无素材文件)----------
let actx: any = null;
let muted = false;
function ensureAudio(): any {
if (actx || muted) return actx;
try {
const g = globalThis as any;
if (typeof g.AudioContext !== 'undefined') actx = new g.AudioContext();
else if (typeof g.webkitAudioContext !== 'undefined') actx = new g.webkitAudioContext();
} catch (e) {
actx = null;
}
return actx;
}
function tone(freq: number, dur: number, type: string, vol: number) {
const a = ensureAudio();
if (!a || muted) return;
try {
const o = a.createOscillator();
const g = a.createGain();
o.type = type;
o.frequency.value = freq;
g.gain.setValueAtTime(vol || 0.12, a.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, a.currentTime + dur);
o.connect(g);
g.connect(a.destination);
o.start();
o.stop(a.currentTime + dur);
} catch (e) { /* 静默 */ }
}
export function setMuted(m: boolean) { muted = !!m; }
export function isMuted(): boolean { return muted; }
export function sfxBounce() { tone(150, 0.06, 'square', 0.10); }
export function sfxCatch() { tone(420, 0.08, 'triangle', 0.12); }
export function sfxSwish() { tone(880, 0.12, 'sine', 0.10); tone(1320, 0.10, 'sine', 0.07); }
export function sfxScore() { tone(660, 0.12, 'square', 0.12); tone(990, 0.16, 'square', 0.10); }
export function sfxBlock() { tone(120, 0.10, 'sawtooth', 0.12); }
export function sfxWhistle() { tone(2200, 0.18, 'square', 0.09); setTimeout(function () { tone(2200, 0.18, 'square', 0.09); }, 220); }
export function sfxClick() { tone(520, 0.05, 'triangle', 0.10); }
export function sfxJump() { tone(300, 0.10, 'sine', 0.06); }
// ---------- 震动反馈(微信小游戏)----------
export function vibrate(): void {
try {
const w = (globalThis as any).wx;
if (w && w.vibrateShort) w.vibrateShort({ type: 'medium' });
} catch (e) { /* 静默 */ }
}
// ---------- 存档(Cocos sys.localStorage 兼容微信/浏览器)----------
const COIN_KEY = 'dtlq_coins';
export function loadNum(key: string, def: number): number {
try {
const s = sys.localStorage.getItem(key);
if (s === null || s === undefined || s === '') return def;
const n = parseFloat(s);
return isNaN(n) ? def : n;
} catch (e) {
return def;
}
}
export function saveNum(key: string, val: number): void {
try {
sys.localStorage.setItem(key, String(val));
} catch (e) { /* 静默 */ }
}
export function getCoins(): number { return loadNum(COIN_KEY, 0); }
export function addCoins(n: number): void { saveNum(COIN_KEY, getCoins() + n); }
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "708192a3-b4c5-d6e7-f8a9-b1c2d3e4f506",
"files": [],
"subMetas": {},
"userData": {}
}
// ============================================================
// WorldView.ts —— 对局世界渲染(Graphics 程序化绘制)
// 内部逻辑坐标为"y 向下"(0..800 x 0..450),此处统一转换为
// Cocos 设计坐标(中心原点,y 向上)。
// 包含:静态球场、动态球员/篮球、拖拽瞄准抛物线预览、扣篮震屏。
// ============================================================
import { Node, Graphics, Color, UITransform } from 'cc';
import { DESIGN_W, DESIGN_H, rand } from './Utils';
import { COURT, MatchState, PlayerState, BallState } from './MatchManager';
function makeNode(name: string, parent: Node): Node {
const n = new Node(name);
n.addComponent(UITransform);
parent.addChild(n);
return n;
}
function toX(x: number): number { return x - DESIGN_W / 2; }
function toY(y: number): number { return DESIGN_H / 2 - y; }
export class WorldView {
readonly actors: Node;
private courtG: Graphics;
private actorG: Graphics;
private aimG: Graphics;
private shakeT = 0;
private shakeDur = 0;
private shakeAmp = 0;
constructor(parent: Node) {
const courtNode = makeNode('Court', parent);
this.courtG = courtNode.addComponent(Graphics);
this.drawCourt();
this.actors = makeNode('Actors', parent);
this.actorG = this.actors.addComponent(Graphics);
const aimNode = makeNode('AimPreview', parent);
this.aimG = aimNode.addComponent(Graphics);
}
// ---- 震屏 ----
shake(amp: number, dur: number): void {
this.shakeAmp = amp;
this.shakeDur = dur;
this.shakeT = dur;
}
update(dt: number): void {
if (this.shakeT > 0) {
this.shakeT -= dt;
const k = this.shakeT / this.shakeDur;
const a = this.shakeAmp * k;
this.actors.setPosition(rand(-a, a), rand(-a, a));
if (this.shakeT <= 0) this.actors.setPosition(0, 0);
}
}
// ---- 静态球场 ----
private drawCourt(): void {
const g = this.courtG;
const gx = toX(0), gy = toY(COURT.groundY);
// 天空(分层渐变色带模拟渐变)
const bands = 24;
for (let i = 0; i < bands; i++) {
const t = i / bands;
const r = Math.round(lerpC(11, 29, t));
const gg = Math.round(lerpC(21, 58, t));
const b = Math.round(lerpC(38, 95, t));
g.fillColor = new Color(r, gg, b, 255);
g.rect(-DESIGN_W / 2, toY(COURT.H) + (toY(0) - toY(COURT.H)) * t, DESIGN_W, (toY(0) - toY(COURT.H)) / bands + 1);
g.fill();
}
// 远处观众席剪影
g.fillColor = new Color(255, 255, 255, 14);
for (let i = 0; i < 26; i++) {
const sx = (i * 37) % DESIGN_W - DESIGN_W / 2;
const sy = toY(60 + ((i * 53) % 70));
g.rect(sx, sy, 22, 10);
g.fill();
}
// 地板
g.fillColor = new Color(138, 75, 34, 255);
g.rect(-DESIGN_W / 2, toY(COURT.H), DESIGN_W, toY(COURT.groundY) - toY(COURT.H));
g.fill();
g.strokeColor = new Color(0, 0, 0, 40);
g.lineWidth = 1;
for (let l = 0; l < 14; l++) {
const ly = toY(COURT.groundY + 6 + l * 8);
g.moveTo(-DESIGN_W / 2, ly);
g.lineTo(DESIGN_W / 2, ly);
g.stroke();
}
// 底线
g.strokeColor = new Color(255, 255, 255, 255);
g.lineWidth = 3;
g.moveTo(toX(COURT.leftX), gy);
g.lineTo(toX(COURT.rightX), gy);
g.stroke();
// 左边界墙
g.lineWidth = 4;
g.moveTo(toX(COURT.leftX), gy);
g.lineTo(toX(COURT.leftX), toY(COURT.groundY - 190));
g.stroke();
// 三分线(虚线竖线)
g.lineWidth = 2;
const threeX = toX(COURT.threeX);
const dashY = toY(COURT.groundY);
const dashTop = toY(COURT.groundY - 230);
let yy = dashY;
while (yy > dashTop) {
const y2 = Math.max(dashTop, yy - 10);
g.moveTo(threeX, yy);
g.lineTo(threeX, y2);
g.stroke();
yy = y2 - 8;
}
// 篮板
g.fillColor = new Color(255, 255, 255, 235);
g.rect(toX(COURT.boardX) - 6, toY(COURT.boardBot), 10, toY(COURT.boardTop) - toY(COURT.boardBot));
g.fill();
g.strokeColor = new Color(255, 255, 255, 255);
g.lineWidth = 2;
g.rect(toX(COURT.boardX) - 6, toY(COURT.boardBot), 10, toY(COURT.boardTop) - toY(COURT.boardBot));
g.stroke();
// 篮筐
const rimX = toX(COURT.rimX);
const rimY = toY(COURT.rimY);
g.strokeColor = new Color(239, 68, 68, 255);
g.lineWidth = 5;
g.moveTo(rimX - 15, rimY);
g.lineTo(rimX + 15, rimY);
g.stroke();
// 篮网
g.strokeColor = new Color(255, 255, 255, 130);
g.lineWidth = 1.5;
const netTop = rimY - 4;
const netBot = rimY - COURT.netLen;
for (let n = 0; n <= 4; n++) {
const t = n / 4;
g.moveTo(rimX - 14 + t * 28, netTop);
g.lineTo(rimX - 8 + t * 16, netBot);
g.stroke();
}
g.moveTo(rimX - 8, netBot);
g.lineTo(rimX + 8, netBot);
g.stroke();
}
// ---- 动态实体 ----
drawActors(match: MatchState): void {
const g = this.actorG;
g.clear();
this.drawPlayer(g, match.players[0]);
this.drawPlayer(g, match.players[1]);
this.drawBall(g, match.ball);
}
private drawPlayer(g: Graphics, p: PlayerState): void {
const x = toX(p.x);
const feetY = toY(p.y);
const h = 64, half = 10;
const bob = p.state === 'run' ? Math.abs(Math.sin(p.walkPhase)) * 3
: (p.state === 'fake' ? Math.abs(Math.sin(p.stateT * 26)) * 4 : 0);
const hipY = feetY + (h - bob - 22);
const headY = hipY + 15;
const swing = p.state === 'run' ? Math.sin(p.walkPhase) * 0.55 : 0;
const c1 = p.idx === 0 ? new Color(56, 189, 248, 255) : new Color(251, 113, 133, 255);
const c2 = p.idx === 0 ? new Color(3, 105, 161, 255) : new Color(190, 18, 60, 255);
// 影子
g.fillColor = new Color(0, 0, 0, 50);
g.ellipse(x, toY(COURT.groundY) + 2, 20, 4);
g.fill();
// 腿
g.strokeColor = new Color(17, 24, 39, 255);
g.lineWidth = 8;
(g as any).lineCap = 'round';
const lx1 = x - 7 + swing * 14;
const lx2 = x + 7 - swing * 14;
g.moveTo(x - 6, hipY); g.lineTo(lx1, feetY); g.stroke();
g.moveTo(x + 6, hipY); g.lineTo(lx2, feetY); g.stroke();
// 鞋
g.fillColor = new Color(248, 250, 252, 255);
g.circle(lx1, feetY - 3, 5); g.fill();
g.circle(lx2, feetY - 3, 5); g.fill();
// 躯干
g.fillColor = c1;
g.roundRect(x - half, hipY, half * 2, 30, 6);
g.fill();
// 号码(用横条示意)
g.fillColor = new Color(255, 255, 255, 220);
g.rect(x - 4, hipY + 13, 8, 3); g.fill();
g.rect(x - 4, hipY + 21, 8, 3); g.fill();
// 手臂
g.strokeColor = c2;
g.lineWidth = 6;
const shY = hipY + 24;
const armSwing = p.state === 'run' ? -swing * 0.9 : 0;
if (p.state === 'dunk') {
g.moveTo(x - 8, shY); g.lineTo(x - 15, shY + 26); g.stroke();
g.moveTo(x + 8, shY); g.lineTo(x + 15, shY + 26); g.stroke();
} else if (p.state === 'shoot') {
g.moveTo(x - 8, shY); g.lineTo(x - 12 - p.facing * 2, shY + 8); g.stroke();
g.moveTo(x + 8, shY); g.lineTo(x + 12 + p.facing * 8, shY + 30); g.stroke();
} else if (p.state === 'fake') {
// 假动作:双手抱球向上虚晃
g.moveTo(x - 8, shY); g.lineTo(x - 14, shY + 18); g.stroke();
g.moveTo(x + 8, shY); g.lineTo(x + 14, shY + 18); g.stroke();
// 球上抬示意
g.fillColor = new Color(249, 115, 22, 255);
g.circle(x, shY + 24, 8); g.fill();
} else if (p.state === 'jump') {
g.moveTo(x - 8, shY); g.lineTo(x - 13 - p.facing * 2, shY + 20); g.stroke();
g.moveTo(x + 8, shY); g.lineTo(x + 13 + p.facing * 6, shY + 22); g.stroke();
} else {
g.moveTo(x - 8, shY); g.lineTo(x - 10 + armSwing * 10, feetY + 16); g.stroke();
g.moveTo(x + 8, shY); g.lineTo(x + 10 - armSwing * 10, feetY + 16); g.stroke();
}
// 头 + 发带
g.fillColor = new Color(242, 196, 155, 255);
g.circle(x, headY, 8.5); g.fill();
g.fillColor = c2;
g.rect(x - 9, headY + 7, 18, 5); g.fill();
}
private drawBall(g: Graphics, b: BallState): void {
const x = toX(b.x);
const y = toY(b.y);
const r = b.r;
g.fillColor = new Color(249, 115, 22, 255);
g.circle(x, y, r); g.fill();
g.strokeColor = new Color(124, 45, 18, 255);
g.lineWidth = 1.5;
g.moveTo(x - r, y); g.lineTo(x + r, y); g.stroke();
g.moveTo(x, y - r); g.lineTo(x, y + r); g.stroke();
g.fillColor = new Color(255, 255, 255, 90);
g.circle(x - r * 0.35, y + r * 0.35, r * 0.28); g.fill();
}
// ---- 瞄准抛物线预览 ----
drawAim(pts: { x: number; y: number }[], flickSX: number, flickSY: number): void {
const g = this.aimG;
g.clear();
// 甩动方向线
g.strokeColor = new Color(255, 255, 255, 90);
g.lineWidth = 2;
g.moveTo(flickSX, flickSY);
g.lineTo(flickSX + (pts.length ? pts[pts.length - 1].x - 400 - flickSX : 0) * 0.3, flickSY + (pts.length ? toY(pts[pts.length - 1].y) - flickSY : 0) * 0.3);
g.stroke();
// 轨迹点
for (let i = 0; i < pts.length; i++) {
const a = 1 - (i / pts.length) * 0.6;
g.fillColor = new Color(255, 255, 255, Math.round(180 * a));
g.circle(toX(pts[i].x), toY(pts[i].y), i === 0 ? 4 : 3);
g.fill();
}
// 落点标记
if (pts.length) {
const last = pts[pts.length - 1];
const near = Math.abs(last.x - COURT.rimX) < 13 && last.y > COURT.rimY;
g.fillColor = near ? new Color(74, 222, 128, 255) : new Color(248, 113, 113, 255);
g.circle(toX(last.x), toY(last.y), 5);
g.fill();
}
}
clearAim(): void {
this.aimG.clear();
}
}
function lerpC(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "3c4d5e6f-7081-92a3-b4c5-d6e7f8a9b1c2",
"files": [],
"subMetas": {},
"userData": {}
}
{
"name": "dantiao-lanqiu-v2",
"uuid": "d1e2f3a4-b5c6-4d5e-8f90-1234567890ab",
"creator": {
"version": "3.8.8"
},
"description": "单挑篮球 v2 —— 模仿《单挑篮球》的 1v1 篮球小游戏(Cocos Creator 3.8)",
"main": "",
"dependencies": {}
}
{
"__version__": "1.3.9"
}
{
"__version__": "1.0.1"
}
{
"__version__": "1.0.12"
}
{
"__version__": "1.0.1",
"information": {
"customSplash": {
"id": "customSplash",
"label": "customSplash",
"enable": false,
"customSplash": {
"complete": false,
"form": "https://creator-api.cocos.com/api/form/show?"
}
},
"removeSplash": {
"id": "removeSplash",
"label": "removeSplash",
"enable": false,
"removeSplash": {
"complete": false,
"form": "https://creator-api.cocos.com/api/form/show?"
}
}
}
}
{
"__version__": "1.0.4"
}
{
"__version__": "1.0.6",
"general": {
"designResolution": {
"width": 800,
"height": 450,
"fitWidth": false,
"fitHeight": false
}
},
"physics": {
"physicsEngine": "cannon.js",
"gravity": {
"x": 0,
"y": -10,
"z": 0
}
},
"scripts": {
"bundleName": "main",
"useBuiltin": false
},
"layers": [
{ "name": "DEFAULT", "value": 1 },
{ "name": "UI_2D", "value": 33554432 },
{ "name": "UI_3D", "value": 8388608 }
],
"sortingLayers": [],
"splashScreen": {
"displayRatio": 1,
"totalTime": 1500,
"autoClear": true
}
}
// ============================================================
// tools/check.js —— Cocos 工程静态校验(无编辑器环境下的自查)
// 1) 全部 .json/.meta/.scene 可被 JSON.parse
// 2) TS 文件括号/引号平衡(去除字符串与注释后)
// 3) 场景 __id__ 引用越界检查;Main 组件 uuid 与 meta 一致
// 4) import 目标文件存在
// 运行:node tools/check.js
// ============================================================
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
let pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.log(' ✗ ' + msg); }
}
// ---- 1) JSON 文件 ----
console.log('[1] JSON 解析');
const jsonFiles = [];
function walk(dir) {
for (const name of fs.readdirSync(dir)) {
if (name === 'node_modules' || name === 'library' || name === 'temp') continue;
const full = path.join(dir, name);
const st = fs.statSync(full);
if (st.isDirectory()) walk(full);
else if (/\.(json|meta|scene)$/.test(name)) jsonFiles.push(full);
}
}
walk(ROOT);
for (const f of jsonFiles) {
try {
JSON.parse(fs.readFileSync(f, 'utf8'));
} catch (e) {
ok(false, path.relative(ROOT, f) + ' -> ' + e.message);
}
}
ok(true, jsonFiles.length + ' 个 JSON/meta/scene 文件全部可解析');
// ---- 2) TS 结构平衡 ----
console.log('[2] TS 结构平衡');
const tsFiles = [];
for (const f of jsonFiles) {
if (f.endsWith('.ts.meta')) tsFiles.push(f.slice(0, -5)); // 对应 .ts
}
function strip(src) {
// 去注释
src = src.replace(/\/\*[\s\S]*?\*\//g, '');
src = src.replace(/\/\/[^\n]*/g, '');
// 去字符串字面量
src = src.replace(/`[^`]*`/g, '""');
src = src.replace(/"(?:[^"\\]|\\.)*"/g, '""');
src = src.replace(/'(?:[^'\\]|\\.)*'/g, "''");
return src;
}
function checkBalance(src, label) {
const pairs = { '{': '}', '(': ')', '[': ']' };
const stack = [];
for (const ch of src) {
if (pairs[ch]) stack.push(ch);
else if (ch === '}' || ch === ')' || ch === ']') {
const top = stack.pop();
if (!top || pairs[top] !== ch) return false;
}
}
return stack.length === 0;
}
for (const f of tsFiles) {
const src = fs.readFileSync(f, 'utf8');
ok(checkBalance(strip(src), f), '平衡: ' + path.relative(ROOT, f));
}
// ---- 3) 场景引用 ----
console.log('[3] 场景引用');
const sceneFile = path.join(ROOT, 'assets', 'scenes', 'main.scene');
const scene = JSON.parse(fs.readFileSync(sceneFile, 'utf8'));
let refErr = 0;
(function walkRefs(obj) {
if (Array.isArray(obj)) { obj.forEach(walkRefs); return; }
if (obj && typeof obj === 'object') {
if (typeof obj.__id__ === 'number') {
if (obj.__id__ < 0 || obj.__id__ >= scene.length) refErr++;
}
for (const k of Object.keys(obj)) {
if (k === '__id__') continue;
walkRefs(obj[k]);
}
}
})(scene);
ok(refErr === 0, '__id__ 引用全部在范围内');
const mainMeta = JSON.parse(fs.readFileSync(path.join(ROOT, 'assets', 'scripts', 'Main.ts.meta'), 'utf8'));
const mainUuidHex = mainMeta.uuid.replace(/-/g, '');
// Cocos 场景里自定义组件 __type__ 可能用裸十六进制,也可能用压缩 UUID(编辑器保存时改写)
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
function compressUuid(uuid) {
uuid = uuid.replace(/-/g, '');
if (uuid.length !== 32) return uuid;
let out = uuid.slice(0, 5);
for (let i = 5; i < 32; i += 3) {
const a = parseInt(uuid[i], 16), b = parseInt(uuid[i + 1], 16), c = parseInt(uuid[i + 2], 16);
out += B64[(a << 2) | (b >> 2)];
out += B64[((b & 3) << 4) | c];
}
return out;
}
const mainUuidCompressed = compressUuid(mainMeta.uuid);
let found = false;
for (const o of scene) {
if (o && (o.__type__ === mainUuidHex || o.__type__ === mainUuidCompressed)) found = true;
}
if (found) {
ok(true, '场景 Main 组件 uuid 与 Main.ts.meta 一致');
} else {
pass++;
console.log(' · 场景未挂载 Main 组件(待编辑器挂载)——跳过 UUID 校验');
}
// ---- 4) import 目标 ----
console.log('[4] import 完整性');
let importErr = 0;
for (const f of tsFiles) {
const src = fs.readFileSync(f, 'utf8');
const re = /from\s+['"](\.\/[A-Za-z0-9_]+)['"]/g;
let m;
while ((m = re.exec(src)) !== null) {
const target = path.join(path.dirname(f), m[1] + '.ts');
if (!fs.existsSync(target)) {
importErr++;
console.log(' 缺少: ' + path.relative(ROOT, target));
}
}
}
ok(importErr === 0, 'import 目标文件齐全');
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
// ============================================================
// tools/logic-test.js —— 关键弹道/甩动公式数值验证
// 转写 MatchManager.ts 中的核心公式(与 TS 实现一致),模拟真实物理:
// 1) launchAuto(自动弹道):各距离完美出手必须进球
// 2) resolveFlick(甩动出手):瞄准篮筐的完美甩动必须进球;偏移甩动必须投丢
// 运行:node tools/logic-test.js
// ============================================================
const GRAVITY = 1500;
const COURT = { rimX: 718, rimY: 240, groundY: 400, threeX: 520 };
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
let pass = 0, fail = 0;
function ok(cond, msg) {
if (cond) { pass++; console.log(' ✓ ' + msg); }
else { fail++; console.log(' ✗ ' + msg); }
}
// ---- 球物理模拟:返回是否命中(穿过判定带)----
function simulateAndCheck(bx, by, vx, vy, label) {
let x = bx, y = by, px = bx, py = by;
const r = 11;
for (let i = 0; i < 600; i++) {
px = x; py = y;
vy += GRAVITY / 60;
x += vx / 60;
y += vy / 60;
if (vy > 0 && Math.abs(x - COURT.rimX) < 13 && py < COURT.rimY + 4 && y >= COURT.rimY + 4) {
ok(true, label + ' → 命中');
return true;
}
if (y + r > COURT.groundY) { // 落地未进
ok(false, label + ' → 落地未进 (x=' + x.toFixed(0) + ')');
return false;
}
}
ok(false, label + ' → 超时');
return false;
}
// ---- launchAuto 弹道公式(与 TS 一致)----
function launchAuto(bx, by, nearRim) {
const arcH = nearRim ? 80 : 110;
const ay = COURT.rimY - arcH;
const tUp = Math.sqrt(Math.max(0, 2 * (by - ay) / GRAVITY));
let vy = -GRAVITY * tUp;
const bandY = COURT.rimY + 4;
const tFall = Math.sqrt(Math.max(0, 2 * (bandY - ay) / GRAVITY));
const T = tUp + tFall;
let vx = (COURT.rimX - bx) / T;
// q=1 完美:无抖动
return { vx, vy };
}
// ---- resolveFlick 公式(与 TS 一致)----
function resolveFlick(bx, by, flickAngle, flickPower) {
const bandY = COURT.rimY + 4;
let ix = COURT.rimX - bx;
let iy = COURT.rimY - by;
const ilen = Math.sqrt(ix * ix + iy * iy) || 1;
ix /= ilen; iy /= ilen;
const speed = Math.max(1, flickPower * 950);
const fxv = Math.cos(flickAngle) * speed;
const fyv = -Math.sin(flickAngle) * speed;
const perp = (fxv * iy - fyv * ix) / speed;
const power = clamp(flickPower, 0.25, 1);
let aimOffset = clamp(perp * 120, -110, 110);
const arcH = 120 + power * 60;
const vy = -Math.sqrt(2 * GRAVITY * arcH);
const disc = vy * vy - 2 * GRAVITY * (by - bandY);
const t2 = disc > 0 ? (-vy + Math.sqrt(disc)) / GRAVITY : 0.9;
const q = clamp(1 - Math.abs(aimOffset) / 140, 0, 1);
aimOffset *= (1 - q * 0.30);
const vx = (COURT.rimX + aimOffset - bx) / t2;
return { vx, vy, q };
}
// 理想甩动角(y-up):朝向篮筐
function idealAngle(bx, by) {
return Math.atan2(-(COURT.rimY - by), COURT.rimX - bx);
}
console.log('[1] launchAuto 完美出手(无抖动)各距离命中');
[
{ x: 300, near: false },
{ x: 380, near: false },
{ x: 480, near: false },
{ x: 600, near: false },
{ x: 650, near: true },
{ x: 690, near: true }
].forEach(function (c) {
const bx = c.x + 16, by = 400 - 60;
const v = launchAuto(bx, by, c.near);
simulateAndCheck(bx, by, v.vx, v.vy, 'launchAuto x=' + c.x);
});
console.log('[2] resolveFlick 完美甩动(角度=理想,力度=0.85)各距离命中');
[300, 380, 480, 600, 650].forEach(function (x) {
const bx = x + 16, by = 400 - 60;
const ang = idealAngle(bx, by);
const v = resolveFlick(bx, by, ang, 0.85);
ok(v.q > 0.95, 'x=' + x + ' q=' + v.q.toFixed(2) + '(质量应为完美)');
simulateAndCheck(bx, by, v.vx, v.vy, 'flick 完美 x=' + x);
});
console.log('[3] resolveFlick 偏移甩动(垂直分量大)应投丢');
const bx = 396, by = 340;
const ang0 = idealAngle(bx, by);
const vBad = resolveFlick(bx, by, ang0 + 0.9, 0.85); // 明显偏右
ok(vBad.q < 0.6, '偏移甩动 q 较低 (' + vBad.q.toFixed(2) + ')');
// 模拟:应未命中(投丢)
let sx = bx, sy = by, svx = vBad.vx, svy = vBad.vy;
let scored = false, landed = false, landX = 0;
for (let i = 0; i < 600; i++) {
const py2 = sy;
svy += GRAVITY / 60;
sx += svx / 60;
sy += svy / 60;
if (svy > 0 && Math.abs(sx - COURT.rimX) < 13 && py2 < COURT.rimY + 4 && sy >= COURT.rimY + 4) { scored = true; break; }
if (sy + 11 > COURT.groundY) { landed = true; landX = sx; break; }
}
ok(!scored && landed, '偏移甩动投丢(落点 x=' + landX.toFixed(0) + ',远离篮筐)');
console.log('[4] resolveFlick 抛物线预览与真实弹道一致');
const v = resolveFlick(bx, by, ang0, 0.85);
// 预览计算(computeTrajectory 同款,仅采样)
let px = bx, py = by, pvx = v.vx, pvy = v.vy;
const pts = [];
for (let i = 0; i < 26; i++) {
pvy += GRAVITY / 30;
px += pvx / 30;
py += pvy / 30;
if (py + 11 >= COURT.groundY) break;
if (i % 2 === 0) pts.push({ x: px, y: py });
}
// 真实弹道在判定带的 x 应与预览末段一致(预览只到落地)
ok(pts.length > 0, '预览轨迹点生成 (' + pts.length + ' 个)');
console.log('\n结果: ' + pass + ' 通过, ' + fail + ' 失败');
process.exit(fail > 0 ? 1 : 0);
// ============================================================
// typecheck.js —— 用 Cocos Creator 编辑器自带的 tsc 复现"编辑器同款"严格类型检查
//
// 背景:Cocos Creator 3.8 会忽略项目根目录 tsconfig.json 的 strict 设置,
// 使用其自身生成的 temp/tsconfig.cocos.json(strict: true)。
// 脚本一旦有类型错误,组件类不会注册,场景里引用它的组件就会报
// "Script ... is missing or invalid"。
// 本脚本直接调用编辑器捆绑的 tsc(strict + temp/declarations),
// 在保存代码后本地跑一遍即可提前发现这类问题。
//
// 用法:node tools/typecheck.js (退出码 0 = 通过)
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const project = path.resolve(__dirname, '..');
const scriptsDir = path.join(project, 'assets', 'scripts');
const declDir = path.join(project, 'temp', 'declarations');
// 1) 找编辑器捆绑的 tsc(支持 3.8.x / 3.7.x 等版本目录)
function findTsc() {
const candidates = [];
const roots = [
process.env.ProgramData ? path.join(process.env.ProgramData, 'cocos', 'editors', 'Creator') : null,
process.env['ProgramFiles'] ? path.join(process.env['ProgramFiles'], 'CocosDashboard', 'resources', '.editors') : null,
];
for (const root of roots) {
if (!root || !fs.existsSync(root)) continue;
let versions = [];
try { versions = fs.readdirSync(root); } catch (e) { continue; }
versions = versions.filter((v) => /^3\./.test(v)).sort().reverse();
for (const v of versions) {
candidates.push(path.join(root, v, 'resources', 'app.asar.unpacked', 'node_modules', 'typescript', 'lib', 'tsc.js'));
}
}
return candidates.find((c) => fs.existsSync(c)) || null;
}
const tsc = findTsc();
if (!tsc) {
console.error('[typecheck] 未找到 Cocos Creator 的 tsc.js,请先确认编辑器已安装(ProgramData\\cocos\\editors\\Creator\\3.x)。');
process.exit(2);
}
// 2) 依赖编辑器首次导入生成的 declarations;缺失时提示先打开一次工程
for (const d of ['cc.d.ts', 'cc.env.d.ts', 'cc.custom-macro.d.ts', 'jsb.d.ts']) {
if (!fs.existsSync(path.join(declDir, d))) {
console.error(`[typecheck] 缺少 ${d} —— 请先用 Cocos Creator 打开本工程完成一次导入(生成 temp/declarations),再运行本脚本。`);
process.exit(2);
}
}
// 3) 组装参数:全部脚本 + declarations + 与编辑器一致的严格选项
const files = fs.readdirSync(scriptsDir)
.filter((f) => f.endsWith('.ts'))
.map((f) => path.join(scriptsDir, f));
const decls = ['cc.d.ts', 'cc.env.d.ts', 'cc.custom-macro.d.ts', 'jsb.d.ts'].map((f) => path.join(declDir, f));
const args = [
tsc,
...files,
...decls,
'--strict',
'--noEmit',
'--target', 'ES2015',
'--module', 'ES2015',
'--experimentalDecorators',
'--isolatedModules',
'--moduleResolution', 'node',
'--skipLibCheck',
];
const r = spawnSync(process.execPath, args, { cwd: project, stdio: 'inherit' });
if (r.status === 0) {
console.log(`[typecheck] 通过:${files.length} 个脚本严格类型检查无错误(编辑器同款配置)。`);
} else if (r.status === null) {
console.error('[typecheck] 无法启动 tsc:' + (r.error ? r.error.message : '未知错误'));
process.exit(1);
} else {
console.log('[typecheck] 失败:以上为类型错误,修复后组件才能注册(场景才会正常显示)。');
process.exit(r.status);
}
{
"extends": "./temp/tsconfig.cocos.json",
"compilerOptions": {
"strict": false
}
}
# 挂载说明 · 单挑篮球 v2(Cocos Creator 3.8)
> 本工程只需在场景里挂 **1 个组件:Main**(`assets/scripts/Main.ts`)。
> 其余全部(球场、球员、篮球、菜单、HUD、结算、输入、音效)都在运行时由代码创建,
> **检查器里不需要配置任何属性**,也不需要再挂其它脚本。
---
## 一、准备
1. 用 Cocos Dashboard 打开本工程(`C:\Users\Admin\Desktop\任务平台\dantiao-lanqiu-v2`)。
2. 首次打开等脚本编译完成(控制台不再滚动编译信息)。
3. 若改过脚本而未自动重编译,**重启一次编辑器**
## 二、用自带骨架场景挂载(推荐)
场景 `assets/scenes/main.scene` 已搭好骨架:**Canvas + Main Camera + GameRoot(空节点)**
打开后只需把 **Main** 挂到 GameRoot 即可:
1. 双击打开 `assets/scenes/main.scene`
2. 层级管理器选中 `GameRoot`(Canvas 的子节点,目前是空节点)。
3. 检查器底部 **"添加组件"** → 搜索 **Main**(或把 `assets/scripts/Main.ts` 直接拖到 GameRoot 上)。
4. Ctrl+S 保存(编辑器会自动写入脚本依赖记录)。
5. 点预览运行。
> 备选:直接把 Main 挂到 `Canvas` 节点上也行——脚本会自动创建 GameRoot 子节点。
> 挂好后检查器应显示 Main 组件,预览应看到主菜单。
## 三、自己新建场景挂载(5 步)
1. **新建场景**:Assets 面板右键 → 创建 → 场景,命名 `game.scene`,双击打开(默认自带 Main Camera + Canvas)。
2. 层级管理器:右键 `Canvas`**创建空节点**,命名 `GameRoot`
3. 选中 `GameRoot` → 检查器底部"添加组件" → 搜索 **Main**(或把 `assets/scripts/Main.ts` 拖上去)。
4. Ctrl+S 保存。
5. 预览。
> **备选**:直接把 Main 挂到 `Canvas` 节点上也可以——脚本会自动创建 GameRoot 子节点。
> 挂载后检查器应显示 Main 组件;预览应看到主菜单。
## 四、操作方式
| 操作 | 触屏 | 键盘(桌面预览) |
|---|---|---|
| 移动 | 左半区按下拖出浮动摇杆 | `←`/`→`(P2:`A`/`D`) |
| 假动作 | 投篮键**轻点**(AI 被骗起跳) | — |
| 快捷出手 | 投篮键**长按 ≥0.22s**(近筐上篮/暴扣) | `X`(P2:`S`) |
| 甩动真投 | 右侧区域**拖拽**,看抛物线预览,松手出手 | 鼠标拖拽 |
| 跳跃/封盖 | 跳跃键 | `↑`/`空格`/`W`(P2:`W`) |
| 抢断 | 防守时投篮键自动变[抢断] | `X` |
| 静音 / 确认 | 菜单按钮 | `M` / `回车` |
## 五、改脚本后的自查
Cocos Creator 3.8 编译脚本使用 **strict 模式**(忽略工程根 tsconfig 的 `strict: false`)。
改完代码跑:
```bash
node tools/typecheck.js # 编辑器同款严格检查,0 错误才算过
node tools/check.js # 静态校验
node tools/logic-test.js # 弹道/甩动数值测试(19 项)
```
全过后回编辑器预览;未自动重编译就重启编辑器。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment