Compare commits

...

4 Commits

Author SHA1 Message Date
Brian Fertig 6b819e00b0 add visual skip overlay for skipped opponents
- Create a red "X" overlay that animates onto the portrait of a skipped opponent
- Implement `showSkipOverlay` to render the effect using Phaser DOM objects
- Trigger the overlay when a player is skipped to provide clearer visual feedback
2026-05-19 20:17:23 -06:00
Brian Fertig d6588405a4 enhance uno game animations and emotional feedback
- Add opponent emotion reactions (upset/happy) for specific game events like skips, draws, and wild card plays
- Differentiate animation durations and effects between local player and opponents
- Implement showcase animation for opponent's played cards with a grow/shrink effect
- Adjust card fly and stagger timings for opponent animations to improve visual clarity
2026-05-19 20:01:22 -06:00
Brian Fertig 85dff94613 feat(uno): enable drag-and-drop playing of cards to discard pile
- Add visual highlight for discard zone when card is dragged over it
- Implement logic to detect valid plays against current discard pile
- Trigger play execution (including wild color picker) when dropped on discard
- Refactor `endCardDrag` to handle both reordering and playing paths
2026-05-19 19:35:51 -06:00
Brian Fertig f829ecf93b feat(uno): implement drag-to-sort for player hand
- Add `pointermove`, `pointerup`, and `pointerdown` handlers for smooth card dragging
- Implement visual feedback including card tilt, scale, and insertion slot indicators
- Update hand array order and re-render layout upon drop completion
- Refactor hover/click logic to support both interaction modes
2026-05-19 19:28:18 -06:00
1 changed files with 299 additions and 11 deletions

View File

@ -145,6 +145,10 @@ export default class UnoGame extends Phaser.Scene {
this.directionArrow = null;
this.colorSwatch = null;
this.pendingWildPlayCardId = null; // local: card chosen but awaiting color
this.localHandCards = [];
this.dragState = null;
this.potentialDrag = null;
}
create() {
@ -154,6 +158,7 @@ export default class UnoGame extends Phaser.Scene {
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.setupDragHandlers();
this.startNewMatch();
}
@ -565,6 +570,8 @@ export default class UnoGame extends Phaser.Scene {
? new Set(legalPlays(this.gs, 0))
: null;
if (seat === 0) this.localHandCards = [];
for (let i = 0; i < n; i++) {
const card = player.hand[i];
const offset = i - (n - 1) / 2;
@ -583,6 +590,7 @@ export default class UnoGame extends Phaser.Scene {
this.cardObjs.set(`hand-${seat}-${card.id}`, c);
if (seat === 0) {
this.localHandCards.push(c);
c.setInteractive(
new Phaser.Geom.Rectangle(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H),
Phaser.Geom.Rectangle.Contains,
@ -590,15 +598,16 @@ export default class UnoGame extends Phaser.Scene {
c.input.cursor = 'pointer';
const baseY = y;
c.on('pointerover', () => {
if (this.animating) return;
if (this.animating || this.dragState) return;
this.tweens.add({ targets: c, y: baseY - 14, duration: 120 });
c.setDepth(D.highlight);
});
c.on('pointerout', () => {
if (this.dragState) return;
this.tweens.add({ targets: c, y: baseY, duration: 120 });
c.setDepth(D.card);
});
c.on('pointerdown', () => this.onHandCardClick(card.id));
c.on('pointerdown', (pointer) => this.onHandPointerDown(i, pointer));
if (localLegal && !localLegal.has(card.id)) {
c.setAlpha(0.6);
}
@ -615,6 +624,230 @@ export default class UnoGame extends Phaser.Scene {
}
}
// ── Hand drag-to-sort ─────────────────────────────────────────────────────
setupDragHandlers() {
this.input.on('pointermove', (pointer) => {
if (this.potentialDrag && !this.dragState) {
const dx = pointer.x - this.potentialDrag.startX;
const dy = pointer.y - this.potentialDrag.startY;
if (Math.sqrt(dx * dx + dy * dy) >= 8) {
const pd = this.potentialDrag;
this.potentialDrag = null;
this.startCardDrag(pd.handIdx, pd.offsetX, pd.offsetY);
}
}
if (this.dragState) {
this.updateCardDrag(pointer);
}
});
this.input.on('pointerup', () => {
if (this.potentialDrag) {
const { cardId } = this.potentialDrag;
this.potentialDrag = null;
this.onHandCardClick(cardId);
return;
}
if (this.dragState) {
this.endCardDrag();
}
});
}
onHandPointerDown(handIdx, pointer) {
if (this.dragState || this.animating) return;
const card = this.localHandCards[handIdx];
if (!card) return;
const cardId = this.gs.players[0].hand[handIdx]?.id;
if (!cardId) return;
this.potentialDrag = {
handIdx,
cardId,
startX: pointer.x,
startY: pointer.y,
offsetX: pointer.x - card.x,
offsetY: pointer.y - card.y,
};
}
startCardDrag(handIdx, offsetX, offsetY) {
const card = this.localHandCards[handIdx];
if (!card) return;
const n = this.gs.players[0].hand.length;
const layout = slotLayout('bottom', this.gs.players.length);
const spread = this.handSpreadFor(0, n);
const handStartX = layout.handCenter.x - (n - 1) / 2 * spread;
const handCenterY = layout.handCenter.y;
const insertIdx = Phaser.Math.Clamp(Math.round((card.x - handStartX) / spread), 0, n - 1);
this.tweens.killTweensOf(card);
const shadow = this.add.graphics();
shadow.fillStyle(0x000000, 0.35);
shadow.fillEllipse(0, 0, CARD_W * 1.1, 28);
shadow.setPosition(card.x + 6, card.y + 16);
shadow.setDepth(D.card + 9);
this.transientObjs.push(shadow);
const slotIndicator = this.add.graphics();
slotIndicator.lineStyle(3, COLORS.accent, 0.75);
slotIndicator.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10);
slotIndicator.fillStyle(COLORS.accent, 0.1);
slotIndicator.fillRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10);
slotIndicator.setPosition(handStartX + insertIdx * spread, handCenterY);
slotIndicator.setDepth(D.card - 1);
this.transientObjs.push(slotIndicator);
const discardHighlight = this.add.graphics();
discardHighlight.lineStyle(3, COLORS.accent, 0.9);
discardHighlight.strokeRoundedRect(-CARD_W / 2 - 6, -CARD_H / 2 - 6, CARD_W + 12, CARD_H + 12, 12);
discardHighlight.fillStyle(COLORS.accent, 0.18);
discardHighlight.fillRoundedRect(-CARD_W / 2 - 6, -CARD_H / 2 - 6, CARD_W + 12, CARD_H + 12, 12);
discardHighlight.setPosition(DISCARD_POS.x, DISCARD_POS.y);
discardHighlight.setDepth(D.card + 1);
discardHighlight.setAlpha(0);
this.transientObjs.push(discardHighlight);
this.dragState = { cardIdx: handIdx, offsetX, offsetY, prevX: card.x, shadow, slotIndicator, discardHighlight, insertIdx, spread, handStartX, handCenterY, overDiscard: false };
card.setDepth(D.card + 10);
this.tweens.add({ targets: card, scaleX: 1.08, scaleY: 1.08, duration: 120, ease: 'Cubic.easeOut' });
}
updateCardDrag(pointer) {
const ds = this.dragState;
const card = this.localHandCards[ds.cardIdx];
const n = this.gs.players[0].hand.length;
card.x = pointer.x - ds.offsetX;
card.y = pointer.y - ds.offsetY;
ds.shadow.setPosition(card.x + 6, card.y + 16);
const tiltRad = Phaser.Math.Clamp((pointer.x - ds.prevX) * 0.008, -0.17, 0.17);
card.setRotation(tiltRad);
ds.prevX = pointer.x;
const overDiscard = this._isDiscardDropTarget(card.x, card.y);
if (overDiscard !== ds.overDiscard) {
ds.overDiscard = overDiscard;
if (overDiscard) {
this.tweens.killTweensOf(ds.slotIndicator);
ds.slotIndicator.setAlpha(0);
this._settleNonDraggedCards();
this.tweens.add({ targets: ds.discardHighlight, alpha: 1, duration: 150 });
} else {
ds.slotIndicator.setAlpha(1);
this._updateNonDraggedCards();
this.tweens.add({ targets: ds.discardHighlight, alpha: 0, duration: 150 });
}
}
if (!overDiscard) {
const newInsertIdx = Phaser.Math.Clamp(Math.round((card.x - ds.handStartX) / ds.spread), 0, Math.max(0, n - 1));
if (newInsertIdx !== ds.insertIdx) {
ds.insertIdx = newInsertIdx;
this.tweens.killTweensOf(ds.slotIndicator);
this.tweens.add({ targets: ds.slotIndicator, x: ds.handStartX + newInsertIdx * ds.spread, duration: 100, ease: 'Cubic.easeOut' });
this._updateNonDraggedCards();
}
}
}
_isDiscardDropTarget(cardX, cardY) {
if (!this.isLocalTurn() || this.gs.phase !== 'play') return false;
const me = this.gs.players[0];
const handCard = me.hand[this.dragState.cardIdx];
if (!handCard || !isLegalPlay(this.gs, handCard, me.hand)) return false;
return Math.abs(cardX - DISCARD_POS.x) < CARD_W * 1.2 && Math.abs(cardY - DISCARD_POS.y) < CARD_H * 1.2;
}
_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: ds.handStartX + k * ds.spread, rotation: 0, scaleX: 1, scaleY: 1, duration: 100, ease: 'Cubic.easeOut' });
k++;
}
}
_updateNonDraggedCards() {
const ds = this.dragState;
const n = this.gs.players[0].hand.length;
const others = [];
for (let i = 0; i < n; i++) { if (i !== ds.cardIdx) others.push(i); }
for (let k = 0; k < others.length; k++) {
const j = others[k];
const finalPos = k < ds.insertIdx ? k : k + 1;
const targetX = ds.handStartX + finalPos * ds.spread;
const distFromGap = Math.abs(finalPos - ds.insertIdx);
const leanDir = finalPos < ds.insertIdx ? -1 : 1;
const targetRot = leanDir * Math.max(0, 2 - distFromGap) * 0.04;
const targetScale = distFromGap <= 1 ? 0.95 : 1.0;
const cardObj = this.localHandCards[j];
this.tweens.killTweensOf(cardObj);
this.tweens.add({ targets: cardObj, x: targetX, rotation: targetRot, scaleX: targetScale, scaleY: targetScale, duration: 100, ease: 'Cubic.easeOut' });
}
}
endCardDrag() {
const ds = this.dragState;
const card = this.localHandCards[ds.cardIdx];
const hand = this.gs.players[0].hand;
const n = hand.length;
this.tweens.killTweensOf(ds.discardHighlight);
ds.discardHighlight.setAlpha(0);
// ── Play path: dropped on discard pile ───────────────────────────────────
if (ds.overDiscard) {
const handCard = hand[ds.cardIdx];
this.tweens.killTweensOf(card);
card.setScale(1);
card.setRotation(0);
ds.shadow.setAlpha(0);
this._settleNonDraggedCards();
this.dragState = null;
if (handCard.isWild) {
this.pendingWildPlayCardId = handCard.id;
this.openColorPicker((color) => {
this.pendingWildPlayCardId = null;
this.executeLocalPlay(handCard.id, color);
});
} else {
this.executeLocalPlay(handCard.id);
}
return;
}
// ── Reorder path: dropped back in hand ───────────────────────────────────
const finalIdx = Phaser.Math.Clamp(Math.round((card.x - ds.handStartX) / ds.spread), 0, n - 1);
this.tweens.killTweensOf(card);
this.tweens.add({ targets: card, x: ds.handStartX + finalIdx * ds.spread, y: ds.handCenterY, rotation: 0, scaleX: 1, scaleY: 1, duration: 220, ease: 'Back.easeOut' });
const others = [];
for (let i = 0; i < n; i++) { if (i !== ds.cardIdx) others.push(i); }
for (let k = 0; k < others.length; k++) {
const j = others[k];
const finalPos = k < finalIdx ? k : k + 1;
const cardObj = this.localHandCards[j];
this.tweens.killTweensOf(cardObj);
this.tweens.add({ targets: cardObj, x: ds.handStartX + finalPos * ds.spread, rotation: 0, scaleX: 1, scaleY: 1, duration: 120, ease: 'Cubic.easeOut' });
}
const [moved] = hand.splice(ds.cardIdx, 1);
hand.splice(finalIdx, 0, moved);
this.dragState = null;
this.time.delayedCall(240, () => this.renderAll());
}
renderTurnIndicator() {
const seat = this.gs.currentPlayer;
const slot = this.slotForSeat[seat];
@ -824,6 +1057,7 @@ export default class UnoGame extends Phaser.Scene {
const banner = this.formatChallengeBanner(last);
this.gs = after;
this.renderAll();
this.handleLogEffects(before, after);
if (banner) {
this.showBanner(banner);
this.time.delayedCall(1300, () => this.hideBanner());
@ -853,14 +1087,24 @@ export default class UnoGame extends Phaser.Scene {
for (let i = oldLen; i < after.log.length; i++) {
const e = after.log[i];
if (e.kind === 'skip') {
this.opponentPortraits[e.seat]?.playEmotion('upset');
this.showSkipOverlay(e.seat);
this.showBanner(`${this.opponentName(e.seat)} is skipped!`);
this.time.delayedCall(900, () => this.hideBanner());
} else if (e.kind === 'reverse') {
this.showBanner('Direction reversed.');
this.time.delayedCall(900, () => this.hideBanner());
} else if (e.kind === 'draw2') {
this.opponentPortraits[e.seat]?.playEmotion('upset');
this.showBanner(`${this.opponentName(e.seat)} draws 2 and is skipped.`);
this.time.delayedCall(1100, () => this.hideBanner());
} else if (e.kind === 'wild4Accept' || e.kind === 'wild4ChallengeLose' || e.kind === 'wild4ChallengeWin') {
this.opponentPortraits[e.seat]?.playEmotion('upset');
} else if (e.kind === 'play') {
const playedCard = before.players[e.seat]?.hand?.find((c) => c.id === e.cardId);
if (playedCard?.kind === 'wild4') {
this.opponentPortraits[e.seat]?.playEmotion('happy');
}
} else if (e.kind === 'win') {
// handled by endGame
}
@ -872,6 +1116,7 @@ export default class UnoGame extends Phaser.Scene {
animatePlayCard(seat, card, onComplete) {
const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length);
const handFaceUp = layout.handFaceUp;
const isOpponent = seat !== 0;
const fromSprite = this.cardObjs.get(`hand-${seat}-${card.id}`);
let sprite;
if (fromSprite) {
@ -891,16 +1136,25 @@ export default class UnoGame extends Phaser.Scene {
targets: sprite,
x: DISCARD_POS.x, y: DISCARD_POS.y,
rotation: 0,
duration: 360,
duration: isOpponent ? 600 : 360,
ease: 'Cubic.easeOut',
onComplete: () => {
if (!handFaceUp) this.renderUnoCardFace(sprite, card, true);
sprite.setDepth(D.card);
// Small bounce.
this.tweens.add({
targets: sprite, scaleX: 1.08, scaleY: 1.08, yoyo: true, duration: 90,
onComplete: () => onComplete && onComplete(),
});
if (isOpponent) {
// Showcase: grow then shrink over 1.5 s to spotlight the played card.
this.tweens.add({
targets: sprite, scaleX: 1.3, scaleY: 1.3,
duration: 750, ease: 'Sine.easeInOut', yoyo: true,
onComplete: () => onComplete && onComplete(),
});
} else {
// Small bounce for local player.
this.tweens.add({
targets: sprite, scaleX: 1.08, scaleY: 1.08, yoyo: true, duration: 90,
onComplete: () => onComplete && onComplete(),
});
}
},
});
}
@ -924,7 +1178,7 @@ export default class UnoGame extends Phaser.Scene {
targets: sprite,
x: dest.x, y: dest.y,
rotation: 0,
duration: 400,
duration: seat !== 0 ? 650 : 400,
ease: 'Cubic.easeIn',
onComplete: () => onComplete && onComplete(),
});
@ -946,6 +1200,9 @@ export default class UnoGame extends Phaser.Scene {
animateBatchDraw(seat, count, onComplete) {
if (count <= 0) { onComplete && onComplete(); return; }
const layout = slotLayout(this.slotForSeat[seat], this.gs.players.length);
const isOpponent = seat !== 0;
const flyDuration = isOpponent ? 480 : 320;
const stagger = isOpponent ? 170 : 110;
let remaining = count;
const fire = (i) => {
const sprite = this.makeUnoCardSprite(null, DRAW_POS.x, DRAW_POS.y, {
@ -957,7 +1214,7 @@ export default class UnoGame extends Phaser.Scene {
this.tweens.add({
targets: sprite,
x: layout.handCenter.x, y: layout.handCenter.y,
duration: 320,
duration: flyDuration,
ease: 'Cubic.easeIn',
onComplete: () => {
remaining -= 1;
@ -966,7 +1223,7 @@ export default class UnoGame extends Phaser.Scene {
});
};
for (let i = 0; i < count; i++) {
this.time.delayedCall(i * 110, () => fire(i));
this.time.delayedCall(i * stagger, () => fire(i));
}
}
@ -1003,6 +1260,7 @@ export default class UnoGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.banner + 2).setScale(0.2);
this.transientObjs.push(t);
playSound(this, SFX.CASINO_WIN);
this.opponentPortraits[seat]?.playEmotion('happy');
this.tweens.add({
targets: t, scaleX: 1, scaleY: 1, duration: 280, ease: 'Back.easeOut',
onComplete: () => {
@ -1014,6 +1272,36 @@ export default class UnoGame extends Phaser.Scene {
});
}
showSkipOverlay(skippedSeat) {
const slot = this.slotForSeat[skippedSeat];
const layout = slotLayout(slot, this.gs.players.length);
const { x: px, y: py, r: pr } = layout.portrait;
const r = pr + 8;
const size = r * 2;
const strokeW = Math.round(r * 0.09);
const slashW = Math.round(r * 0.13);
// Build as a DOM element so it renders above the portrait video layer.
const el = document.createElement('div');
el.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;background:transparent;border:${strokeW}px solid rgba(204,0,0,0.95);box-sizing:border-box;position:relative;overflow:hidden;`;
const slash = document.createElement('div');
slash.style.cssText = `position:absolute;width:${slashW}px;height:${Math.round(size * 1.1)}px;background:rgba(204,0,0,0.95);border-radius:${Math.round(slashW / 2)}px;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-45deg);`;
el.appendChild(slash);
const domObj = this.add.dom(DISCARD_POS.x, DISCARD_POS.y, el);
domObj.setDepth(D.banner);
domObj.setScale(0.2);
this.transientObjs.push(domObj);
this.tweens.add({
targets: domObj,
x: px, y: py,
scaleX: 1, scaleY: 1,
duration: 1500,
ease: 'Back.easeOut',
});
}
// ── Color picker modal ────────────────────────────────────────────────────
openColorPicker(onPick) {