Improve trade modal layout and card draw animations

- Make trade modal panel height dynamic; wrap hand cards to second row if >4
- Refactor card fly animations to use Containers for correct border scaling
- Animate AI partner card placement to HUD thumbnails
- Add Waters Rise animation: card flies to meter, triggers water rise with segment fade and marker slide
This commit is contained in:
Brian Fertig 2026-06-06 12:12:45 -06:00
parent 49761bf264
commit 9d8366ac5b
1 changed files with 114 additions and 28 deletions

View File

@ -911,14 +911,20 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
if (this.tradeModalObjs.length) return; // already open
const reg = (o) => { this.tradeModalObjs.push(o); return o; };
const panelX = 160, panelY = 330, panelW = 1600, panelH = 400;
const panelX = 160, panelY = 310, panelW = 1600;
const cx = GAME_WIDTH / 2;
const portR = 36;
const portCY = panelY + 60;
// Push portrait below the title/instruction block (~panelY+75) with a clear gap
const portCY = panelY + 130;
const nameY = portCY + portR + 14;
const roleY = nameY + 22;
const cardsTop = roleY + 30;
const cardW = 70, cardH = 95, cardGap = 6;
const CARDS_PER_ROW = 4;
const hasSecondRow = this.gs.players.some((p) => p.hand.length > CARDS_PER_ROW);
// Panel height: one row of cards, or two if any hand overflows
const cardsAreaH = hasSecondRow ? cardH * 2 + cardGap : cardH;
const panelH = (cardsTop - panelY) + cardsAreaH + 24;
const hasArt = this.textures.exists('forbiddenisland-cards');
const N = this.gs.players.length;
@ -1003,15 +1009,17 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: role.colorHex,
}).setDepth(DEPTH.popup + 3));
// Hand cards
// Hand cards (wrap to second row after CARDS_PER_ROW)
if (!player.hand.length) {
reg(this.add.text(textX, cardsTop + cardH / 2, 'No cards', {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.popup + 3));
} else {
player.hand.forEach((card, ci) => {
const wx = textX + ci * (cardW + cardGap) + cardW / 2;
const wy = cardsTop + cardH / 2;
const row = ci < CARDS_PER_ROW ? 0 : 1;
const colIdx = ci < CARDS_PER_ROW ? ci : ci - CARDS_PER_ROW;
const wx = textX + colIdx * (cardW + cardGap) + cardW / 2;
const wy = cardsTop + row * (cardH + cardGap) + cardH / 2;
const frame = hasArt ? cardFrame(card) : null;
let cardObj;
@ -1436,72 +1444,103 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
onComplete: () => {
back.destroy();
// Create face-up card at center
let faceObj;
// Create face-up card Container (image/vector + border) at center.
// Using a Container means the border travels and scales with the card
// for all three fly destinations (human hand, AI HUD, chat fade).
let faceContent;
if (frame != null) {
faceObj = this.add.image(cx, cy, 'forbiddenisland-cards', frame)
.setScale(SX_DRAW, SY_DRAW)
.setDepth(depth);
const img = this.add.image(0, 0, 'forbiddenisland-cards', frame).setScale(SX_DRAW, SY_DRAW);
const borderGfx = this.add.graphics();
borderGfx.lineStyle(3, 0xd4c08a, 1);
borderGfx.strokeRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 12);
faceContent = [img, borderGfx];
} else {
// Vector fallback
const info = cardInfo(card);
const g = this.add.graphics().setDepth(depth);
const g = this.add.graphics();
g.fillStyle(info.color, 1);
g.fillRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 16);
g.lineStyle(3, 0xffffff, 0.6);
g.lineStyle(3, 0xd4c08a, 1);
g.strokeRoundedRect(-DRAW_W / 2, -DRAW_H / 2, DRAW_W, DRAW_H, 16);
this.add.text(cx, cy, info.label, {
const lbl = this.add.text(0, 0, info.label, {
fontFamily: '"Julius Sans One"', fontSize: '28px', color: info.text, align: 'center',
}).setOrigin(0.5).setDepth(depth + 1);
faceObj = g;
}).setOrigin(0.5);
faceContent = [g, lbl];
}
const faceObj = this.add.container(cx, cy, faceContent).setDepth(depth);
faceObj.scaleX = 0;
if (this.cache.audio.exists('sfx-card-show')) this.sound.play('sfx-card-show', { volume: 0.35 });
// Flip open
// Flip open — container collapses to scaleX=0 then reopens to 1
this.tweens.add({
targets: faceObj,
scaleX: frame != null ? SX_DRAW : 1,
scaleX: 1,
duration: 150,
ease: 'Linear',
onComplete: () => {
// 4. Pause 1.2 seconds
this.time.delayedCall(1200, () => {
if (isHuman && handSlot >= 0) {
// 5a. Human player — fly to hand slot, keep image visible until renderHand replaces it
// 5a. Human player — fly to hand slot; park container so renderHand can destroy it
const tx = RAIL_X + 8 + handSlot * (HAND_W + 10) + HAND_W / 2;
const ty = 884 + HAND_H / 2;
// Container scale needed to render at HAND_W × HAND_H from DRAW_W × DRAW_H content
const csX = HAND_W / DRAW_W, csY = HAND_H / DRAW_H;
this.tweens.add({
targets: faceObj,
x: tx, y: ty,
scaleX: SX_HAND,
scaleY: SY_HAND,
scaleX: csX, scaleY: csY,
duration: 350,
ease: 'Cubic.easeIn',
onComplete: () => {
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.3 });
// Park the image at the hand slot; renderHand will destroy it when re-rendering
this.tempHandImages.push(faceObj);
onDone();
},
});
} else {
// 5b. AI player or Waters Rise — shrink and fade toward chat panel
const chatCX = RAIL_X + RAIL_W / 2;
const chatCY = 248 + 235;
} else if (!isHuman && handSlot >= 0 && this.partnerCardSlots[seat]) {
// 5b. AI player drawing a hand card — fly to partner HUD thumbnail slot
const slot = this.partnerCardSlots[seat];
const HUD_W = 24, HUD_H = 33, HUD_GAP = 3;
const tx = slot.cardX + handSlot * (HUD_W + HUD_GAP) + HUD_W / 2;
const ty = slot.cardY + HUD_H / 2;
// Container scale to render at HUD thumbnail size from DRAW content size
const csX = HUD_W / DRAW_W, csY = HUD_H / DRAW_H;
this.tweens.add({
targets: faceObj,
x: chatCX, y: chatCY,
scaleX: 0.05, scaleY: 0.05,
alpha: 0,
x: tx, y: ty,
scaleX: csX, scaleY: csY,
duration: 380,
ease: 'Cubic.easeIn',
onComplete: () => {
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.2 });
faceObj.destroy();
onDone();
},
});
} else if (card === SPECIAL.WATERS_RISE && this.waterX != null) {
// 5c. Waters Rise — card flies to the next unfilled meter segment, then level rises
const oldLevel = this.gs.waterLevel;
const newLevel = Math.min(oldLevel + 1, MAX_WATER);
const mSegH = (this.waterBottom - this.waterTop) / MAX_WATER;
const targetX = this.waterX;
const targetY = this.waterTop + (MAX_WATER - newLevel) * mSegH + mSegH / 2;
this.tweens.add({
targets: faceObj,
x: targetX, y: targetY,
scaleX: 0.12, scaleY: 0.08,
duration: 500,
ease: 'Cubic.easeIn',
onComplete: () => {
faceObj.destroy();
this.animateWaterRise(oldLevel, onDone);
},
});
} else {
// Fallback — destroy and continue
faceObj.destroy();
onDone();
}
});
},
@ -1512,6 +1551,53 @@ export default class ForbiddenIslandGame extends Phaser.Scene {
});
}
// Animate the water level rising: fade in the new segment, slide the triangle marker up.
animateWaterRise(oldLevel, onDone) {
const D = DEPTH.ui + 2;
const newLevel = Math.min(oldLevel + 1, MAX_WATER);
const segH = (this.waterBottom - this.waterTop) / MAX_WATER;
const w = 40, barX = this.waterX - w / 2;
const segColor = newLevel >= MAX_WATER ? 0xb22a2a
: newLevel >= 8 ? 0xd1632f
: 0x2f8fd0;
// New-level segment overlay fades in from transparent
const newSegY = this.waterTop + (MAX_WATER - newLevel) * segH;
const segG = this.add.graphics().setDepth(D);
segG.fillStyle(segColor, 1);
segG.fillRoundedRect(barX, newSegY + 2, w, segH - 4, 6);
segG.lineStyle(1, 0x000000, 0.4);
segG.strokeRoundedRect(barX, newSegY + 2, w, segH - 4, 6);
segG.alpha = 0;
// Gold triangle marker slides from old-level center to new-level center
const oldMarkerY = this.waterTop + (MAX_WATER - oldLevel) * segH + segH / 2;
const newMarkerY = this.waterTop + (MAX_WATER - newLevel) * segH + segH / 2;
const markG = this.add.graphics().setDepth(D + 1);
markG.fillStyle(COLORS.gold, 1);
markG.fillTriangle(-12, -8, -12, 8, -2, 0);
markG.x = barX;
markG.y = oldMarkerY;
if (this.cache.audio.exists('sfx-card-place')) this.sound.play('sfx-card-place', { volume: 0.5 });
// Marker slides up quickly, segment fades in slowly
this.tweens.add({
targets: markG, y: newMarkerY, duration: 600, ease: 'Cubic.easeOut',
onComplete: () => markG.destroy(),
});
this.tweens.add({
targets: segG, alpha: 1, duration: 900, ease: 'Cubic.easeOut',
onComplete: () => {
// Call onDone first (triggers endActions + render → drawWaterMeter redraws the
// filled segment on waterG), then destroy the overlay so there's no flash.
onDone();
segG.destroy();
},
});
}
aiTurn(seat) {
this.busy = true;
// Announce the plan.