diff --git a/public/src/games/forbiddenisland/ForbiddenIslandGame.js b/public/src/games/forbiddenisland/ForbiddenIslandGame.js index a703c6e..b310ee1 100644 --- a/public/src/games/forbiddenisland/ForbiddenIslandGame.js +++ b/public/src/games/forbiddenisland/ForbiddenIslandGame.js @@ -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.