From 6927b148f26df609d558182f0d04eba5aa2e6137 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 14 Jun 2026 19:33:28 -0600 Subject: [PATCH] feat(rummikub): add rack reordering with insert indicator and improve layout - Implement drag-and-drop rack reordering with animated slot indicator showing where a tile will be inserted - Preserve manual rack ordering across turn resets and draw actions - Support dynamic table layout with proper margins for side opponents - Reposition player portraits, buttons, and UI elements for 3-4 player configurations - Add point value labels below valid committed sets containing staged tiles - Scale up tiles slightly when picked up for drag feedback --- public/src/games/rummikub/RummikubData.js | 15 +- public/src/games/rummikub/RummikubGame.js | 171 +++++++++++++++++++-- public/src/games/rummikub/RummikubLogic.js | 22 ++- 3 files changed, 187 insertions(+), 21 deletions(-) diff --git a/public/src/games/rummikub/RummikubData.js b/public/src/games/rummikub/RummikubData.js index acdcc4c..2b75578 100644 --- a/public/src/games/rummikub/RummikubData.js +++ b/public/src/games/rummikub/RummikubData.js @@ -90,17 +90,24 @@ export function buildTableLayout(playerCount, { width, height }) { }; // AI seats by count: 2p → top; 3p → top + left; 4p → top + left + right. const order = []; - if (playerCount >= 2) order.push({ seat: 1, x: cx, y: 150, dir: 'h', side: 'top' }); + if (playerCount >= 2) order.push({ seat: 1, x: cx, y: 200, dir: 'h', side: 'top' }); if (playerCount >= 3) order.push({ seat: 2, x: 150, y: 470, dir: 'v', side: 'left' }); if (playerCount >= 4) order.push({ seat: 3, x: width - 150, y: 470, dir: 'v', side: 'right' }); for (const o of order) seats[o.seat] = o; + // Side opponents at x≈150 (left) and x≈1770 (right) need clear space. + // Portrait ring + name text extends ~180px inward from each side opponent. + const lm = playerCount >= 3 ? 350 : 120; // left margin + const rm = playerCount >= 4 ? 350 : 120; // right margin + + const tableX = lm, tableY = 300, tableW = width - lm - rm, tableH = 430; + return { cx, seats, - pool: { x: cx, y: 232 }, // face-down draw pool anchor - // Central band where committed sets are laid out (top-left origin, wraps). - table: { x: 120, y: 300, w: width - 240, h: 430 }, + // Pool sits just outside the table band to the right, near the bottom edge. + pool: { x: tableX + tableW + 70, y: tableY + tableH - 20 }, + table: { x: tableX, y: tableY, w: tableW, h: tableH }, rack: { x: 120, y: height - 168, w: width - 240, h: 150 }, }; } diff --git a/public/src/games/rummikub/RummikubGame.js b/public/src/games/rummikub/RummikubGame.js index 31d4492..eebd9f0 100644 --- a/public/src/games/rummikub/RummikubGame.js +++ b/public/src/games/rummikub/RummikubGame.js @@ -51,6 +51,7 @@ export default class RummikubGame extends Phaser.Scene { this.busy = false; this.recorded = false; this.dragging = null; + this.rackDragState = null; this.originalTableIds = new Set(); this.tableObjs = []; @@ -67,7 +68,7 @@ export default class RummikubGame extends Phaser.Scene { this.buildBackdrop(); this.buildPortraits(); - this.statusText = this.add.text(GAME_WIDTH / 2, 264, '', { + this.statusText = this.add.text(GAME_WIDTH / 2, 780, '', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui).setShadow(0, 2, '#000', 6); @@ -106,7 +107,7 @@ export default class RummikubGame extends Phaser.Scene { fontFamily: 'Righteous', fontSize: '32px', color: HEX(THEME.brassHi), }).setOrigin(0.5, 0).setDepth(D.ui).setAlpha(0.6); - this.add.text(this.layout.table.x - 16, this.layout.table.y - 24, 'TABLE', { + this.add.text(this.layout.table.x - 16, this.layout.table.y - 34, 'TABLE', { fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, }).setOrigin(0, 1).setDepth(D.ui); } @@ -114,17 +115,26 @@ export default class RummikubGame extends Phaser.Scene { buildPortraits() { for (let seat = 0; seat < this.playerCount; seat++) { const a = this.layout.seats[seat]; - const px = a.side === 'bottom' ? 80 : a.side === 'top' ? 80 : a.x; - const py = a.side === 'bottom' ? a.y : a.side === 'top' ? a.y : a.y - 110; + const px = a.side === 'bottom' ? 80 : a.x; + const py = a.side === 'bottom' ? a.y : a.y - 80; this.portraits[seat] = seat === 0 ? createPlayerPortrait(this, px, py, 42, D.ui, 'RummikubGame') : createOpponentPortrait(this, this.seatOpp[seat], px, py, 42, D.ui, { playIntro: seat === 1 }); - this.add.text(px + 58, py - 8, this.seatName[seat].toUpperCase(), { - fontFamily: 'Righteous', fontSize: '18px', color: HEX(THEME.brassHi), - }).setOrigin(0, 0.5).setDepth(D.ui); - this.portraits[seat].countText = this.add.text(px + 58, py + 16, '', { - fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, - }).setOrigin(0, 0.5).setDepth(D.ui); + // Left/right opponents: name + count stacked above the portrait circle. + // Top/bottom opponents: name + count to the right of the portrait. + const sideOpponent = a.side === 'left' || a.side === 'right'; + this.add.text( + sideOpponent ? px : px + 58, + sideOpponent ? py - 48 : py - 8, + this.seatName[seat].toUpperCase(), + { fontFamily: 'Righteous', fontSize: '18px', color: HEX(THEME.brassHi) }, + ).setOrigin(sideOpponent ? 0.5 : 0, sideOpponent ? 1 : 0.5).setDepth(D.ui); + this.portraits[seat].countText = this.add.text( + sideOpponent ? px : px + 58, + sideOpponent ? py - 70 : py + 16, + '', + { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex }, + ).setOrigin(sideOpponent ? 0.5 : 0, sideOpponent ? 1 : 0.5).setDepth(D.ui); const ring = this.add.graphics().setDepth(D.ring).setVisible(false); ring.lineStyle(5, THEME.brassHi, 0.95); ring.strokeCircle(px, py, 48); this.portraits[seat].ring = ring; @@ -133,7 +143,7 @@ export default class RummikubGame extends Phaser.Scene { } buildButtons() { - const y = GAME_HEIGHT - 30; + const y = 840; const cx = GAME_WIDTH / 2; this.btn = {}; this.btn.play = new Button(this, cx - 300, y, 'Play', () => this.humanCommit(), @@ -144,7 +154,7 @@ export default class RummikubGame extends Phaser.Scene { { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); this.btn.sort = new Button(this, cx + 300, y, 'Sort', () => this.sortRack(), { width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui); - this.btn.leave = new Button(this, GAME_WIDTH - 100, 40, 'Leave', () => this.scene.start('GameMenu'), + this.btn.leave = new Button(this, GAME_WIDTH - 100, 140, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 150, height: 46, fontSize: 19 }).setDepth(D.ui); this.hideActionButtons(); } @@ -278,6 +288,15 @@ export default class RummikubGame extends Phaser.Scene { if (this.isHumanTurn()) this.makeDraggable(obj); this.tableObjs.push(obj); }); + + // Point value label below valid sets containing staged tiles. + if (this.isHumanTurn() && valid.valid && set.some((t) => !this.originalTableIds.has(t.id))) { + const label = this.add.text(rect.x + rect.w / 2, rect.y + rect.h + 4, `${valid.points} pts`, { + fontFamily: 'Righteous', fontSize: '14px', color: HEX(THEME.brassHi), + }).setOrigin(0.5, 0).setDepth(D.ui); + this.tableObjs.push(label); + } + cx += setW + setGap; }); @@ -350,12 +369,16 @@ export default class RummikubGame extends Phaser.Scene { obj.setDepth(D.drag); this.dragging = obj; playSound(this, SFX.PIECE_CLICK); + this.tweens.add({ targets: obj, scaleX: 1.06, scaleY: 1.06, duration: 120, ease: 'Cubic.easeOut' }); + if (obj._loc?.type === 'rack') this._initRackDragState(obj); }); - obj.on('drag', (p, dx, dy) => { obj.x = dx; obj.y = dy; this.pointer = p; }); + obj.on('drag', (p, dx, dy) => { obj.x = dx; obj.y = dy; this.pointer = p; this._onRackDragMove(obj, p); }); obj.on('dragend', (p) => this.onDrop(obj, p)); } onDrop(obj, pointer) { + const insertIndex = this.rackDragState?.insertIdx; + this._cleanupRackDragState(); this.dragging = null; const px = pointer.x, py = pointer.y; const tile = obj.tileRef; @@ -372,7 +395,7 @@ export default class RummikubGame extends Phaser.Scene { } if (!dest && py >= rack.y - 30) { if (this.originalTableIds.has(tile.id)) dest = null; // table tiles can't return to rack - else dest = { type: 'rack' }; + else dest = { type: 'rack', insertIndex: insertIndex ?? this.state.workingRack.length }; } if (dest) { @@ -382,6 +405,126 @@ export default class RummikubGame extends Phaser.Scene { this.renderAll(); } + // ── Rack insert-indicator helpers ───────────────────────────────────────────── + + _rackGridPos(slotIdx, totalSlots) { + const r = this.layout.rack; + const tileW = TW * RACK_SCALE + TILE_GAP; + const perRow = Math.max(1, Math.floor(r.w / tileW)); + const rows = Math.ceil(totalSlots / perRow) || 1; + const rowH = TH * RACK_SCALE + 8; + const row = Math.floor(slotIdx / perRow); + const slotsInRow = Math.min(perRow, totalSlots - row * perRow); + const col = slotIdx % perRow; + const rowW = slotsInRow * tileW; + const x = r.x + (r.w - rowW) / 2 + col * tileW + tileW / 2; + const y = r.y + (r.h - rows * rowH) / 2 + row * rowH + rowH / 2; + return { x, y }; + } + + _rackInsertIdx(px, py, totalSlots) { + let best = 0, bestDist = Infinity; + for (let k = 0; k < totalSlots; k++) { + const { x, y } = this._rackGridPos(k, totalSlots); + const d = (px - x) * (px - x) + (py - y) * (py - y); + if (d < bestDist) { bestDist = d; best = k; } + } + return best; + } + + _makeSlotIndicator(x, y) { + const g = this.add.graphics(); + g.fillStyle(THEME.brassHi, 0.15); + g.fillRoundedRect(-TW / 2 - 5, -TH / 2 - 5, TW + 10, TH + 10, TR + 2); + g.lineStyle(2.5, THEME.brassHi, 0.85); + g.strokeRoundedRect(-TW / 2 - 5, -TH / 2 - 5, TW + 10, TH + 10, TR + 2); + return g.setPosition(x, y).setDepth(8); + } + + _initRackDragState(obj) { + const initIdx = this.rackObjs.indexOf(obj); + const totalSlots = this.state.workingRack.length; + const pos = this._rackGridPos(Math.max(0, initIdx), totalSlots); + const indicator = this._makeSlotIndicator(pos.x, pos.y); + this.rackDragState = { insertIdx: initIdx, slotIndicator: indicator, isRackTile: true, visible: true }; + this._updateRackGap(obj, totalSlots); + } + + _onRackDragMove(obj, pointer) { + const rack = this.layout.rack; + const px = pointer.x, py = pointer.y; + const inRackZone = py >= rack.y - 40; + const isRackTile = obj._loc?.type === 'rack'; + + if (!inRackZone) { + if (this.rackDragState?.visible) { + this.rackDragState.slotIndicator.setAlpha(0); + this.rackDragState.visible = false; + this._settleRackTiles(obj); + } + return; + } + + const totalSlots = isRackTile + ? this.state.workingRack.length + : this.state.workingRack.length + 1; + + if (!this.rackDragState) { + const initIdx = isRackTile ? this.rackObjs.indexOf(obj) : this.state.workingRack.length; + const clamped = Math.max(0, Math.min(initIdx, totalSlots - 1)); + const pos = this._rackGridPos(clamped, totalSlots); + this.rackDragState = { + insertIdx: clamped, + slotIndicator: this._makeSlotIndicator(pos.x, pos.y), + isRackTile, + visible: true, + }; + } + + const ds = this.rackDragState; + const wasHidden = !ds.visible; + ds.visible = true; + ds.slotIndicator.setAlpha(1); + + const newInsertIdx = this._rackInsertIdx(px, py, totalSlots); + if (newInsertIdx !== ds.insertIdx || wasHidden) { + ds.insertIdx = newInsertIdx; + const pos = this._rackGridPos(newInsertIdx, totalSlots); + this.tweens.killTweensOf(ds.slotIndicator); + this.tweens.add({ targets: ds.slotIndicator, x: pos.x, y: pos.y, duration: 100, ease: 'Cubic.easeOut' }); + this._updateRackGap(obj, totalSlots); + } + } + + _updateRackGap(draggedObj, totalSlots) { + const insertIdx = this.rackDragState.insertIdx; + let k = 0; + for (const rackObj of this.rackObjs) { + if (rackObj === draggedObj) continue; + const targetSlot = k < insertIdx ? k : k + 1; + const pos = this._rackGridPos(targetSlot, totalSlots); + this.tweens.killTweensOf(rackObj); + this.tweens.add({ targets: rackObj, x: pos.x, y: pos.y, duration: 100, ease: 'Cubic.easeOut' }); + k++; + } + } + + _settleRackTiles(draggedObj) { + const others = this.rackObjs.filter((o) => o !== draggedObj); + others.forEach((rackObj, k) => { + const pos = this._rackGridPos(k, others.length); + this.tweens.killTweensOf(rackObj); + this.tweens.add({ targets: rackObj, x: pos.x, y: pos.y, duration: 100, ease: 'Cubic.easeOut' }); + }); + } + + _cleanupRackDragState() { + if (this.rackDragState) { + this.rackDragState.slotIndicator.destroy(); + this.rackDragState = null; + } + } + // ── Human actions ─────────────────────────────────────────────────────────--- humanCommit() { if (!this.isHumanTurn()) return; diff --git a/public/src/games/rummikub/RummikubLogic.js b/public/src/games/rummikub/RummikubLogic.js index 1712549..567f737 100644 --- a/public/src/games/rummikub/RummikubLogic.js +++ b/public/src/games/rummikub/RummikubLogic.js @@ -111,7 +111,9 @@ export function applyDrag(state, tileId, dest) { if (!tile) return state; // unknown tile if (dest.type === 'rack') { - workingRack.push(tile); + const idx = (dest.insertIndex !== undefined && dest.insertIndex >= 0) + ? Math.min(dest.insertIndex, workingRack.length) : workingRack.length; + workingRack.splice(idx, 0, tile); } else if (dest.type === 'newSet') { workingTable.push([tile]); } else if (dest.type === 'set') { @@ -133,10 +135,21 @@ export function stageAiPlan(state, plan) { export function resetTurn(state) { const ts = state.turnStart; + // Preserve manual rack ordering: tiles already in workingRack keep their + // current positions; tiles being returned from the table append at the end + // in their original turnStart order. + const rankInWorking = new Map(state.workingRack.map((t, i) => [t.id, i])); + const origRank = new Map(ts.rack.map((t, i) => [t.id, i])); + const n = ts.rack.length; + const resetRack = ts.rack.slice().sort((a, b) => { + const ai = rankInWorking.has(a.id) ? rankInWorking.get(a.id) : n + origRank.get(a.id); + const bi = rankInWorking.has(b.id) ? rankInWorking.get(b.id) : n + origRank.get(b.id); + return ai - bi; + }); return { ...state, workingTable: ts.table.map((s) => s.slice()), - workingRack: ts.rack.slice(), + workingRack: resetRack, drewThisTurn: false, commitError: null, }; @@ -230,8 +243,11 @@ export function drawTile(state) { const pool = state.pool.slice(); const tile = pool.shift(); + // Use workingRack so any manual reordering done before drawing is preserved; + // draw is only allowed when no tiles have been moved to the table, so + // workingRack has the same tile set as players[seat].rack. const players = state.players.map((p, i) => - i === seat ? { ...p, rack: [...state.players[seat].rack, tile] } : p); + i === seat ? { ...p, rack: [...state.workingRack, tile] } : p); const next = { ...state, pool, players, consecutivePasses: 0, drewThisTurn: true, commitError: null }; return advanceTurn(next); }