初始提交:三玩法 Web 原型与设计文档

- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
cabinw
2026-09-16 14:30:59 +08:00
commit 8026e4628d
169 changed files with 22162 additions and 0 deletions
@@ -0,0 +1,151 @@
/** Creator 3.8 适配组件;通过 npm run cocos:export 导出后放入 assets/modes/02-connect-1234。 */
import { _decorator, Component, EventTouch, Graphics, Label, Node, UITransform, Vec3, Color,
HorizontalTextAlignment, VerticalTextAlignment } from 'cc';
import { FlowGame, type FlowResult } from '../src/flow';
import { canFlowAppend } from '../src/flow-rules';
import { Gesture } from '../src/input';
import { WIDTH, HEIGHT } from '../src/config';
import { drawBoard, Draw } from '../src/view';
import { FlowAnimation } from '../src/flow-animation';
import type { Point } from '../src/types';
const { ccclass, property, requireComponent } = _decorator;
@ccclass('Connect1234Board')
@requireComponent(UITransform)
export class Connect1234Board extends Component {
@property({ tooltip: "开局种子;相同种子和操作得到相同盘面" }) seed = 1234;
private game!: FlowGame;
private gesture!: Gesture;
private graphics!: Graphics;
private surface!: Node;
private labels: Label[] = [];
private touchId: number | null = null;
private time = 0;
private hints: number[] = [];
private animation: FlowAnimation | null = null;
private seenReaction = 0;
onLoad(): void {
this.surface = new Node('BalloonDrawing'); this.surface.layer = this.node.layer; this.node.addChild(this.surface);
this.surface.addComponent(UITransform).setContentSize(WIDTH, HEIGHT);
this.graphics = this.surface.addComponent(Graphics);
this.startNew(this.seed);
}
onEnable(): void {
this.node.on(Node.EventType.TOUCH_START, this.onStart, this);
this.node.on(Node.EventType.TOUCH_MOVE, this.onMove, this);
this.node.on(Node.EventType.TOUCH_END, this.onEnd, this);
this.node.on(Node.EventType.TOUCH_CANCEL, this.cancel, this);
}
onDisable(): void { this.node.targetOff(this); this.cancel(); }
onDestroy(): void { this.surface?.destroy(); }
startNew(seed = Date.now() >>> 0): void {
this.seed = seed; this.game = new FlowGame(seed);
this.gesture = new Gesture(() => this.game.balls, canFlowAppend);
this.animation = null; this.touchId = null; this.hints = this.game.hint(); this.emitHUD();
}
retry(): void { this.startNew(this.seed); }
undo(): void { if (!this.animation && this.game.undo()) { this.cancel(); this.hints = []; this.emitHUD(); } }
showHint(): void { if (!this.animation) { this.cancel(); this.hints = this.game.hint(); } }
shuffle(): void { if (!this.animation) { this.cancel(); this.begin(this.game.shuffle()); } }
snapshot() { return this.game.snapshot(); }
private emitHUD(): void {
this.node.emit('balloon-hud', { snapshot: this.game.snapshot(), canUndo: this.game.canUndo,
busy: !!this.animation, fever: this.game.fever });
}
private begin(result: FlowResult): void {
this.hints = []; this.seenReaction = 0; this.animation = new FlowAnimation(result, this.time);
this.node.emit('balloon-merge', result); this.emitHUD();
}
private toPoint(e: EventTouch): Point {
const p = e.getUILocation();
const local = this.surface.getComponent(UITransform)!.convertToNodeSpaceAR(new Vec3(p.x, p.y, 0));
return { x: local.x + WIDTH / 2, y: HEIGHT / 2 - local.y };
}
private onStart(e: EventTouch): void {
if (this.touchId !== null || this.animation) return;
this.touchId = e.getID(); this.hints = []; this.gesture.down(this.toPoint(e));
}
private onMove(e: EventTouch): void { if (e.getID() === this.touchId) this.gesture.move(this.toPoint(e)); }
private onEnd(e: EventTouch): void {
if (e.getID() !== this.touchId) return;
this.gesture.move(this.toPoint(e)); const path = this.gesture.up(); this.touchId = null;
const merged = this.game.commit(path);
if (merged) this.begin(merged);
}
private cancel(e?: EventTouch): void {
if (e && e.getID() !== this.touchId) return;
this.touchId = null; this.gesture?.cancel();
}
update(dt: number): void {
this.time += dt * 1000;
const ui = this.node.getComponent(UITransform)!;
const scale = Math.min(ui.width / WIDTH, ui.height / HEIGHT);
this.surface.setScale(scale, scale, 1);
// node 可以使用任意 anchor,绘图始终居中于 UITransform 内容区域。
this.surface.setPosition((.5 - ui.anchorX) * ui.width, (.5 - ui.anchorY) * ui.height, 0);
let balls = this.game.balls;
if (this.animation) {
const sample = this.animation.sample(this.time);
if (sample) {
balls = sample;
const current = this.animation.reaction;
if (current && current.index > this.seenReaction) {
for (const stage of this.animation.result.stages.filter(s => s.index > this.seenReaction && s.index <= current.index))
this.node.emit('balloon-reaction', stage, this.animation.result.doubled);
this.seenReaction = current.index;
}
} else {
const result = this.animation.result; this.animation = null; this.emitHUD();
this.node.emit('balloon-settled', result, this.game.snapshot());
}
}
this.render(drawBoard({ balls, chain: this.animation ? this.animation.activePath : this.gesture.chain,
hint: this.animation ? [] : this.hints, time: this.time / 1000, fever: this.game.fever }));
}
private color(hex: string, alpha = 1): Color {
const value = parseInt(hex.slice(1), 16);
return new Color((value >> 16) & 255, (value >> 8) & 255, value & 255, Math.round(alpha * 255));
}
private render(commands: Draw[]): void {
const g = this.graphics; g.clear(); let labelIndex = 0;
for (const d of commands) {
if (d.type === 'text') {
let label = this.labels[labelIndex++];
if (!label) {
const node = new Node('BalloonLabel'); node.layer = this.node.layer; this.surface.addChild(node);
node.addComponent(UITransform).setContentSize(340, 60);
label = node.addComponent(Label); label.horizontalAlign = HorizontalTextAlignment.CENTER;
label.verticalAlign = VerticalTextAlignment.CENTER; label.fontFamily = 'Arial';
this.labels.push(label);
}
label.node.active = true; label.node.setPosition(d.x - WIDTH / 2, HEIGHT / 2 - d.y, 0);
label.string = d.text; label.fontSize = d.size; label.lineHeight = d.size * 1.3;
label.isBold = (d.weight || 600) >= 650; label.color = this.color(d.fill || '#40678e', d.alpha);
label.enableOutline = !!d.stroke; label.outlineWidth = d.width || 1;
label.outlineColor = this.color(d.stroke || '#ffffff', d.alpha);
continue;
}
g.fillColor = this.color(d.fill || '#ffffff', d.alpha);
g.strokeColor = this.color(d.stroke || '#ffffff', d.alpha); g.lineWidth = d.width || 1;
if (d.type === 'circle') g.circle(d.x - WIDTH / 2, HEIGHT / 2 - d.y, d.r);
else if (d.type === 'ellipse') g.ellipse(d.x - WIDTH / 2, HEIGHT / 2 - d.y, d.rx, d.ry);
else if (d.dash) {
// Graphics 没有 Canvas 的 setLineDash;逐段画虚线。
for (let i = 1; i < d.points.length; i++) {
const a = d.points[i - 1], b = d.points[i], length = Math.hypot(b.x - a.x, b.y - a.y);
for (let t = 0; t < length; t += d.dash[0] + d.dash[1]) {
const end = Math.min(length, t + d.dash[0]);
g.moveTo(a.x + (b.x - a.x) * t / length - WIDTH / 2, HEIGHT / 2 - a.y - (b.y - a.y) * t / length);
g.lineTo(a.x + (b.x - a.x) * end / length - WIDTH / 2, HEIGHT / 2 - a.y - (b.y - a.y) * end / length);
}
}
} else {
d.points.forEach((p, i) => i ? g.lineTo(p.x - WIDTH / 2, HEIGHT / 2 - p.y) : g.moveTo(p.x - WIDTH / 2, HEIGHT / 2 - p.y));
if (d.close) g.close();
}
if (d.fill) g.fill(); if (d.stroke) g.stroke();
}
for (let i = labelIndex; i < this.labels.length; i++) this.labels[i].node.active = false;
}
}