Skip to content
Open
8 changes: 4 additions & 4 deletions artwork/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
12 changes: 6 additions & 6 deletions artwork/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 5 additions & 2 deletions public/display/BaseUIRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions public/display/BoardRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 14 additions & 10 deletions public/display/DisplayRender.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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]
};
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
}
Expand Down
49 changes: 30 additions & 19 deletions public/display/HexBoardRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
}
}
}
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -268,7 +279,7 @@ class HexBoardRenderer {
}
}
gc.strokeStyle = this._gridStroke;
gc.lineWidth = 1.5;
gc.lineWidth = this._gridLineWidth;
gc.stroke();
}

Expand Down
Loading
Loading