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
This commit is contained in:
Brian Fertig 2026-06-14 19:33:28 -06:00
parent f2da9bfb20
commit 6927b148f2
3 changed files with 187 additions and 21 deletions

View File

@ -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 },
};
}

View File

@ -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;

View File

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