feat: add hex trail puzzle, level editor, and visual design guide
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { _decorator, Component, Node, Sprite, SpriteFrame, Texture2D, UITransform, Mask, Graphics, Rect, Size, Vec2 } from 'cc';
|
||||
// Copy src/ui-skin.js beside the shared core; do not re-enter crop values by hand.
|
||||
import { PANELS, ICONS } from '../src/ui-skin.js';
|
||||
const { ccclass, property } = _decorator;
|
||||
type FrameRect = { x:number; y:number; w:number; h:number };
|
||||
/** Apply to any Cocos UI node. Child labels stay native/localizable. */
|
||||
@ccclass('CandyUiSkin')
|
||||
export class CandyUiSkin extends Component {
|
||||
@property(Texture2D) panels: Texture2D | null = null;
|
||||
@property(Texture2D) icons: Texture2D | null = null;
|
||||
@property panel = 'surface';
|
||||
@property icon = '';
|
||||
@property corner = 14;
|
||||
private panelFrame: SpriteFrame | null = null;
|
||||
private iconFrame: SpriteFrame | null = null;
|
||||
private plate: Node | null = null;
|
||||
private fill: Node | null = null;
|
||||
private glyph: Node | null = null;
|
||||
onLoad() {
|
||||
this.refresh();
|
||||
this.node.on(Node.EventType.SIZE_CHANGED, this.refresh, this);
|
||||
this.node.on(Node.EventType.ANCHOR_CHANGED, this.refresh, this);
|
||||
}
|
||||
onDestroy() {
|
||||
this.node.off(Node.EventType.SIZE_CHANGED, this.refresh, this);
|
||||
this.node.off(Node.EventType.ANCHOR_CHANGED, this.refresh, this);
|
||||
for(const n of [this.fill,this.glyph]) {
|
||||
if(n?.isValid){const sp=n.getComponent(Sprite);if(sp)sp.spriteFrame=null;}
|
||||
}
|
||||
this.panelFrame?.destroy();this.iconFrame?.destroy();
|
||||
this.plate?.destroy();this.glyph?.destroy();
|
||||
}
|
||||
private frame(sprite:Sprite, texture:Texture2D, f:FrameRect, current:SpriteFrame | null) {
|
||||
const sf=current || new SpriteFrame(),r=sf.rect;
|
||||
if(sf.texture!==texture || r.x!==f.x || r.y!==f.y || r.width!==f.w || r.height!==f.h) {
|
||||
// Each component owns at most two frames, reused across style changes.
|
||||
sprite.spriteFrame=null;
|
||||
sf.texture=texture;sf.rect=new Rect(f.x,f.y,f.w,f.h);
|
||||
sf.originalSize=new Size(f.w,f.h);sf.offset=new Vec2();sf.packable=false;
|
||||
}
|
||||
sprite.spriteFrame=sf;return sf;
|
||||
}
|
||||
private child(name:string, parent=this.node, sprite=true) {
|
||||
const n=new Node(name);n.layer=this.node.layer;n.parent=parent;
|
||||
n.addComponent(UITransform);
|
||||
if(sprite){const sp=n.addComponent(Sprite);sp.sizeMode=Sprite.SizeMode.CUSTOM;sp.type=Sprite.Type.SIMPLE;}
|
||||
return n;
|
||||
}
|
||||
refresh() {
|
||||
const size=this.getComponent(UITransform);if(!size)return;
|
||||
const width=Math.max(0,size.width),height=Math.max(0,size.height);
|
||||
const x=(.5-size.anchorX)*width,y=(.5-size.anchorY)*height;
|
||||
if(this.panels && PANELS[this.panel]) {
|
||||
if(!this.plate) {
|
||||
this.plate=this.child('Panel clip',this.node,false);
|
||||
this.plate.addComponent(Mask).type=Mask.Type.GRAPHICS_STENCIL;
|
||||
this.fill=this.child('Panel image',this.plate);
|
||||
}
|
||||
this.plate.active=true;this.plate.layer=this.fill!.layer=this.node.layer;
|
||||
this.plate.setSiblingIndex(0);this.plate.setPosition(x,y);
|
||||
this.plate.getComponent(UITransform)!.setContentSize(width,height);
|
||||
this.fill!.getComponent(UITransform)!.setContentSize(width,height);
|
||||
this.panelFrame=this.frame(this.fill!.getComponent(Sprite)!,this.panels,PANELS[this.panel],this.panelFrame);
|
||||
// The 32px PNG contains a baked flat role color. Clip geometry supplies
|
||||
// the corner radius; cap=0 is intentional and never used as a divisor.
|
||||
const g=this.plate.getComponent(Mask)!.subComp as Graphics;
|
||||
g.clear();
|
||||
if(width && height){g.roundRect(-width/2,-height/2,width,height,Math.max(0,Math.min(this.corner,width/2,height/2)));g.fill();}
|
||||
}else if(this.plate)this.plate.active=false;
|
||||
if(this.icons && ICONS[this.icon]) {
|
||||
this.glyph??=this.child('Image icon');const sp=this.glyph.getComponent(Sprite)!;
|
||||
this.glyph.active=true;this.glyph.layer=this.node.layer;
|
||||
const f=ICONS[this.icon];this.iconFrame=this.frame(sp,this.icons,f,this.iconFrame);
|
||||
const scale=Math.min(width/f.w,height/f.h);
|
||||
this.glyph.getComponent(UITransform)!.setContentSize(f.w*scale,f.h*scale);
|
||||
this.glyph.setPosition(x,y);
|
||||
}else if(this.glyph)this.glyph.active=false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { tileSample, roundedHex } from '../src/tile-shape.js';
|
||||
import { _decorator, Component, Graphics, UITransform, Node, EventTouch, Vec3, Color, JsonAsset, Texture2D, Sprite, SpriteFrame, Rect, Mask } from 'cc';
|
||||
import { Game, solveRotations, trace, validateLevel } from '../src/core.js';
|
||||
import { layout, hitCell } from '../src/geometry.js';
|
||||
import { drawScene } from '../src/renderer.js';
|
||||
import { TILE_FRAMES, TILE_ASPECT } from '../src/sprites.js';
|
||||
import { LEVELS } from '../src/levels.js';
|
||||
const { ccclass, property, requireComponent } = _decorator;
|
||||
type Point = { x: number; y: number };
|
||||
type PooledSprite = { node: Node; sprite: Sprite; transform: UITransform; mask: Mask };
|
||||
|
||||
// The static tracks use Graphics; the exact generated textures use SpriteFrames.
|
||||
// Arrows use a separate foreground Graphics so they stay above the sprite atlas.
|
||||
class CocosPainter {
|
||||
private cursor = 0;
|
||||
private overlay = false;
|
||||
private pool: PooledSprite[] = [];
|
||||
private frames: SpriteFrame[] = [];
|
||||
constructor(private root: Node, private board: Graphics, private arrows: Graphics, private w: number, private h: number, texture: Texture2D | null) {
|
||||
if (texture) this.frames = TILE_FRAMES.map(f => { const frame = new SpriteFrame(); frame.texture = texture; const sample=tileSample(f); frame.rect = new Rect(sample.x, sample.y, sample.w, sample.h); return frame; });
|
||||
}
|
||||
begin(w: number, h: number) {
|
||||
this.w = w; this.h = h; this.cursor = 0; this.overlay = false;
|
||||
this.board.clear(); this.arrows.clear(); this.pool.forEach(p => p.node.active = false);
|
||||
}
|
||||
dispose() { this.frames.forEach(f => f.destroy()); }
|
||||
private x(x: number) { return x - this.w / 2; }
|
||||
private y(y: number) { return this.h / 2 - y; }
|
||||
private color(hex: string) {
|
||||
const rgb = parseInt(hex.slice(1,7),16), alpha = hex.length === 9 ? parseInt(hex.slice(7),16) : 255;
|
||||
return new Color((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, alpha);
|
||||
}
|
||||
sprite(index: number, x: number, y: number, radius: number) {
|
||||
if (!this.frames.length) return false;
|
||||
if (!this.pool[this.cursor]) {
|
||||
const node = new Node('Tile'); node.layer = this.root.layer; node.parent = this.root;
|
||||
node.addComponent(UITransform);const mask=node.addComponent(Mask);mask.type=Mask.Type.GRAPHICS_STENCIL;
|
||||
const face=new Node('Matte texture');face.layer=node.layer;face.parent=node;
|
||||
const transform=face.addComponent(UITransform);const sprite=face.addComponent(Sprite);
|
||||
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
|
||||
this.pool.push({ node, transform, sprite, mask });
|
||||
}
|
||||
const p = this.pool[this.cursor++], f = TILE_FRAMES[index], w = radius * 2, h = w * TILE_ASPECT;
|
||||
p.node.active = true; p.node.setSiblingIndex(this.cursor - 1);
|
||||
p.node.getComponent(UITransform)!.setContentSize(w,h);
|
||||
const stencil=p.mask.subComp as Graphics;stencil.clear();const shape=roundedHex(0,0,radius);
|
||||
shape.forEach((v:Point,i:number)=>i?stencil.lineTo(v.x,v.y):stencil.moveTo(v.x,v.y));stencil.close();stencil.fill();
|
||||
p.sprite.spriteFrame = this.frames[index];
|
||||
p.transform.setAnchorPoint(f.anchorX, 1 - f.anchorY); p.transform.setContentSize(w, h);
|
||||
p.node.setPosition(this.x(x), this.y(y), 0);
|
||||
this.arrows.node.setSiblingIndex(this.root.children.length - 1);
|
||||
this.overlay = true; return true;
|
||||
}
|
||||
polygon(points: Point[], fill: string) {
|
||||
const g = this.overlay ? this.arrows : this.board;
|
||||
points.forEach((p,i) => i ? g.lineTo(this.x(p.x),this.y(p.y)) : g.moveTo(this.x(p.x),this.y(p.y)));
|
||||
g.close(); g.fillColor = this.color(fill); g.fill();
|
||||
}
|
||||
line(points: Point[], color: string, width: number) {
|
||||
if (!points.length) return;
|
||||
const g = this.overlay ? this.arrows : this.board;
|
||||
points.forEach((p,i) => i ? g.lineTo(this.x(p.x),this.y(p.y)) : g.moveTo(this.x(p.x),this.y(p.y)));
|
||||
g.lineWidth = width; g.strokeColor = this.color(color); g.stroke();
|
||||
}
|
||||
circle(x: number, y: number, r: number, fill: string) {
|
||||
this.board.circle(this.x(x),this.y(y),r); this.board.fillColor = this.color(fill); this.board.fill();
|
||||
}
|
||||
}
|
||||
|
||||
@ccclass('HexTrailsBoard')
|
||||
@requireComponent(UITransform)
|
||||
@requireComponent(Graphics)
|
||||
export class HexTrailsBoard extends Component {
|
||||
@property(JsonAsset) levelAsset: JsonAsset | null = null;
|
||||
@property(Texture2D) tileAtlas: Texture2D | null = null;
|
||||
@property levelIndex = 0;
|
||||
private game: any;
|
||||
private viewport: any;
|
||||
private elapsed = 0;
|
||||
private moving: any = null;
|
||||
private rotating: any = null;
|
||||
private path: any = null;
|
||||
private hintId: string | null = null;
|
||||
private painter!: CocosPainter;
|
||||
|
||||
onLoad() {
|
||||
const ui = this.getComponent(UITransform)!;
|
||||
ui.setAnchorPoint(0.5,0.5);
|
||||
const graphics = this.getComponent(Graphics)!;
|
||||
graphics.lineCap = Graphics.LineCap.ROUND; graphics.lineJoin = Graphics.LineJoin.ROUND;
|
||||
const arrowNode = new Node('ArrowOverlay'); arrowNode.layer = this.node.layer; arrowNode.parent = this.node;
|
||||
arrowNode.addComponent(UITransform);
|
||||
const arrowGraphics = arrowNode.addComponent(Graphics);
|
||||
this.painter = new CocosPainter(this.node,graphics,arrowGraphics,ui.width,ui.height,this.tileAtlas);
|
||||
this.node.on(Node.EventType.TOUCH_END,this.onTouch,this);
|
||||
this.node.on(Node.EventType.SIZE_CHANGED,this.redraw,this);
|
||||
this.loadLevel(this.levelAsset?.json ?? LEVELS[Math.max(0,Math.min(23,this.levelIndex))]);
|
||||
}
|
||||
onDestroy() {
|
||||
this.node.off(Node.EventType.TOUCH_END,this.onTouch,this);
|
||||
this.node.off(Node.EventType.SIZE_CHANGED,this.redraw,this);
|
||||
this.painter?.dispose();
|
||||
}
|
||||
loadLevel(level: any) {
|
||||
const errors = validateLevel(level); if (errors.length) throw new Error(errors.join('; '));
|
||||
this.game = new Game(level); this.moving = null; this.rotating = null; this.path = null; this.hintId = null;
|
||||
this.redraw(); this.emitState();
|
||||
}
|
||||
private onTouch(event: EventTouch) {
|
||||
if (!this.game || this.moving || this.rotating) return;
|
||||
const ui = this.getComponent(UITransform)!, loc = event.getUILocation();
|
||||
const p = ui.convertToNodeSpaceAR(new Vec3(loc.x,loc.y,0));
|
||||
const cell = hitCell(this.game.level.cells,this.viewport,p.x+ui.width/2,ui.height/2-p.y);
|
||||
if (!cell) return;
|
||||
const tile = this.game.tiles.find((t:any) => t.q===cell.q && t.r===cell.r); if (!tile) return;
|
||||
const route = this.game.tap(tile.id); this.hintId = null; this.path = null; this.elapsed = 0;
|
||||
if (route.status === 'exit') this.moving = { route,progress:0,duration:Math.min(1.7,0.55+route.steps.length*0.085) };
|
||||
else if (route.status === 'blocked' || route.status === 'loop') {
|
||||
this.path = route;
|
||||
if (route.travelPoints) this.moving = { route,progress:0,duration:1.25 };
|
||||
this.node.emit('trail-blocked',route);
|
||||
}
|
||||
this.redraw(); this.emitState();
|
||||
}
|
||||
rotateLeft() { this.rotate(-1); }
|
||||
rotateRight() { this.rotate(1); }
|
||||
private rotate(direction: number) {
|
||||
if (this.moving || this.rotating) return;
|
||||
if (this.game.rotate(direction).status !== 'rotated') return;
|
||||
this.path = null; this.hintId = null; this.elapsed = 0;
|
||||
this.rotating = { direction,angle:-direction*Math.PI/3 };
|
||||
this.redraw(); this.emitState();
|
||||
}
|
||||
update(dt:number) {
|
||||
if (!this.moving && !this.rotating) return;
|
||||
this.elapsed += dt;
|
||||
if (this.moving) { this.moving.progress = Math.min(1,this.elapsed/this.moving.duration); if (this.moving.progress >= 1) this.moving = null; }
|
||||
if (this.rotating) { const t = Math.min(1,this.elapsed/0.42); this.rotating.angle = -this.rotating.direction*Math.PI/3*Math.pow(1-t,3); if (t>=1) this.rotating = null; }
|
||||
this.redraw(); if (!this.moving && !this.rotating) this.emitState();
|
||||
}
|
||||
undo() {
|
||||
if (this.moving || this.rotating) return;
|
||||
this.game.undo(); this.path = null; this.hintId = null; this.redraw(); this.emitState();
|
||||
}
|
||||
restart() { this.loadLevel(this.game.initialLevel); }
|
||||
hint() {
|
||||
if (this.moving || this.rotating) return;
|
||||
const solution = solveRotations({ ...this.game.level,tiles:this.game.tiles,moveLimit:this.game.moves });
|
||||
const action = solution.actions[0];
|
||||
if (!solution.solvable || !action) { this.node.emit('trail-no-hint',solution); return; }
|
||||
if (action.type === 'rotate') this.node.emit('trail-hint-rotation',action.direction);
|
||||
else { const t=this.game.tiles.find((p:any)=>p.id===action.id); this.path=trace(this.game.level,t,this.game.tiles); this.hintId=t.id; this.redraw(); }
|
||||
}
|
||||
private emitState() {
|
||||
const busy=!!this.moving || !!this.rotating;
|
||||
this.node.emit('trail-state',{moves:this.game.moves,remaining:this.game.tiles.length,orientation:this.game.orientation*60,won:this.game.won&&!busy,lost:this.game.lost,busy});
|
||||
}
|
||||
private redraw() {
|
||||
if (!this.game) return;
|
||||
const ui=this.getComponent(UITransform)!;
|
||||
this.painter.begin(ui.width,ui.height);
|
||||
this.viewport=layout(this.game.level.cells,ui.width,ui.height);
|
||||
drawScene(this.painter,this.game.level,this.game.tiles,this.viewport,{path:this.path,hint:this.hintId,animation:this.moving,boardAngle:this.rotating?.angle||0});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user