Compare commits
4 Commits
3d5c49d325
...
6b819e00b0
| Author | SHA1 | Date |
|---|---|---|
|
|
6b819e00b0 | |
|
|
d6588405a4 | |
|
|
85dff94613 | |
|
|
f829ecf93b |
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue