diff --git a/artwork/builder.js b/artwork/builder.js index 86397e1..f6c7299 100644 --- a/artwork/builder.js +++ b/artwork/builder.js @@ -26,13 +26,13 @@ // Hex piece definitions (axial coords) — multi-cell pieces, not just singles const HEX_PIECES = { hT: [[-1,0],[0,0],[1,0],[0,-1]], // T-shape - hS: [[-1,0],[0,0],[0,-1],[1,-1]], // S-shape + hO: [[-1,0],[0,0],[0,-1],[1,-1]], // O-shape (compact zigzag) hL: [[-1,0],[0,0],[1,0],[1,-1]], // L-shape - hI: [[-1,0],[0,0],[1,0],[2,0]], // I4-shape - hF: [[-2,1],[-1,1],[0,0],[1,0]], // F-shape + hI: [[-1,0],[0,0],[1,0],[2,0]], // I-shape (4 in a line) + hS: [[-2,1],[-1,1],[0,0],[1,0]], // S-shape (wider zigzag) }; const HEX_PIECE_COLORS = { - hT: '#FFD700', hS: '#00CED1', hL: '#EE4444', hI: '#FF1493', hF: '#7FFF00', + hI: '#EE4444', hT: '#00CED1', hL: '#FFD700', hO: '#7FFF00', hS: '#9B59F0', }; const HEX_PIECE_TYPES = Object.keys(HEX_PIECES); const HEX_SOLO_COLORS = ['#00CED1','#FFD700','#9B59F0','#FF1493']; diff --git a/artwork/generate.js b/artwork/generate.js index 0d10cc9..0101d7d 100644 --- a/artwork/generate.js +++ b/artwork/generate.js @@ -180,13 +180,13 @@ function hexBannerGrid4() { const HEX_BANNER_GRIDS = [hexBannerGrid1, hexBannerGrid2, hexBannerGrid3, hexBannerGrid4]; const HEX_BANNER_LEVELS = [13, 8, 6, 4]; const HEX_BANNER_LINES = [115, 60, 35, 18]; -const HEX_BANNER_PIECE_TYPES = ['T', 'I4', 'S', 'F']; -const HEX_BANNER_HOLD = ['Fm', 'L', 'Tp', 'S']; +const HEX_BANNER_PIECE_TYPES = ['J', 'I', 'O', 'S']; +const HEX_BANNER_HOLD = ['Z', 'L', 'T', 'O']; const HEX_BANNER_NEXT = [ - ['L', 'Fm', 'I4', 'Tp', 'S'], - ['T', 'F', 'S', 'L', 'Fm'], - ['I4', 'Tp', 'F', 'T', 'L'], - ['Fm', 'T', 'L', 'I4', 'Tp'], + ['L', 'Z', 'I', 'T', 'O'], + ['J', 'S', 'O', 'L', 'Z'], + ['I', 'T', 'S', 'J', 'L'], + ['Z', 'J', 'L', 'I', 'T'], ]; function buildHexBannerGameState() { diff --git a/playwright.config.js b/playwright.config.js index 829bfe1..a0f5bbe 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -34,6 +34,12 @@ module.exports = defineConfig({ testMatch: 'hex-display.spec.js', use: { viewport: { width: 1920, height: 1080 } }, }, + { + name: 'style-comparison', + testDir: './tests/visual', + testMatch: 'style-comparison.spec.js', + use: { viewport: { width: 1920, height: 1080 } }, + }, { name: 'controller', testDir: './tests/visual', diff --git a/public/display/BaseUIRenderer.js b/public/display/BaseUIRenderer.js index 0fbdee2..f37b203 100644 --- a/public/display/BaseUIRenderer.js +++ b/public/display/BaseUIRenderer.js @@ -100,11 +100,14 @@ class BaseUIRenderer { } _nextPanelLayout(playerState) { + var nextCount = playerState.nextPieces ? Math.min(playerState.nextPieces.length, 3) : 0; + if (this._cachedNextLayout && this._cachedNextCount === nextCount) return this._cachedNextLayout; var pieceSpacing = this.miniSize * 3; var startY = this.boardY + this._labelSize + this.cellSize * 0.2; - var nextCount = playerState.nextPieces ? Math.min(playerState.nextPieces.length, 3) : 0; var boxHeight = pieceSpacing * Math.max(nextCount, 3); - return { startY: startY, boxHeight: boxHeight, pieceSpacing: pieceSpacing }; + this._cachedNextCount = nextCount; + this._cachedNextLayout = { startY: startY, boxHeight: boxHeight, pieceSpacing: pieceSpacing }; + return this._cachedNextLayout; } drawNextPanel(playerState, layout) { diff --git a/public/display/BoardRenderer.js b/public/display/BoardRenderer.js index e6b1e21..e37123d 100644 --- a/public/display/BoardRenderer.js +++ b/public/display/BoardRenderer.js @@ -208,6 +208,7 @@ class BoardRenderer { // Used by DisplayRender __TEST__._extraGhosts path; main render uses batched compound path above. drawGhostBlock(col, row, color) { + if (row < 0 || row >= VISIBLE_ROWS || col < 0 || col >= COLS) return; const ctx = this.ctx; const x = this.x + col * this.cellSize; const y = this.y + row * this.cellSize; diff --git a/public/display/DisplayRender.js b/public/display/DisplayRender.js index 0e3c3ff..e1832bd 100644 --- a/public/display/DisplayRender.js +++ b/public/display/DisplayRender.js @@ -7,15 +7,18 @@ var lastThrottled = null; var lastMusicLevel = 0; +var _NO_SHAKE = Object.freeze({ x: 0, y: 0 }); // Returns all effects if any is still active; otherwise clears the map entry and returns []. +var _EMPTY_EFFECTS = Object.freeze([]); function getOrClearEffects(effectsMap, playerId, timestamp) { - var effects = effectsMap.get(playerId) || []; + var effects = effectsMap.get(playerId); + if (!effects) return _EMPTY_EFFECTS; for (var i = 0; i < effects.length; i++) { if (timestamp - effects[i].startTime < effects[i].duration) return effects; } effectsMap.delete(playerId); - return []; + return _EMPTY_EFFECTS; } function startRenderLoop() { @@ -115,7 +118,8 @@ function renderFrame(timestamp) { id: playerOrder[i], alive: true, lines: 0, level: pInfo?.startLevel || 1, - garbageIndicatorEffects: [], + garbageIndicatorEffects: _EMPTY_EFFECTS, + garbageDefenceEffects: _EMPTY_EFFECTS, playerName: pInfo?.playerName || PLAYER_NAMES[i], playerColor: pInfo?.playerColor || PLAYER_COLORS[i] }; @@ -132,7 +136,7 @@ function renderFrame(timestamp) { var shake = animations ? animations.getShakeOffsetForBoard(boardRenderers[j].x, boardRenderers[j].y) - : { x: 0, y: 0 }; + : _NO_SHAKE; if (shake.x !== 0 || shake.y !== 0) { ctx.save(); @@ -160,12 +164,12 @@ function renderFrame(timestamp) { for (var eg = 0; eg < extras.length; eg++) { var ghost = extras[eg]; var gc = ghostColorSet[ghost.typeId] || { outline: 'rgba(255,255,255,0.12)', fill: 'rgba(255,255,255,0.06)' }; - for (var bl = 0; bl < ghost.blocks.length; bl++) { - var gbx = ghost.blocks[bl][0]; - var gby = ghost.blocks[bl][1]; - var drawRow = ghost.ghostY + gby; - var drawCol = ghost.x + gbx; - if (drawRow >= 0 && drawRow < GameConstants.VISIBLE_HEIGHT && drawCol >= 0 && drawCol < GameConstants.BOARD_WIDTH) { + if (ghost.blocks) { + for (var bl = 0; bl < ghost.blocks.length; bl++) { + var gbx = ghost.blocks[bl][0]; + var gby = ghost.blocks[bl][1]; + var drawCol = ghost.x + gbx; + var drawRow = ghost.ghostY + gby; br.drawGhostBlock(drawCol, drawRow, gc); } } diff --git a/public/display/HexBoardRenderer.js b/public/display/HexBoardRenderer.js index 806753d..af945f0 100644 --- a/public/display/HexBoardRenderer.js +++ b/public/display/HexBoardRenderer.js @@ -8,6 +8,7 @@ var HEX_VIS_ROWS = HexConstants.HEX_VISIBLE_ROWS; var HEX_COLS_N = HexConstants.HEX_COLS; var _hexScratch = { x: 0, y: 0 }; var _hexLocalScratch = { x: 0, y: 0 }; +var _GHOST_KEY_STRIDE = 32; // must exceed max(HEX_VIS_ROWS, HEX_COLS) for collision-free keys class HexBoardRenderer { @@ -27,6 +28,10 @@ class HexBoardRenderer { this.colW = geo.colW; this.boardWidth = geo.boardWidth; this.boardHeight = geo.boardHeight; + // Pre-compute cell size with apothem-based gap (stable post-construction) + this._sCell = this.hexSize - cellSize * THEME.size.blockGap * 2 / _SQRT3; + this._stampHeight = _SQRT3 * this._sCell; + this._gridLineWidth = this._stampHeight * THEME.stroke.grid; this._prevGhostCol = -1; this._prevGhostRow = -1; this._prevGhostType = -1; @@ -39,9 +44,12 @@ class HexBoardRenderer { this._cachedGridVersion = -1; this._cachedGridTier = null; - // Pre-compute hex outline vertices (only changes on layout recalculation) + // Pre-compute hex outline vertices, expanded outward so border is fully + // outside the cell area with gap matching inter-cell spacing + // Outset = half border width so the border inner edge aligns with hexSize + var borderHalf = cellSize * THEME.stroke.border / 2; this._outlineVerts = HexConstants.computeHexOutlineVerts( - this.x, this.y, this.hexSize, this.hexH, this.colW, HEX_COLS_N, HEX_VIS_ROWS + this.x, this.y, this.hexSize, this.hexH, this.colW, HEX_COLS_N, HEX_VIS_ROWS, borderHalf ); // Cached rgba strings (stable between layout recalculations) @@ -70,13 +78,9 @@ class HexBoardRenderer { return _hexLocalScratch; } - _hexPath(cx, cy, size) { - hexPath(this.ctx, cx, cy, size); - } - _drawHex(cx, cy, size, fill, stroke, alpha) { var ctx = this.ctx; - this._hexPath(cx, cy, size); + hexPath(ctx, cx, cy, size); if (fill) { if (alpha != null) ctx.globalAlpha = alpha; ctx.fillStyle = fill; @@ -85,14 +89,21 @@ class HexBoardRenderer { } if (stroke) { ctx.strokeStyle = stroke; - ctx.lineWidth = 1.5; + ctx.lineWidth = this._gridLineWidth; ctx.stroke(); } } + // Draw a ghost block at grid (col, row) — used by test harness for extra ghosts + drawGhostBlock(col, row, gc) { + if (row < 0 || row >= HEX_VIS_ROWS || col < 0 || col >= HEX_COLS_N) return; + var pos = this._hexCenter(col, row); + this._drawHex(pos.x, pos.y, this._sCell, gc.fill, gc.outline); + } + _drawFilledHex(cx, cy, size, color) { - var stamp = getHexStamp(this._styleTier, color, size); - this.ctx.drawImage(stamp, cx - size - 1, cy - stamp.cssH / 2, stamp.cssW, stamp.cssH); + var stamp = getHexStamp(this._styleTier, color, this._stampHeight); + this.ctx.drawImage(stamp, cx - stamp.cssW / 2, cy - stamp.cssH / 2, stamp.cssW, stamp.cssH); } render(playerState, timestamp) { @@ -104,7 +115,7 @@ class HexBoardRenderer { var colors = isNeon ? NEON_PIECE_COLORS : PIECE_COLORS; var ghostColors = isNeon ? NEON_GHOST_COLORS : GHOST_COLORS; - var sCell = hs * (1 - THEME.size.blockGap * 2); + var sCell = this._sCell; // Grid cells — cached to offscreen canvas, redrawn only when gridVersion changes if (playerState.grid) { @@ -129,7 +140,7 @@ class HexBoardRenderer { var gb = ghost.blocks[gi]; if (gb[1] >= 0 && gb[1] < HEX_VIS_ROWS) { var gp = this._hexCenter(gb[0], gb[1]); - this._drawHex(gp.x, gp.y, sCell, gc.fill, gc.outline, 0.4); + this._drawHex(gp.x, gp.y, sCell, gc.fill, gc.outline); } } } @@ -153,14 +164,14 @@ class HexBoardRenderer { this._prevGhostGV = gkVersion; var ghostSet = {}; for (var gi2 = 0; gi2 < ghostBlocks.length; gi2++) { - ghostSet[ghostBlocks[gi2][0] + ',' + ghostBlocks[gi2][1]] = true; + ghostSet[ghostBlocks[gi2][0] * _GHOST_KEY_STRIDE + ghostBlocks[gi2][1]] = true; } var gridRows = playerState.grid.length; var grid = playerState.grid; var result = HexConstants.findClearableZigzags( HEX_COLS_N, gridRows, - function(col, row) { return grid[row][col] > 0 || ghostSet[col + ',' + row]; }, - function(col, row) { return grid[row][col] === 0 && ghostSet[col + ',' + row]; } + function(col, row) { return grid[row][col] > 0 || ghostSet[col * _GHOST_KEY_STRIDE + row]; }, + function(col, row) { return grid[row][col] === 0 && ghostSet[col * _GHOST_KEY_STRIDE + row]; } ); // findClearableZigzags returns clearCells as [[col, row], ...] this._cachedPreviewCells = result.clearCells; @@ -178,7 +189,7 @@ class HexBoardRenderer { } else { this._prevGhostCol = -1; this._prevGhostRow = -1; this._prevGhostType = -1; this._prevGhostGV = -1; - this._cachedPreviewCells = []; + this._cachedPreviewCells.length = 0; } // Current piece @@ -239,8 +250,8 @@ class HexBoardRenderer { for (var c = 0; c < row.length; c++) { var pos = this._hexCenterLocal(c, r); if (row[c] > 0) { - var stamp = getHexStamp(this._styleTier, colors[row[c]], sCell); - gc.drawImage(stamp, pos.x - sCell - 1, pos.y - stamp.cssH / 2, stamp.cssW, stamp.cssH); + var stamp = getHexStamp(this._styleTier, colors[row[c]], this._stampHeight); + gc.drawImage(stamp, pos.x - stamp.cssW / 2, pos.y - stamp.cssH / 2, stamp.cssW, stamp.cssH); } else { hexPath(gc, pos.x, pos.y, sCell); gc.fillStyle = THEME.color.bg.board; @@ -268,7 +279,7 @@ class HexBoardRenderer { } } gc.strokeStyle = this._gridStroke; - gc.lineWidth = 1.5; + gc.lineWidth = this._gridLineWidth; gc.stroke(); } diff --git a/public/display/HexUIRenderer.js b/public/display/HexUIRenderer.js index f5f22e0..9d97f08 100644 --- a/public/display/HexUIRenderer.js +++ b/public/display/HexUIRenderer.js @@ -6,6 +6,8 @@ var HEX_MINI_PIECES = HexPieceModule.HEX_PIECES; var HEX_TYPE_TO_ID = HexConstants.HEX_PIECE_TYPE_TO_ID; +var _getIndicatorColor = function(e) { return e.color; }; +var _getDefenceColor = function() { return THEME.color.text.white; }; // Compute bounding boxes for flat-top hex mini pieces using odd-q offset conversion. var HEX_MINI_BOUNDS = {}; @@ -47,75 +49,82 @@ class HexUIRenderer extends BaseUIRenderer { this._hexSize = geo.hexSize; this._hexH = geo.hexH; this._colW = geo.colW; + // Pre-compute stable meter/cell values + this._sCell = geo.hexSize - cellSize * THEME.size.blockGap * 2 / _SQRT3; + this._gridLineWidth = _SQRT3 * this._sCell * THEME.stroke.grid; + this._meterX = boardX - cellSize * 1.07; } drawGarbageMeter(pendingGarbage) { - var hs = this._hexSize; - var sCell = hs * (1 - THEME.size.blockGap * 2); - var maxLines = HexConstants.HEX_VISIBLE_ROWS; - var lines = Math.min(pendingGarbage, maxLines); - - for (var row = 0; row < lines; row++) { - for (var cell = 0; cell < 2; cell++) { - var pos = this._getMeterPos(row, cell); - this._drawMeterHex(pos.x, pos.y, sCell, '#808080', null); - } - } - } + var sCell = this._sCell; + var lines = Math.min(pendingGarbage, HexConstants.HEX_VISIBLE_ROWS); + if (lines === 0) return; - // Helper: draw a hex at a meter cell position - _drawMeterHex(cx, cy, sCell, fill, alpha) { var ctx = this.ctx; - if (alpha != null) ctx.globalAlpha = alpha; - hexPath(ctx, cx, cy, sCell); - ctx.fillStyle = fill; - ctx.fill(); - if (alpha != null) ctx.globalAlpha = 1.0; - } - - // Get meter hex center for a given garbage row index and cell index (0 or 1) - _getMeterPos(garbageRow, cell) { - var hs = this._hexSize; + var mx = this._meterX; var hexH = this._hexH; - var colW = this._colW; - var evenX = this.boardX - colW - hs * 0.4; - var oddX = evenX + colW; - var row = HexConstants.HEX_VISIBLE_ROWS - 1 - garbageRow; - return { - x: cell ? oddX : evenX, - y: this.boardY + hexH * (row + 0.5 * cell) + hexH / 2 - }; + var baseY = this.boardY; + + // Single-pass: build compound path, then stroke + fill + ctx.beginPath(); + for (var i = 0; i < lines; i++) { + var row = HexConstants.HEX_VISIBLE_ROWS - 1 - i; + var cy = baseY + hexH * row + hexH / 2; + ctx.moveTo(mx + sCell * HEX_UNIT_VERTICES[0], cy + sCell * HEX_UNIT_VERTICES[1]); + for (var vi = 2; vi < 12; vi += 2) { + ctx.lineTo(mx + sCell * HEX_UNIT_VERTICES[vi], cy + sCell * HEX_UNIT_VERTICES[vi + 1]); + } + ctx.closePath(); + } + ctx.strokeStyle = 'rgba(255, 255, 255, ' + THEME.opacity.label + ')'; + ctx.lineWidth = this._gridLineWidth; + ctx.stroke(); + ctx.fillStyle = 'rgba(255, 255, 255, ' + THEME.opacity.muted + ')'; + ctx.fill(); } _drawGarbageEffects(effects, timestamp, getColor) { if (!Array.isArray(effects) || effects.length === 0) return; - var hs = this._hexSize; - var sCell = hs * (1 - THEME.size.blockGap * 2); + var sCell = this._sCell; + var ctx = this.ctx; + var mx = this._meterX; + var hexH = this._hexH; + var baseY = this.boardY; var now = timestamp || performance.now(); - for (var ei = 0; ei < effects.length; ei++) { - var effect = effects[ei]; - var elapsed = now - effect.startTime; - if (elapsed < 0 || elapsed >= effect.duration) continue; - var alpha = (1 - elapsed / effect.duration) * (effect.maxAlpha || 0.9); - var color = getColor(effect); - - for (var row = effect.rowStart; row < effect.rowStart + effect.lines; row++) { - if (row < 0 || row >= HexConstants.HEX_VISIBLE_ROWS) continue; - for (var cell = 0; cell < 2; cell++) { - var pos = this._getMeterPos(row, cell); - this._drawMeterHex(pos.x, pos.y, sCell, color, alpha); + try { + for (var ei = 0; ei < effects.length; ei++) { + var effect = effects[ei]; + var elapsed = now - effect.startTime; + if (elapsed < 0 || elapsed >= effect.duration) continue; + ctx.globalAlpha = (1 - elapsed / effect.duration) * (effect.maxAlpha || 0.9); + ctx.fillStyle = getColor(effect); + + // Batch all rows of this effect into one fill call + ctx.beginPath(); + for (var row = effect.rowStart; row < effect.rowStart + effect.lines; row++) { + if (row < 0 || row >= HexConstants.HEX_VISIBLE_ROWS) continue; + var visRow = HexConstants.HEX_VISIBLE_ROWS - 1 - row; + var cy = baseY + hexH * visRow + hexH / 2; + ctx.moveTo(mx + sCell * HEX_UNIT_VERTICES[0], cy + sCell * HEX_UNIT_VERTICES[1]); + for (var vi = 2; vi < 12; vi += 2) { + ctx.lineTo(mx + sCell * HEX_UNIT_VERTICES[vi], cy + sCell * HEX_UNIT_VERTICES[vi + 1]); + } + ctx.closePath(); } + ctx.fill(); } + } finally { + ctx.globalAlpha = 1.0; } } drawGarbageIndicatorEffects(effects, timestamp) { - this._drawGarbageEffects(effects, timestamp, function(e) { return e.color; }); + this._drawGarbageEffects(effects, timestamp, _getIndicatorColor); } drawGarbageDefenceEffects(effects, timestamp) { - this._drawGarbageEffects(effects, timestamp, function() { return THEME.color.text.white; }); + this._drawGarbageEffects(effects, timestamp, _getDefenceColor); } // Trace the hex board outline as a closed path (matching the zigzag walls) @@ -148,7 +157,7 @@ class HexUIRenderer extends BaseUIRenderer { var hexS = size * 0.45; var drawS = hexS * (1 - THEME.size.blockGap * 2); - var hexH = Math.sqrt(3) * hexS; // height of flat-top hex (layout spacing) + var hexH = _SQRT3 * hexS; // height of flat-top hex (layout spacing) var colW = 1.5 * hexS; // column spacing var cols = bounds.maxC - bounds.minC + 1; var rows = bounds.maxR - bounds.minR + 1; @@ -158,13 +167,13 @@ class HexUIRenderer extends BaseUIRenderer { var ox = centerX - totalW / 2; var oy = centerY - totalH / 2; - var stamp = getMiniHexStamp(this._styleTier, color, drawS); + var stamp = getHexStamp(this._styleTier, color, _SQRT3 * drawS); var ctx = this.ctx; for (var i = 0; i < bounds.offsets.length; i++) { var o = bounds.offsets[i]; var px = ox + colW * (o.col - bounds.minC) + hexS; var py = oy + hexH * (o.row - bounds.minR + 0.5 * (o.col & 1)) + hexH / 2; - ctx.drawImage(stamp, px - drawS - 1, py - stamp.cssH / 2, stamp.cssW, stamp.cssH); + ctx.drawImage(stamp, px - stamp.cssW / 2, py - stamp.cssH / 2, stamp.cssW, stamp.cssH); } } } diff --git a/public/display/UIRenderer.js b/public/display/UIRenderer.js index 83e62a8..aabff10 100644 --- a/public/display/UIRenderer.js +++ b/public/display/UIRenderer.js @@ -30,8 +30,10 @@ var _getDefenceColor = function() { return THEME.color.text.white; }; class UIRenderer extends BaseUIRenderer { getGarbageMeterLayout() { + // cx = center of meter cell (matches hex meter positioning) + var cx = this.boardX - this.cellSize * 1.07; return { - x: this.boardX - this.cellSize * 1.07, + x: cx - this.cellSize / 2, y: this.boardY, cellSize: this.cellSize, rows: GameConstants.VISIBLE_HEIGHT @@ -78,6 +80,7 @@ class UIRenderer extends BaseUIRenderer { const r = THEME.radius.block(meter.cellSize); const bw = meter.cellSize - inset * 2; const bh = meter.cellSize - inset * 2; + const bx = meter.x + inset; try { for (const effect of effects) { @@ -85,15 +88,21 @@ class UIRenderer extends BaseUIRenderer { if (elapsed < 0 || elapsed >= effect.duration) continue; ctx.globalAlpha = (1 - elapsed / effect.duration) * (effect.maxAlpha || 0.9); + // Batched fill: compound path for all rows in this effect + ctx.beginPath(); for (let row = effect.rowStart; row < effect.rowStart + effect.lines; row++) { if (row < 0 || row >= meter.rows) continue; - const y = meter.y + row * meter.cellSize; - const bx = meter.x + inset; - const by = y + inset; - ctx.fillStyle = getColor(effect); - roundRect(ctx, bx, by, bw, bh, r); - ctx.fill(); - ctx.fillStyle = 'rgba(255, 255, 255, ' + highlightAlpha + ')'; + const by = meter.y + row * meter.cellSize + inset; + _addRoundRectSubPath(ctx, bx, by, bw, bh, r); + } + ctx.fillStyle = getColor(effect); + ctx.fill(); + + // Batched highlight stripe + ctx.fillStyle = 'rgba(255, 255, 255, ' + highlightAlpha + ')'; + for (let row = effect.rowStart; row < effect.rowStart + effect.lines; row++) { + if (row < 0 || row >= meter.rows) continue; + const by = meter.y + row * meter.cellSize + inset; ctx.fillRect(bx + inset, by + inset, bw - inset * 2, inset); } } @@ -119,7 +128,7 @@ class UIRenderer extends BaseUIRenderer { const tier = this._styleTier; const isNeon = tier === STYLE_TIERS.NEON_FLAT; const color = (isNeon ? NEON_PIECE_COLORS[typeId] : PIECE_COLORS[typeId]) || '#ffffff'; - const stamp = getMiniBlockStamp(tier, color, size); + const stamp = getBlockStamp(tier, color, size); const offsetX = centerX - (bounds.w * size) / 2; const offsetY = centerY - (bounds.h * size) / 2; diff --git a/public/shared/CanvasUtils.js b/public/shared/CanvasUtils.js index 593ab8f..14e258c 100644 --- a/public/shared/CanvasUtils.js +++ b/public/shared/CanvasUtils.js @@ -84,6 +84,8 @@ function darkenColor(hex, percent) { return cached; } +var _SQRT3 = Math.sqrt(3); + // Precomputed unit vertices for flat-top hexagons (0°, 60°, 120°, ...). // Flat array [cos0, sin0, cos1, sin1, ...] for cache-line friendliness. var HEX_UNIT_VERTICES = []; @@ -170,30 +172,6 @@ function getBlockStamp(tier, color, cellSize) { return oc; } -function getMiniBlockStamp(tier, color, miniSize) { - var size = Math.round(miniSize); - var key = 'mi_' + tier + '_' + color + '_' + size + '_' + _stampDpr; - var stamp = _stampCache.get(key); - if (stamp) return stamp; - var inset = size * THEME.size.blockGap; - var s = size - inset * 2; - var r = THEME.radius.mini(size); - var oc = _createStampCanvas(size); - var c = oc.getContext('2d'); - c.setTransform(_stampDpr, 0, 0, _stampDpr, 0, 0); - - if (tier === STYLE_TIERS.PILLOW) { - _stampPillow(c, size, inset, s, r, color); - } else if (tier === STYLE_TIERS.NEON_FLAT) { - _stampNeonFlat(c, size, inset, s, r, color); - } else { - _stampMiniNormal(c, size, inset, s, r, color); - } - - _stampCache.set(key, oc); - return oc; -} - function getGarbageStamp(cellSize) { var size = Math.round(cellSize); var key = 'g_' + size + '_' + _stampDpr; @@ -222,13 +200,19 @@ function clearStampCache() { } // ============================================================ -// Hex stamp cache — pre-renders each (tier, color, hexSize) +// Hex stamp cache — pre-renders each (tier, color, height) // hexagon to an offscreen canvas for single drawImage() blits. +// size = drawn height (matches square cellSize for proportions). // ============================================================ -function _createHexStampCanvas(hexSize) { - var w = Math.ceil(2 * hexSize) + 2; // +2 for stroke bleed - var h = Math.ceil(Math.sqrt(3) * hexSize) + 2; +function getHexStamp(tier, color, size) { + var sizeKey = Math.round(size * 10); + var key = 'hx_' + tier + '_' + color + '_' + sizeKey + '_' + _stampDpr; + var stamp = _stampCache.get(key); + if (stamp) return stamp; + var cr = size / _SQRT3; // circumradius for hex path + var w = Math.ceil(2 * cr) + 2; // +2 for stroke bleed + var h = Math.ceil(size) + 2; var pw = Math.ceil(w * _stampDpr); var ph = Math.ceil(h * _stampDpr); var oc; @@ -236,163 +220,98 @@ function _createHexStampCanvas(hexSize) { else { oc = document.createElement('canvas'); oc.width = pw; oc.height = ph; } oc.cssW = w; oc.cssH = h; - return oc; -} - -function getHexStamp(tier, color, hexSize) { - // Use rounded-tenth key to avoid cache explosion from float drift, - // but render at exact size to prevent blurriness - var sizeKey = Math.round(hexSize * 10); - var key = 'hx_' + tier + '_' + color + '_' + sizeKey + '_' + _stampDpr; - var stamp = _stampCache.get(key); - if (stamp) return stamp; - var size = hexSize; - var oc = _createHexStampCanvas(size); var c = oc.getContext('2d'); c.setTransform(_stampDpr, 0, 0, _stampDpr, 0, 0); - var cx = size + 1, cy = oc.cssH / 2; // +1 for stroke padding + var cx = cr + 1, cy = h / 2; // +1 for stroke padding if (tier === STYLE_TIERS.PILLOW) { - _stampHexPillow(c, cx, cy, size, color); + _stampHexPillow(c, cx, cy, cr, size, color); } else if (tier === STYLE_TIERS.NEON_FLAT) { - _stampHexNeonFlat(c, cx, cy, size, color); + _stampHexNeonFlat(c, cx, cy, cr, size, color); } else { - _stampHexNormal(c, cx, cy, size, color); + _stampHexNormal(c, cx, cy, cr, size, color); } _stampCache.set(key, oc); return oc; } -function getMiniHexStamp(tier, color, hexSize) { - var sizeKey = Math.round(hexSize * 10); - var key = 'mhx_' + tier + '_' + color + '_' + sizeKey + '_' + _stampDpr; - var stamp = _stampCache.get(key); - if (stamp) return stamp; - var size = hexSize; - var oc = _createHexStampCanvas(size); - var c = oc.getContext('2d'); - c.setTransform(_stampDpr, 0, 0, _stampDpr, 0, 0); - var cx = size + 1, cy = oc.cssH / 2; - - if (tier === STYLE_TIERS.PILLOW) { - _stampHexPillow(c, cx, cy, size, color); - } else if (tier === STYLE_TIERS.NEON_FLAT) { - _stampHexNeonFlat(c, cx, cy, size, color); - } else { - _stampHexMiniNormal(c, cx, cy, size, color); - } - - _stampCache.set(key, oc); - return oc; -} - -function _stampHexNormal(c, cx, cy, size, color) { - // Clip to hex shape - hexPath(c, cx, cy, size); +function _stampHexNormal(c, cx, cy, cr, size, color) { + // cr = circumradius (for hex path), size = drawn height (for proportions) + hexPath(c, cx, cy, cr); c.save(); c.clip(); - // Gradient fill - var ng = c.createLinearGradient(cx, cy - size, cx, cy + size); + var ng = c.createLinearGradient(cx, cy - cr, cx, cy + cr); ng.addColorStop(0, lightenColor(color, 15)); ng.addColorStop(1, darkenColor(color, 10)); c.fillStyle = ng; c.fill(); - // Top highlight c.fillStyle = 'rgba(255,255,255,' + THEME.opacity.highlight + ')'; - c.fillRect(cx - size * 0.5, cy - size * 0.88, size, size * 0.12); - // Left highlight + c.fillRect(cx - cr * 0.5, cy - cr * 0.88, cr, size * 0.08); c.fillStyle = 'rgba(255,255,255,' + THEME.opacity.muted + ')'; - c.fillRect(cx - size * 0.9, cy - size * 0.5, size * 0.1, size); - // Bottom shadow + c.fillRect(cx - cr * 0.9, cy - cr * 0.5, size * 0.07, cr); c.fillStyle = 'rgba(0,0,0,' + THEME.opacity.shadow + ')'; - c.fillRect(cx - size * 0.5, cy + size * 0.76, size, size * 0.12); - // Inner shine + c.fillRect(cx - cr * 0.5, cy + cr * 0.76, cr, size * 0.08); c.fillStyle = 'rgba(255,255,255,' + THEME.opacity.subtle + ')'; - var sh = size * 0.3; - c.fillRect(cx - size * 0.3, cy - size * 0.4, sh, sh * 0.5); + var sh = size * 0.25; + c.fillRect(cx - cr * 0.25, cy - cr * 0.4, sh, sh * 0.5); c.restore(); - // Border - hexPath(c, cx, cy, size); - c.strokeStyle = 'rgba(255,255,255,0.15)'; - c.lineWidth = 1.5; - c.stroke(); } -function _stampHexPillow(c, cx, cy, size, color) { - // Flat fill - hexPath(c, cx, cy, size); +function _stampHexPillow(c, cx, cy, cr, size, color) { + hexPath(c, cx, cy, cr); c.fillStyle = color; c.fill(); - // Clip + radial gradient var rgb = hexToRgb(color); var lum = rgb ? (rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114) / 255 : 0.5; var hiAlpha = 0.14 + lum * 0.46; - hexPath(c, cx, cy, size); + hexPath(c, cx, cy, cr); c.save(); c.clip(); - var g = c.createRadialGradient(cx - size * 0.1, cy - size * 0.2, 0, cx, cy, size * 0.9); + var g = c.createRadialGradient(cx - cr * 0.05, cy - cr * 0.1, 0, cx, cy, cr * 0.9); g.addColorStop(0, 'rgba(255,255,255,' + hiAlpha.toFixed(2) + ')'); g.addColorStop(0.6, 'rgba(255,255,255,0.03)'); g.addColorStop(1, 'rgba(0,0,0,0.2)'); c.fillStyle = g; c.fill(); c.restore(); - // Top edge highlight var edgeAlpha = 0.12 + lum * 0.38; c.strokeStyle = 'rgba(255,255,255,' + edgeAlpha.toFixed(2) + ')'; - c.lineWidth = Math.max(0.5, size * 0.05); + c.lineWidth = Math.max(0.5, size * 0.04); c.beginPath(); - c.moveTo(cx + size * HEX_UNIT_VERTICES[8], cy + size * HEX_UNIT_VERTICES[9]); // vertex 4 - c.lineTo(cx + size * HEX_UNIT_VERTICES[10], cy + size * HEX_UNIT_VERTICES[11]); // vertex 5 + c.moveTo(cx + cr * HEX_UNIT_VERTICES[8], cy + cr * HEX_UNIT_VERTICES[9]); + c.lineTo(cx + cr * HEX_UNIT_VERTICES[10], cy + cr * HEX_UNIT_VERTICES[11]); c.stroke(); - // Bottom edge shadow c.strokeStyle = 'rgba(0,0,0,0.25)'; c.beginPath(); - c.moveTo(cx + size * HEX_UNIT_VERTICES[2], cy + size * HEX_UNIT_VERTICES[3]); // vertex 1 - c.lineTo(cx + size * HEX_UNIT_VERTICES[4], cy + size * HEX_UNIT_VERTICES[5]); // vertex 2 + c.moveTo(cx + cr * HEX_UNIT_VERTICES[2], cy + cr * HEX_UNIT_VERTICES[3]); + c.lineTo(cx + cr * HEX_UNIT_VERTICES[4], cy + cr * HEX_UNIT_VERTICES[5]); c.stroke(); } -function _stampHexNeonFlat(c, cx, cy, size, color) { +function _stampHexNeonFlat(c, cx, cy, cr, size, color) { var rgb = hexToRgb(color); if (!rgb) return; - // Dark fill var darkFill = 'rgba(' + (rgb.r * 0.2 | 0) + ',' + (rgb.g * 0.2 | 0) + ',' + (rgb.b * 0.2 | 0) + ',0.92)'; - hexPath(c, cx, cy, size); + hexPath(c, cx, cy, cr); c.fillStyle = darkFill; c.fill(); - // Colored border - var bw = Math.max(1, size * 0.12); + var bw = Math.max(1, size * 0.08); c.strokeStyle = color; c.lineWidth = bw; - hexPath(c, cx, cy, size); + hexPath(c, cx, cy, cr); c.stroke(); - // Top highlight line (vertices 4→5) + var insetScale = 1 - bw / cr; c.globalAlpha = 0.25; c.beginPath(); - c.moveTo(cx + size * 0.85 * HEX_UNIT_VERTICES[8], cy + size * 0.85 * HEX_UNIT_VERTICES[9]); - c.lineTo(cx + size * 0.85 * HEX_UNIT_VERTICES[10], cy + size * 0.85 * HEX_UNIT_VERTICES[11]); + c.moveTo(cx + cr * insetScale * HEX_UNIT_VERTICES[8], cy + cr * insetScale * HEX_UNIT_VERTICES[9]); + c.lineTo(cx + cr * insetScale * HEX_UNIT_VERTICES[10], cy + cr * insetScale * HEX_UNIT_VERTICES[11]); c.strokeStyle = '#fff'; - c.lineWidth = Math.max(0.5, size * 0.04); + c.lineWidth = Math.max(0.5, size * 0.025); c.stroke(); c.globalAlpha = 1; } -function _stampHexMiniNormal(c, cx, cy, size, color) { - hexPath(c, cx, cy, size); - c.save(); - c.clip(); - var mg = c.createLinearGradient(cx, cy - size, cx, cy + size); - mg.addColorStop(0, color); - mg.addColorStop(1, darkenColor(color, 15)); - c.fillStyle = mg; - c.fill(); - c.fillStyle = 'rgba(255,255,255,' + THEME.opacity.highlight + ')'; - c.fillRect(cx - size * 0.5, cy - size * 0.88, size, size * 0.1); - c.restore(); -} - // --- Square stamp drawing helpers (draw at 0,0 on offscreen context) --- function _stampNormal(c, size, inset, s, r, color) { @@ -464,17 +383,6 @@ function _stampNeonFlat(c, size, inset, s, r, color) { c.stroke(); } -function _stampMiniNormal(c, size, inset, s, r, color) { - var g = c.createLinearGradient(0, 0, 0, size); - g.addColorStop(0, color); - g.addColorStop(1, darkenColor(color, 15)); - c.fillStyle = g; - roundRect(c, inset, inset, s, s, r); - c.fill(); - c.fillStyle = 'rgba(255, 255, 255, ' + THEME.opacity.highlight + ')'; - c.fillRect(inset + r, inset, s - r * 2, size * 0.06); -} - // Shared font detection — returns the preferred display font family string. // Checks whether Orbitron has loaded; falls back to monospace. // Re-checks on each font load event until Orbitron is detected. diff --git a/public/shared/WelcomeBackground.js b/public/shared/WelcomeBackground.js index d12121f..024a30a 100644 --- a/public/shared/WelcomeBackground.js +++ b/public/shared/WelcomeBackground.js @@ -339,11 +339,11 @@ class WelcomeBackground { const sCell = size * 0.94; const hasStamps = typeof getHexStamp === 'function'; if (hasStamps) { - const stamp = getHexStamp(STYLE_TIERS.NORMAL, p.color, sCell); + const stamp = getHexStamp(STYLE_TIERS.NORMAL, p.color, _SQRT3 * sCell); for (const [q, r] of p.cells) { const cx = p.x + size * 1.5 * q; const cy = p.y + size * _SQRT3 * (r + q / 2); - ctx.drawImage(stamp, cx - sCell - 1, cy - stamp.cssH / 2, stamp.cssW, stamp.cssH); + ctx.drawImage(stamp, cx - stamp.cssW / 2, cy - stamp.cssH / 2, stamp.cssW, stamp.cssH); } } else { for (const [q, r] of p.cells) { diff --git a/public/shared/theme.js b/public/shared/theme.js index a6c5b14..ad158f0 100644 --- a/public/shared/theme.js +++ b/public/shared/theme.js @@ -4,16 +4,16 @@ // Design Tokens — single source of truth for the visual layer // ============================================================ -// --- Piece colors (classic: 1=I…7=Z, hex: 1=L…7=Tp, garbage: 9) --- +// --- Piece colors (1=I…7=Z, shared by square and hex modes, garbage: 8-9) --- const PIECE_COLORS = { 0: '#000000', // empty - 1: '#EE4444', // classic I / hex L - red - 2: '#00CED1', // classic J / hex S - teal - 3: '#FFD700', // classic L / hex T - gold - 4: '#7FFF00', // classic O / hex F - lime - 5: '#9B59F0', // classic S / hex Fm - violet - 6: '#FF1493', // classic T / hex I4 - hot pink - 7: '#FF8C00', // classic Z / hex Tp - amber + 1: '#EE4444', // I - red + 2: '#00CED1', // J - teal + 3: '#FFD700', // L - gold + 4: '#7FFF00', // O - lime + 5: '#9B59F0', // S - violet + 6: '#FF1493', // T - hot pink + 7: '#FF8C00', // Z - amber 8: '#33AAFF', // classic garbage - sky blue 9: '#808080' // hex garbage - gray }; @@ -122,7 +122,6 @@ const THEME = Object.freeze({ // ---- Border Radii (functions of cell/block size) ---- radius: Object.freeze({ block: (size) => size * 0.12, - mini: (size) => size * 0.1, panel: (size) => size * 0.2, }), diff --git a/server/HexConstants.js b/server/HexConstants.js index 8d67e21..36e10fd 100644 --- a/server/HexConstants.js +++ b/server/HexConstants.js @@ -13,8 +13,8 @@ var HEX_VISIBLE_ROWS = 21; // 7 hex piece types (1-indexed to match grid cell values) // All 4-hex pieces -var HEX_PIECE_TYPES = ['L', 'S', 'T', 'F', 'Fm', 'I4', 'Tp']; -var HEX_PIECE_TYPE_TO_ID = { L: 1, S: 2, T: 3, F: 4, Fm: 5, I4: 6, Tp: 7 }; +var HEX_PIECE_TYPES = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']; +var HEX_PIECE_TYPE_TO_ID = { I: 1, J: 2, L: 3, O: 4, S: 5, T: 6, Z: 7 }; var HEX_GARBAGE_CELL = 9; // ===================== ZIGZAG CLEAR DETECTION ===================== @@ -112,11 +112,10 @@ function computeHexGeometry(boardCols, visRows, cellSize) { }; } -// Trace the closed outline of a hex board on a canvas context. -// bx, by: board origin. hs: hexSize. hexH: hex height. colW: column spacing. // Compute the outline vertices for a hex board as a flat array of [x, y] pairs. +// bx, by: board origin. hs: hexSize. hexH: hex height. colW: column spacing. // Used by both traceHexOutline (canvas path) and HexBoardRenderer (pre-computed cache). -function computeHexOutlineVerts(bx, by, hs, hexH, colW, cols, visRows) { +function computeHexOutlineVerts(bx, by, hs, hexH, colW, cols, visRows, outset) { var verts = []; var lastRow = visRows - 1; var lastCol = cols - 1; @@ -169,6 +168,33 @@ function computeHexOutlineVerts(bx, by, hs, hexH, colW, cols, visRows) { verts.push(hv(pl[0], pl[1], 3)); verts.push(hv(pl[0], pl[1], 4)); } + + // Offset each vertex outward along the average normal of its two adjacent edges. + // This ensures uniform perpendicular distance from the original outline. + if (outset) { + var n = verts.length; + var offset = []; + for (var oi = 0; oi < n; oi++) { + var prev = verts[(oi - 1 + n) % n]; + var curr = verts[oi]; + var next = verts[(oi + 1) % n]; + // Edge normals (outward = right-hand perpendicular for CW winding) + var n1x = curr[1] - prev[1], n1y = prev[0] - curr[0]; + var n2x = next[1] - curr[1], n2y = curr[0] - next[0]; + var l1 = Math.sqrt(n1x * n1x + n1y * n1y) || 1; + var l2 = Math.sqrt(n2x * n2x + n2y * n2y) || 1; + n1x /= l1; n1y /= l1; + n2x /= l2; n2y /= l2; + // Average normal, scaled to maintain perpendicular offset distance + var ax = n1x + n2x, ay = n1y + n2y; + var dot = ax * n1x + ay * n1y; + if (Math.abs(dot) < 0.001) dot = 1; + var scale = outset / dot; + offset.push([curr[0] + ax * scale, curr[1] + ay * scale]); + } + return offset; + } + return verts; } diff --git a/server/HexPiece.js b/server/HexPiece.js index e28d430..910f586 100644 --- a/server/HexPiece.js +++ b/server/HexPiece.js @@ -23,13 +23,13 @@ var _absBlocksScratch = [[0,0],[0,0],[0,0],[0,0]]; // ===================== PIECE DEFINITIONS ===================== // Same shapes as pointy-top hex — axial coords are orientation-independent. var HEX_PIECES = { - L: [[-1,0],[0,0],[1,0],[1,-1]], - S: [[-1,0],[0,0],[0,-1],[1,-1]], - T: [[-1,0],[0,0],[1,0],[0,-1]], - F: [[-2,1],[-1,1],[0,0],[1,0]], - Fm: [[-1,0],[0,0],[0,1],[1,1]], - I4: [[-1,0],[0,0],[1,0],[2,0]], - Tp: [[0,0],[1,0],[-1,1],[0,-1]], // Tripod: center + 3 legs, 2 rotations + I: [[-1,0],[0,0],[1,0],[2,0]], // 4 in a line + J: [[-1,1],[0,0],[1,-1],[-1,0]], // 3 in a row + 1 on top of center + L: [[-1,0],[0,0],[1,0],[1,-1]], // 3 in a row + 1 corner + O: [[-1,0],[0,0],[0,-1],[1,-1]], // compact zigzag (closest to 2x2) + S: [[-2,1],[-1,1],[0,0],[1,0]], // wider zigzag + T: [[0,0],[1,0],[-1,1],[0,-1]], // tripod: center + 3 legs + Z: [[-1,1],[0,0],[1,0],[2,-1]], // mirror zigzag }; var KICKS = [[0,0], [-1,0], [1,0], [0,-1], [0,1], [-1,-1], [1,-1], [-1,1], [1,1]]; diff --git a/tests/hex-board.test.js b/tests/hex-board.test.js index 0613d4e..5de946c 100644 --- a/tests/hex-board.test.js +++ b/tests/hex-board.test.js @@ -13,7 +13,7 @@ describe('HexPiece', () => { it('creates a piece with correct type and cells', () => { var p = new HexPiece('T'); assert.equal(p.type, 'T'); - assert.equal(p.typeId, 3); + assert.equal(p.typeId, 6); assert.equal(p.cells.length, 4); }); @@ -42,7 +42,7 @@ describe('HexPiece', () => { }); it('all 7 piece types create valid pieces', () => { - var types = ['L', 'S', 'T', 'F', 'Fm', 'I4', 'Tp']; + var types = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']; for (var t of types) { var p = new HexPiece(t); var blocks = p.getAbsoluteBlocks(); @@ -54,13 +54,13 @@ describe('HexPiece', () => { } }); - it('I4 has 4 cells', () => { - var p = new HexPiece('I4'); + it('I has 4 cells', () => { + var p = new HexPiece('I'); assert.equal(p.cells.length, 4); }); - it('Tp (tripod) has 4 cells and 2 unique rotations', () => { - var p = new HexPiece('Tp'); + it('T (tripod) has 4 cells and 2 unique rotations', () => { + var p = new HexPiece('T'); assert.equal(p.cells.length, 4); var seen = new Set(); var cells = p.cells.map(c => ({ q: c.q, r: c.r })); @@ -106,7 +106,7 @@ describe('HexPiece - coordinate math', () => { }); it('6 CW rotations return to original (hex symmetry)', () => { - var p = new HexPiece('I4'); + var p = new HexPiece('I'); var original = JSON.stringify(p.cells); for (var i = 0; i < 6; i++) p.rotateCW(); assert.equal(JSON.stringify(p.cells), original); diff --git a/tests/visual/display.spec.js-snapshots/07d-pieces-neon-display-darwin.png b/tests/visual/display.spec.js-snapshots/07d-pieces-neon-display-darwin.png index d6e921c..878545f 100644 Binary files a/tests/visual/display.spec.js-snapshots/07d-pieces-neon-display-darwin.png and b/tests/visual/display.spec.js-snapshots/07d-pieces-neon-display-darwin.png differ diff --git a/tests/visual/display.spec.js-snapshots/07d-pieces-normal-display-darwin.png b/tests/visual/display.spec.js-snapshots/07d-pieces-normal-display-darwin.png index 381c9f9..db93fcb 100644 Binary files a/tests/visual/display.spec.js-snapshots/07d-pieces-normal-display-darwin.png and b/tests/visual/display.spec.js-snapshots/07d-pieces-normal-display-darwin.png differ diff --git a/tests/visual/display.spec.js-snapshots/07d-pieces-pillow-display-darwin.png b/tests/visual/display.spec.js-snapshots/07d-pieces-pillow-display-darwin.png index d717d67..2c85fdc 100644 Binary files a/tests/visual/display.spec.js-snapshots/07d-pieces-pillow-display-darwin.png and b/tests/visual/display.spec.js-snapshots/07d-pieces-pillow-display-darwin.png differ diff --git a/tests/visual/fixtures.js b/tests/visual/fixtures.js index 098b2da..f18d0b5 100644 --- a/tests/visual/fixtures.js +++ b/tests/visual/fixtures.js @@ -248,12 +248,12 @@ function createAllColorsGrid() { return grid; } -// Build a game state with all 4 style tiers visible (4 players, one per tier) +// Build a game state with all 3 style tiers visible (3 players, one per tier) // Each board shows all 7 piece colors + garbage function buildStyleTierGameState(playerIds) { - const tierLevels = [3, 8, 13]; // Normal, Square, Neon + const tierLevels = [3, 8, 13]; // Normal, Pillow, Neon const tierLines = [20, 70, 120]; // lines matching those levels - const tierNames = ['Normal', 'Square', 'Neon']; + const tierNames = ['Normal', 'Pillow', 'Neon']; const allColorsGrid = createAllColorsGrid(); const tierPiece = { typeId: 6, x: 4, y: 3, blocks: [[1, 0], [0, 1], [1, 1], [2, 1]] }; @@ -291,24 +291,10 @@ function buildStyleTierGameState(playerIds) { // AND all 7 ghost outlines at the bottom, all at the same style tier. // Uses extraGhosts (rendered by BoardRenderer) for the 6 non-active ghost pieces. function buildAllPiecesGhostState(playerIds, tierLevel) { - const tierLevelMap = { 3: 'Normal', 8: 'Square', 13: 'Neon' }; + const tierLevelMap = { 3: 'Normal', 8: 'Pillow', 13: 'Neon' }; const tierName = tierLevelMap[tierLevel] || 'Normal'; - // All 7 pieces as solid blocks in rows 2-6 - function createShowcaseGrid() { - const grid = Array.from({ length: 22 }, () => Array(10).fill(0)); - // I(1) horizontal - grid[2] = [0, 0, 0, 1, 1, 1, 1, 0, 0, 0]; - // J(2) + O(4) + L(3) - grid[3] = [2, 0, 0, 4, 4, 0, 0, 0, 0, 3]; - grid[4] = [2, 2, 2, 4, 4, 5, 5, 0, 3, 3]; - // S(5) + T(6) + Z(7) - grid[5] = [0, 0, 6, 0, 5, 5, 7, 7, 3, 0]; - grid[6] = [0, 6, 6, 6, 0, 0, 0, 7, 7, 0]; - return grid; - } - - // All 7 piece definitions for active + ghost placement + // All 7 piece definitions (blocks are [col, row] offsets from position) const allPieces = [ { typeId: 1, blocks: [[0,0],[1,0],[2,0],[3,0]] }, // I { typeId: 2, blocks: [[0,0],[0,1],[1,1],[2,1]] }, // J @@ -319,39 +305,56 @@ function buildAllPiecesGhostState(playerIds, tierLevel) { { typeId: 7, blocks: [[0,0],[1,0],[1,1],[2,1]] }, // Z ]; - // Ghost row positions — spread across rows 10-18, spaced by 2 rows per piece - // Active piece ghost at row 10, extra ghosts at rows 12, 14, 16, 18 etc. - // Place them at different x positions so they don't overlap - const ghostPositions = [ - { x: 0, ghostY: 12 }, // I at cols 0-3 - { x: 0, ghostY: 15 }, // J at cols 0-2 - { x: 4, ghostY: 15 }, // L at cols 4-6 - { x: 8, ghostY: 15 }, // O at cols 8-9 - { x: 0, ghostY: 18 }, // S at cols 0-2 - { x: 4, ghostY: 18 }, // T at cols 4-6 - { x: 7, ghostY: 18 }, // Z at cols 7-9 + // Solid pieces in rows 2-6, ghosts in rows 12-18 (same positions) + const piecePositions = [ + { x: 0, y: 2 }, // I at cols 0-3 + { x: 0, y: 5 }, // J at cols 0-2 + { x: 4, y: 5 }, // L at cols 4-6 + { x: 8, y: 5 }, // O at cols 8-9 + { x: 0, y: 8 }, // S at cols 0-2 + { x: 4, y: 8 }, // T at cols 4-6 + { x: 7, y: 8 }, // Z at cols 7-9 ]; - const pieceLabels = ['I', 'J', 'L', 'O', 'S', 'T', 'Z', 'I']; + const ghostPositions = piecePositions.map(p => ({ x: p.x, ghostY: p.y + 10 })); + + // 6 pieces as solid blocks (T is the active piece, shown separately) + function createShowcaseGrid() { + const grid = Array.from({ length: 22 }, () => Array(10).fill(0)); + for (let i = 0; i < allPieces.length; i++) { + if (allPieces[i].typeId === 6) continue; // T is the active piece + const pos = piecePositions[i]; + for (const [bx, by] of allPieces[i].blocks) { + grid[pos.y + by][pos.x + bx] = allPieces[i].typeId; + } + } + return grid; + } + + // 6 extra ghost definitions (T excluded — it's the active ghost) + const extraPieces = allPieces.filter(p => p.typeId !== 6); + + // Active piece: T, same on all boards + const tIdx = allPieces.findIndex(p => p.typeId === 6); + const activeDef = allPieces[tIdx]; + const activeX = piecePositions[tIdx].x; + const activeGhostY = ghostPositions[tIdx].ghostY; + + // Extra ghosts: all pieces except T (same on all boards) + const extras = []; + for (let i = 0; i < allPieces.length; i++) { + if (i === tIdx) continue; + extras.push({ + typeId: allPieces[i].typeId, + x: ghostPositions[i].x, + ghostY: ghostPositions[i].ghostY, + blocks: allPieces[i].blocks.map(b => b.slice()), + }); + } const extraGhostsPerPlayer = []; const players = playerIds.map((id, index) => { - const activeIdx = index % 7; - const activePos = ghostPositions[activeIdx]; - const activeDef = allPieces[activeIdx]; - - // Build extra ghosts for the other 6 piece types - const extras = []; - for (let i = 0; i < 7; i++) { - if (i === activeIdx) continue; - extras.push({ - typeId: allPieces[i].typeId, - x: ghostPositions[i].x, - ghostY: ghostPositions[i].ghostY, - blocks: allPieces[i].blocks.map(b => b.slice()), - }); - } extraGhostsPerPlayer.push(extras); return { @@ -363,15 +366,15 @@ function buildAllPiecesGhostState(playerIds, tierLevel) { grid: createShowcaseGrid(), currentPiece: { typeId: activeDef.typeId, - x: activePos.x, + x: activeX, y: 8, blocks: activeDef.blocks.map(b => b.slice()), }, - ghostY: activePos.ghostY, + ghostY: activeGhostY, holdPiece: null, nextPieces: [], pendingGarbage: 0, - playerName: tierName + ' ' + pieceLabels[index % 8], + playerName: tierName + ' ' + (index + 1), playerColor: PLAYER_COLORS[index % PLAYER_COLORS.length], }; }); diff --git a/tests/visual/hex-display.spec.js b/tests/visual/hex-display.spec.js index aecd1a4..c5a30c6 100644 --- a/tests/visual/hex-display.spec.js +++ b/tests/visual/hex-display.spec.js @@ -6,7 +6,7 @@ const { waitForFont, waitForGameRender, } = require('./helpers'); -const { buildHexGameState, buildHexStyleTierState, buildPlayerIds, buildPlayers } = require('./hex-fixtures'); +const { buildHexGameState, buildHexStyleTierState, buildHexAllPiecesGhostState, buildPlayerIds, buildPlayers } = require('./hex-fixtures'); async function injectHexPlayers(page, count) { const playerList = buildPlayers(count); @@ -92,6 +92,23 @@ test.describe('Hex Display', () => { await expect(page).toHaveScreenshot('hex-08-style-tiers.png'); }); + for (const [tierName, tierLevel] of [['normal', 3], ['pillow', 8], ['neon', 13]]) { + test(`hex mode - all pieces + ghosts ${tierName}`, async ({ page }) => { + await page.setViewportSize({ width: 2560, height: 1440 }); + await gotoDisplayTest(page); + await injectHexPlayers(page, 8); + const playerIds = buildPlayerIds(8); + const result = buildHexAllPiecesGhostState(playerIds, tierLevel); + await page.evaluate(({ s, extraGhosts }) => { + window.__TEST__.setGameMode('hex'); + window.__TEST__.setExtraGhosts(extraGhosts); + window.__TEST__.injectGameState(s); + }, { s: result.state, extraGhosts: result.extraGhostsPerPlayer }); + await waitForGameRender(page); + await expect(page).toHaveScreenshot(`hex-08b-pieces-${tierName}.png`); + }); + } + test('hex mode - KO overlay', async ({ page }) => { await gotoDisplayTest(page); await injectHexPlayers(page, 2); diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-01-1player-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-01-1player-hex-display-darwin.png index 3744bcf..d4dba8a 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-01-1player-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-01-1player-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-02-1player-empty-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-02-1player-empty-hex-display-darwin.png index 2ea966f..f5f9c3d 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-02-1player-empty-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-02-1player-empty-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-03-2players-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-03-2players-hex-display-darwin.png index 8a3def3..5419e42 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-03-2players-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-03-2players-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-04-4players-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-04-4players-hex-display-darwin.png index 9ef3e42..ee3bc63 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-04-4players-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-04-4players-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-05-tier-pillow-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-05-tier-pillow-hex-display-darwin.png index 77643ad..891320e 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-05-tier-pillow-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-05-tier-pillow-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-06-tier-neon-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-06-tier-neon-hex-display-darwin.png index d5675c4..3d40b19 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-06-tier-neon-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-06-tier-neon-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-07-clear-preview-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-07-clear-preview-hex-display-darwin.png index ea81ef1..5015398 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-07-clear-preview-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-07-clear-preview-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-08-style-tiers-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-08-style-tiers-hex-display-darwin.png index 44fce58..b975037 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-08-style-tiers-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-08-style-tiers-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-neon-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-neon-hex-display-darwin.png new file mode 100644 index 0000000..5ce16ee Binary files /dev/null and b/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-neon-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-normal-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-normal-hex-display-darwin.png new file mode 100644 index 0000000..e4d73f6 Binary files /dev/null and b/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-normal-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-pillow-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-pillow-hex-display-darwin.png new file mode 100644 index 0000000..19de1fb Binary files /dev/null and b/tests/visual/hex-display.spec.js-snapshots/hex-08b-pieces-pillow-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-09-ko-overlay-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-09-ko-overlay-hex-display-darwin.png index 0f3a91f..0164cc7 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-09-ko-overlay-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-09-ko-overlay-hex-display-darwin.png differ diff --git a/tests/visual/hex-display.spec.js-snapshots/hex-10-disconnected-hex-display-darwin.png b/tests/visual/hex-display.spec.js-snapshots/hex-10-disconnected-hex-display-darwin.png index 25f9576..a72affb 100644 Binary files a/tests/visual/hex-display.spec.js-snapshots/hex-10-disconnected-hex-display-darwin.png and b/tests/visual/hex-display.spec.js-snapshots/hex-10-disconnected-hex-display-darwin.png differ diff --git a/tests/visual/hex-fixtures.js b/tests/visual/hex-fixtures.js index 2707806..db8fd23 100644 --- a/tests/visual/hex-fixtures.js +++ b/tests/visual/hex-fixtures.js @@ -3,6 +3,7 @@ const { HEX_COLS, HEX_VISIBLE_ROWS } = require('../../server/HexConstants.js'); const { HexPiece } = require('../../server/HexPiece.js'); +const { PLAYER_COLORS } = require('../../public/shared/theme.js'); function createHexGrid() { return Array.from({ length: HEX_VISIBLE_ROWS }, () => Array(HEX_COLS).fill(0)); @@ -35,7 +36,7 @@ function buildHexGameState(playerIds, options) { } } - var pieceTypes = ['L', 'S', 'T', 'F', 'Fm', 'I4', 'Tp']; + var pieceTypes = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']; var pieceType = pieceTypes[i % pieceTypes.length]; var piece = new HexPiece(pieceType); piece.anchorCol = 5; @@ -81,15 +82,120 @@ function buildHexGameState(playerIds, options) { // 3 players at levels 3, 8, 13 to show all style tiers function buildHexStyleTierState(playerIds) { var levels = [3, 8, 13]; - return buildHexGameState(playerIds, { level: null }).players.length - ? { players: playerIds.map(function(pid, i) { - var s = buildHexGameState([pid], {}); - var p = s.players[0]; - p.level = levels[i % levels.length]; - p.lines = (levels[i % levels.length] - 1) * 10; - return p; - }), elapsed: 60000 } - : buildHexGameState(playerIds, {}); + return { players: playerIds.map(function(pid, i) { + var s = buildHexGameState([pid], {}); + var p = s.players[0]; + p.level = levels[i % levels.length]; + p.lines = (levels[i % levels.length] - 1) * 10; + return p; + }), elapsed: 60000 }; +} + +// Build a state showing all 7 hex piece types with ghosts at a given style tier. +// Each player board has all 7 pieces as solid blocks at top + all 7 ghost outlines below. +function buildHexAllPiecesGhostState(playerIds, tierLevel) { + var tierLevelMap = { 3: 'Normal', 8: 'Pillow', 13: 'Neon' }; + var tierName = tierLevelMap[tierLevel] || 'Normal'; + var pieceTypes = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']; + + // 6 pieces as solid blocks in rows 2-6 (T is the active piece, shown separately) + function createShowcaseGrid() { + var grid = createHexGrid(); + var placements = [ + { type: 'L', col: 2, row: 2 }, + { type: 'O', col: 6, row: 2 }, + { type: 'S', col: 2, row: 4 }, + { type: 'Z', col: 5, row: 4 }, + { type: 'I', col: 5, row: 6 }, + { type: 'J', col: 9, row: 4 }, + ]; + for (var pi = 0; pi < placements.length; pi++) { + var pl = placements[pi]; + var piece = new HexPiece(pl.type); + piece.anchorCol = pl.col; + piece.anchorRow = pl.row; + var blocks = piece.getAbsoluteBlocks(); + for (var bi = 0; bi < blocks.length; bi++) { + var bc = blocks[bi][0], br = blocks[bi][1]; + if (br >= 0 && br < HEX_VISIBLE_ROWS && bc >= 0 && bc < HEX_COLS) { + grid[br][bc] = piece.typeId; + } + } + } + return grid; + } + + // Ghost positions: 6 extra ghosts + T as the active ghost + var ghostPlacements = [ + { type: 'L', col: 2, row: 10 }, + { type: 'O', col: 6, row: 10 }, + { type: 'S', col: 2, row: 13 }, + { type: 'Z', col: 5, row: 13 }, + { type: 'I', col: 5, row: 16 }, + { type: 'J', col: 9, row: 13 }, + ]; + + // Active piece (same on all boards): T-piece falling, ghost at row 10 + var activePiece = new HexPiece('T'); + activePiece.anchorCol = 9; + activePiece.anchorRow = 8; + var activeBlocks = activePiece.getAbsoluteBlocks(); + var ghostPiece = new HexPiece('T'); + ghostPiece.anchorCol = 9; + ghostPiece.anchorRow = 10; + var ghostBlocks = ghostPiece.getAbsoluteBlocks(); + + // Extra ghosts: the other 6 piece types (same on all boards) + var extraGhosts = []; + for (var gi = 0; gi < ghostPlacements.length; gi++) { + var gpl = ghostPlacements[gi]; + var gp = new HexPiece(gpl.type); + gp.anchorCol = gpl.col; + gp.anchorRow = gpl.row; + var gBlocks = gp.getAbsoluteBlocks(); + extraGhosts.push({ + typeId: gp.typeId, + x: 0, + ghostY: 0, + blocks: gBlocks.map(function(b) { return [b[0], b[1]]; }), + }); + } + + var extraGhostsPerPlayer = []; + var players = playerIds.map(function(id, index) { + extraGhostsPerPlayer.push(extraGhosts); + return { + id: id, + alive: true, + lines: (tierLevel - 1) * 10, + level: tierLevel, + grid: createShowcaseGrid(), + currentPiece: { + type: activePiece.type, + typeId: activePiece.typeId, + anchorCol: activePiece.anchorCol, + anchorRow: activePiece.anchorRow, + cells: activePiece.cells, + blocks: activeBlocks.map(function(b) { return [b[0], b[1]]; }), + }, + ghost: { + anchorCol: ghostPiece.anchorCol, + anchorRow: ghostPiece.anchorRow, + blocks: ghostBlocks.map(function(b) { return [b[0], b[1]]; }), + }, + holdPiece: null, + nextPieces: [], + pendingGarbage: 0, + playerName: tierName + ' ' + (index + 1), + playerColor: PLAYER_COLORS[index % PLAYER_COLORS.length], + clearingCells: null, + }; + }); + + return { + state: { players: players, elapsed: 65000 }, + extraGhostsPerPlayer: extraGhostsPerPlayer, + }; } function buildPlayerIds(count) { @@ -109,6 +215,7 @@ function buildPlayers(count) { module.exports = { buildHexGameState, buildHexStyleTierState, + buildHexAllPiecesGhostState, buildPlayerIds, buildPlayers, createHexGrid, diff --git a/tests/visual/style-comparison.spec.js b/tests/visual/style-comparison.spec.js new file mode 100644 index 0000000..697722e --- /dev/null +++ b/tests/visual/style-comparison.spec.js @@ -0,0 +1,102 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); +const { + gotoDisplayTest, + injectPlayers, + injectStyleTierGameState, + waitForGameRender, +} = require('./helpers'); +const { buildHexStyleTierState, buildPlayerIds, buildPlayers } = require('./hex-fixtures'); + +test.describe('Style Comparison', () => { + test('square vs hex - all 3 style tiers', async ({ page }) => { + // Use 2x DPR for high-res capture + const W = 1920; + const H = 1080; + const DPR = 2; + + await gotoDisplayTest(page); + + // --- Square: 3 players at Normal / Pillow / Neon --- + await injectPlayers(page, 3); + await injectStyleTierGameState(page, 3); + // Ensure all square players show pending garbage for visual comparison + await page.evaluate(() => { + for (const p of gameState.players) p.pendingGarbage = 4; + }); + + const squareData = await page.evaluate(() => { + const c = document.getElementById('game-canvas'); + return c.toDataURL('image/png'); + }); + + // --- Hex: 3 players at Normal / Pillow / Neon --- + const hexPlayerIds = buildPlayerIds(3); + const hexState = buildHexStyleTierState(hexPlayerIds); + // Ensure all hex players show pending garbage for visual comparison + for (const p of hexState.players) p.pendingGarbage = 4; + await page.evaluate(({ s }) => { + window.__TEST__.setGameMode('hex'); + window.__TEST__.injectGameState(s); + }, { s: hexState }); + await waitForGameRender(page); + + // Wait until the render loop has actually drawn hex content to the canvas. + // The render loop runs via RAF; we need several frames to ensure renderFrame() + // has executed with the new hex renderers (not just our own RAF callbacks). + await page.waitForTimeout(200); + + const hexData = await page.evaluate(() => { + const c = document.getElementById('game-canvas'); + return c.toDataURL('image/png'); + }); + + // --- Composite: square on top, hex on bottom at 2x resolution --- + const compW = W * DPR; + const compH = H * 2 * DPR; + const labelH = 50 * DPR; + const halfH = H * DPR; + + await page.setViewportSize({ width: compW, height: compH }); + await page.evaluate(async ({ sq, hx, cw, ch, lh, hh }) => { + const loadImg = (src) => new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + const sqImg = await loadImg(sq); + const hxImg = await loadImg(hx); + + const canvas = document.createElement('canvas'); + canvas.width = cw; + canvas.height = ch; + canvas.style.width = cw + 'px'; + canvas.style.height = ch + 'px'; + canvas.style.display = 'block'; + const ctx = canvas.getContext('2d'); + + // Dark background + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, cw, ch); + + // Labels + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 48px Orbitron, monospace'; + ctx.textAlign = 'center'; + ctx.fillText('SQUARE', cw / 2, lh - 10); + ctx.fillText('HEX', cw / 2, hh + lh - 10); + + // Draw each half (source is 1x, stretch to 2x) + ctx.drawImage(sqImg, 0, lh, cw, hh - lh); + ctx.drawImage(hxImg, 0, hh + lh, cw, hh - lh); + + document.body.innerHTML = ''; + document.body.style.margin = '0'; + document.body.style.overflow = 'hidden'; + document.body.appendChild(canvas); + }, { sq: squareData, hx: hexData, cw: compW, ch: compH, lh: labelH, hh: halfH }); + + await expect(page).toHaveScreenshot('style-comparison-square-vs-hex.png'); + }); +}); diff --git a/tests/visual/style-comparison.spec.js-snapshots/style-comparison-square-vs-hex-style-comparison-darwin.png b/tests/visual/style-comparison.spec.js-snapshots/style-comparison-square-vs-hex-style-comparison-darwin.png new file mode 100644 index 0000000..9c4a375 Binary files /dev/null and b/tests/visual/style-comparison.spec.js-snapshots/style-comparison-square-vs-hex-style-comparison-darwin.png differ