feat: enable drag-to-discard and drag-to-hit in Phase10

- Allow dragging local hand cards to discard pile or valid hit targets.
- Add visual feedback with action highlights for discard (red) and hit (gold) zones.
- Handle drag state transitions, including settling cards and animating to target.
- Support 'skip' card drag with immediate modal opening.
- Refactor drop target detection and group center calculation for reuse.
- Adjust portrait and button positions for better layout alignment.
This commit is contained in:
Brian Fertig 2026-05-17 13:15:51 -06:00
parent d5a12ae559
commit a4c8f659ee
1 changed files with 196 additions and 48 deletions

View File

@ -68,8 +68,8 @@ function slotLayout(slot) {
handFaceUp: true, handFaceUp: true,
laidStart: { x: 200, y: 820 }, laidStart: { x: 200, y: 820 },
laidAxis: 'x', laidAxis: 'x',
portrait: { x: 120, y: 990, r: 56 }, portrait: { x: 350, y: 990, r: 56 },
nameLabel: { x: 120, y: 1056 }, nameLabel: { x: 350, y: 1056 },
chip: { x: 752, y: 870 }, chip: { x: 752, y: 870 },
rotateCards: 0, rotateCards: 0,
}; };
@ -81,8 +81,8 @@ function slotLayout(slot) {
handFaceUp: false, handFaceUp: false,
laidStart: { x: 200, y: 200 }, laidStart: { x: 200, y: 200 },
laidAxis: 'x', laidAxis: 'x',
portrait: { x: 120, y: 70, r: 50 }, portrait: { x: 350, y: 70, r: 50 },
nameLabel: { x: 120, y: 134 }, nameLabel: { x: 350, y: 134 },
chip: { x: 752, y: 180 }, chip: { x: 752, y: 180 },
rotateCards: 180, rotateCards: 180,
}; };
@ -414,10 +414,10 @@ export default class Phase10Game extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui); }).setOrigin(0.5).setDepth(D.ui);
new Button(this, 110, GAME_HEIGHT - 50, 'Leave', () => this.scene.start('GameMenu'), { new Button(this, 55, GAME_HEIGHT - 20, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 40, fontSize: 18, variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui); }).setDepth(D.ui);
new Button(this, 110, GAME_HEIGHT - 100, 'New', () => this.startNewMatch(), { new Button(this, 55, GAME_HEIGHT - 60, 'New', () => this.startNewMatch(), {
variant: 'ghost', width: 110, height: 40, fontSize: 18, variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(D.ui); }).setDepth(D.ui);
@ -494,7 +494,7 @@ export default class Phase10Game extends Phaser.Scene {
slotIndicator.setDepth(D.card - 1); slotIndicator.setDepth(D.card - 1);
this.transientObjs.push(slotIndicator); this.transientObjs.push(slotIndicator);
this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx }; this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, insertIdx, dropTarget: null, actionHighlight: null };
card.setDepth(D.card + 10); card.setDepth(D.card + 10);
this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' }); this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' });
@ -513,6 +513,23 @@ export default class Phase10Game extends Phaser.Scene {
card.setRotation(tiltRad); card.setRotation(tiltRad);
ds.prevX = pointer.x; ds.prevX = pointer.x;
const newDropTarget = this._getDropTarget(card.x, card.y);
if (!this._dropTargetsEqual(newDropTarget, ds.dropTarget)) {
ds.dropTarget = newDropTarget;
this._updateActionHighlight(newDropTarget);
if (newDropTarget) {
// Entered a drop zone — hide slot indicator, settle hand to no-gap
this.tweens.killTweensOf(ds.slotIndicator);
ds.slotIndicator.setAlpha(0);
this._settleNonDraggedCards();
} else {
// Left drop zone — restore slot indicator and gap
ds.slotIndicator.setAlpha(1);
this._updateNonDraggedCards();
}
}
if (!newDropTarget) {
const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1);
if (newInsertIdx !== ds.insertIdx) { if (newInsertIdx !== ds.insertIdx) {
ds.insertIdx = newInsertIdx; ds.insertIdx = newInsertIdx;
@ -521,6 +538,7 @@ export default class Phase10Game extends Phaser.Scene {
this._updateNonDraggedCards(); this._updateNonDraggedCards();
} }
} }
}
_updateNonDraggedCards() { _updateNonDraggedCards() {
const ds = this.dragState; const ds = this.dragState;
@ -543,10 +561,66 @@ export default class Phase10Game extends Phaser.Scene {
} }
} }
_settleNonDraggedCards() {
const ds = this.dragState;
const n = this.gs.players[0].hand.length;
let k = 0;
for (let j = 0; j < n; j++) {
if (j === ds.cardIdx) continue;
const cardObj = this.localHandCards[j];
this.tweens.killTweensOf(cardObj);
this.tweens.add({ targets: cardObj, x: 460 + k * HAND_SPREAD, rotation: 0, scaleX: 1, scaleY: 1, duration: 100, ease: 'Cubic.easeOut' });
k++;
}
}
endCardDrag() { endCardDrag() {
const ds = this.dragState; const ds = this.dragState;
const card = this.localHandCards[ds.cardIdx]; const card = this.localHandCards[ds.cardIdx];
const n = this.gs.players[0].hand.length; const n = this.gs.players[0].hand.length;
const dropTarget = this._getDropTarget(card.x, card.y);
// ── Discard path ──────────────────────────────────────────────────────
if (dropTarget?.type === 'discard') {
const handIdx = ds.cardIdx;
const handCard = this.gs.players[0].hand[handIdx];
this.tweens.killTweensOf(card);
this._settleNonDraggedCards();
this.dragState = null;
if (handCard.value === 'skip') {
// Snap back to hand, then immediately open skip-target modal.
this.tweens.add({ targets: card, x: 460 + handIdx * HAND_SPREAD, y: 990, rotation: 0, scaleX: 1, scaleY: 1, duration: 140, ease: 'Back.easeOut' });
this.time.delayedCall(160, () => { this.renderAll(); this.openSkipTargetModal(handIdx); });
return;
}
this.tweens.add({
targets: card, x: DISCARD_POS.x, y: DISCARD_POS.y, rotation: 0, scaleX: 1, scaleY: 1,
duration: 160, ease: 'Cubic.easeOut',
onComplete: () => { this.selectedHandIdx = handIdx; this.commitDiscard(handIdx); },
});
return;
}
// ── Hit path ──────────────────────────────────────────────────────────
if (dropTarget?.type === 'hit') {
const handIdx = ds.cardIdx;
const target = { targetSeat: dropTarget.targetSeat, groupIdx: dropTarget.groupIdx, position: dropTarget.position };
const gc = this._getGroupCenter(target);
this.tweens.killTweensOf(card);
this._settleNonDraggedCards();
this.dragState = null;
this.tweens.add({
targets: card, x: gc ? gc.x : card.x, y: gc ? gc.y : card.y, rotation: 0, scaleX: 1, scaleY: 1,
duration: 160, ease: 'Cubic.easeOut',
onComplete: () => { this.selectedHandIdx = handIdx; this.commitHit(target); },
});
return;
}
// ── Reorder path (existing) ───────────────────────────────────────────
const finalIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1); const finalIdx = Phaser.Math.Clamp(Math.round((card.x - 460) / HAND_SPREAD), 0, n - 1);
this.selectedHandIdx = null; this.selectedHandIdx = null;
@ -572,6 +646,109 @@ export default class Phase10Game extends Phaser.Scene {
this.time.delayedCall(240, () => this.renderAll()); this.time.delayedCall(240, () => this.renderAll());
} }
_dropTargetsEqual(a, b) {
if (!a && !b) return true;
if (!a || !b) return false;
if (a.type !== b.type) return false;
if (a.type === 'hit') return a.targetSeat === b.targetSeat && a.groupIdx === b.groupIdx && a.position === b.position;
return true;
}
_getDropTarget(cardX, cardY) {
if (!this.isLocalTurn() || !this.gs.drawnThisTurn) return null;
const handCard = this.gs.players[0].hand[this.dragState.cardIdx];
// Discard pile — generous hit area
if (Math.abs(cardX - DISCARD_POS.x) < CARD_W && Math.abs(cardY - DISCARD_POS.y) < CARD_H) {
return { type: 'discard' };
}
// Hit targets — only when the local player has already laid down
if (this.gs.players[0].laidDown) {
const targets = getHitTargets(this.gs, handCard);
// Bucket by group so runs with both low+high positions are handled together.
const byGroup = new Map();
for (const t of targets) {
const key = `${t.targetSeat}:${t.groupIdx}`;
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key).push(t);
}
for (const groupTargets of byGroup.values()) {
const gc = this._getGroupCenter(groupTargets[0]);
if (!gc) continue;
if (Math.abs(cardX - gc.x) < gc.w / 2 + 20 && Math.abs(cardY - gc.y) < gc.h / 2 + 20) {
if (groupTargets.length === 1) return { type: 'hit', ...groupTargets[0] };
// Run with both low and high: pick based on which side the card is on.
const low = groupTargets.find((t) => t.position === 'low');
const high = groupTargets.find((t) => t.position === 'high');
const chosen = (low && cardX < gc.x) ? low : (high ?? low);
return { type: 'hit', ...chosen };
}
}
}
return null;
}
_getGroupCenter(target) {
const slot = this.slotForSeat[target.targetSeat];
const layout = slotLayout(slot);
const laid = this.gs.players[target.targetSeat].laidDown;
if (!laid) return null;
let cursorX = layout.laidStart.x;
let cursorY = layout.laidStart.y;
for (let gi = 0; gi < laid.length; gi++) {
const group = laid[gi];
const N = group.cards.length;
const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
if (gi === target.groupIdx) {
if (layout.laidAxis === 'x') {
const cx = cursorX + groupW / 2;
return { x: cx, y: cursorY, w: groupW + 6, h: LAIDOWN_CARD_H + 8, lowX: cursorX - 8, highX: cursorX + groupW + 8, axis: 'x' };
} else {
const groupH = N * (LAIDOWN_CARD_H * 0.5) + (LAIDOWN_CARD_H * 0.5);
return { x: cursorX, y: cursorY + groupH / 2, w: LAIDOWN_CARD_W + 8, h: groupH + 6, axis: 'y' };
}
}
if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP;
else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP;
}
return null;
}
_updateActionHighlight(dropTarget) {
const ds = this.dragState;
if (ds.actionHighlight) {
const idx = this.transientObjs.indexOf(ds.actionHighlight);
if (idx !== -1) this.transientObjs.splice(idx, 1);
ds.actionHighlight.destroy();
ds.actionHighlight = null;
}
if (!dropTarget) return;
const g = this.add.graphics();
if (dropTarget.type === 'discard') {
const px = DISCARD_POS.x - CARD_W / 2 - 8;
const py = DISCARD_POS.y - CARD_H / 2 - 8;
g.lineStyle(4, 0xff6b6b, 1);
g.strokeRoundedRect(px, py, CARD_W + 16, CARD_H + 16, 10);
g.fillStyle(0xff6b6b, 0.18);
g.fillRoundedRect(px, py, CARD_W + 16, CARD_H + 16, 10);
} else if (dropTarget.type === 'hit') {
const gc = this._getGroupCenter(dropTarget);
if (gc) {
g.lineStyle(4, 0xffd700, 1);
g.strokeRoundedRect(gc.x - gc.w / 2, gc.y - gc.h / 2, gc.w, gc.h, 8);
g.fillStyle(0xffd700, 0.22);
g.fillRoundedRect(gc.x - gc.w / 2, gc.y - gc.h / 2, gc.w, gc.h, 8);
}
}
g.setDepth(D.highlight);
ds.actionHighlight = g;
this.transientObjs.push(g);
}
endCardDragImmediate() { endCardDragImmediate() {
if (!this.dragState) return; if (!this.dragState) return;
const ds = this.dragState; const ds = this.dragState;
@ -966,46 +1143,17 @@ export default class Phase10Game extends Phaser.Scene {
} }
hitTargetHighlight(target) { hitTargetHighlight(target) {
const slot = this.slotForSeat[target.targetSeat]; const gc = this._getGroupCenter(target);
const layout = slotLayout(slot); if (!gc) return null;
const player = this.gs.players[target.targetSeat];
const laid = player.laidDown;
if (!laid) return null;
// Replicate cursor walk from renderLaidDown to find this group's centroid.
let cursorX = layout.laidStart.x;
let cursorY = layout.laidStart.y;
let groupCenter = null;
let lowX = 0, highX = 0;
for (let gi = 0; gi < laid.length; gi++) {
const group = laid[gi];
const N = group.cards.length;
const groupW = N * (LAIDOWN_CARD_W * 0.75) + (LAIDOWN_CARD_W * 0.25);
if (gi === target.groupIdx) {
if (layout.laidAxis === 'x') {
const cx = cursorX + groupW / 2;
groupCenter = { x: cx, y: cursorY, w: groupW + 6, h: LAIDOWN_CARD_H + 8 };
lowX = cursorX - 8;
highX = cursorX + groupW + 8;
} else {
const groupH = N * (LAIDOWN_CARD_H * 0.5) + (LAIDOWN_CARD_H * 0.5);
groupCenter = { x: cursorX, y: cursorY + groupH / 2, w: LAIDOWN_CARD_W + 8, h: groupH + 6 };
}
break;
}
if (layout.laidAxis === 'x') cursorX += groupW + GROUP_GAP;
else cursorY += N * (LAIDOWN_CARD_H * 0.5) + GROUP_GAP;
}
if (!groupCenter) return null;
// For runs with position, draw a small marker on the relevant end too.
const seatColor = 0xffd700; const seatColor = 0xffd700;
const rect = this.add.rectangle(groupCenter.x, groupCenter.y, groupCenter.w, groupCenter.h, seatColor, 0.12) const rect = this.add.rectangle(gc.x, gc.y, gc.w, gc.h, seatColor, 0.12)
.setStrokeStyle(2, seatColor, 0.9).setDepth(D.highlight) .setStrokeStyle(2, seatColor, 0.9).setDepth(D.highlight)
.setInteractive({ useHandCursor: true }); .setInteractive({ useHandCursor: true });
rect.on('pointerdown', () => this.commitHit(target)); rect.on('pointerdown', () => this.commitHit(target));
if (target.position && layout.laidAxis === 'x') { if (target.position && gc.axis === 'x') {
const tip = this.add.text(target.position === 'low' ? lowX : highX, groupCenter.y, target.position === 'low' ? '◀' : '▶', { const tipX = target.position === 'low' ? gc.lowX : gc.highX;
const tip = this.add.text(tipX, gc.y, target.position === 'low' ? '◀' : '▶', {
fontFamily: 'Righteous', fontSize: '20px', color: '#ffd700', fontFamily: 'Righteous', fontSize: '20px', color: '#ffd700',
}).setOrigin(0.5).setDepth(D.highlight); }).setOrigin(0.5).setDepth(D.highlight);
this.transientObjs.push(tip); this.transientObjs.push(tip);