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}); } }