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:
parent
d5a12ae559
commit
a4c8f659ee
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue