feat(forbiddenisland): overhaul UI, animations, and card trading mechanics
- Introduce animated intro sequence, player role intros, and deck count displays. - Replace direct treasure giving with a new Trade Modal for swapping cards between players. - Add smooth Phaser animations for pawn movement, shore-ups, flood/treasure card draws, and card swaps. - Improve state management during animations using immutable cloning and peek functions (`peekFloodDraw`, `peekTreasureDraw`). - Update role definitions with detailed descriptions and remove 'messenger' from `ROLE_KEYS`. - Change default difficulty to 'novice'.
This commit is contained in:
parent
9cb05f5f44
commit
49761bf264
Binary file not shown.
|
Before Width: | Height: | Size: 734 KiB After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -132,7 +132,7 @@ export function evalState(state, seat) {
|
|||
// Card logistics: other holders should converge on the carrier to hand
|
||||
// their matching cards over (the Messenger can do it from anywhere).
|
||||
for (const o of state.players) {
|
||||
if (o.seat === cs || o.role === 'messenger') continue;
|
||||
if (o.seat === cs) continue;
|
||||
const oc = handTreasureCounts(o)[key];
|
||||
if (oc > 0) v -= bfsDist(state, o.tileId, new Set([carrierTile])) * 5 * oc * focus;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,15 +70,21 @@ export const TILES = [
|
|||
// The six adventurer roles. `power` is read by the engine/AI to branch special
|
||||
// abilities; `color` is the pawn colour.
|
||||
export const ROLES = {
|
||||
pilot: { key: 'pilot', name: 'Pilot', color: 0x4a90d9, colorHex: '#4a90d9', power: 'Once per turn, fly to any tile.' },
|
||||
engineer: { key: 'engineer', name: 'Engineer', color: 0xd0473a, colorHex: '#d0473a', power: 'Shore up two tiles for one action.' },
|
||||
messenger: { key: 'messenger', name: 'Messenger', color: 0xb9bfc6, colorHex: '#b9bfc6', power: 'Give Treasure cards to anyone, any distance.' },
|
||||
navigator: { key: 'navigator', name: 'Navigator', color: 0xe2b53c, colorHex: '#e2b53c', power: 'Move another adventurer up to 2 tiles.' },
|
||||
diver: { key: 'diver', name: 'Diver', color: 0x2b2b30, colorHex: '#2b2b30', power: 'Swim through flooded and missing tiles.' },
|
||||
explorer: { key: 'explorer', name: 'Explorer', color: 0x49a25a, colorHex: '#49a25a', power: 'Move and shore up diagonally.' },
|
||||
pilot: { key: 'pilot', name: 'Pilot', color: 0x4a90d9, colorHex: '#4a90d9', power: 'Once per turn, fly to any tile.',
|
||||
description: 'A fearless aviator who has logged more hours above the clouds than on solid ground. When the island is too treacherous to cross on foot, the Pilot takes to the skies — once per turn they may spend one action to fly directly to any tile on the island, no matter how far.' },
|
||||
engineer: { key: 'engineer', name: 'Engineer', color: 0xd0473a, colorHex: '#d0473a', power: 'Shore up two tiles for one action.',
|
||||
description: 'A meticulous builder who arrived on the island with nothing but a toolkit and a plan. While others spend two actions to shore up two separate tiles, the Engineer patches them both in a single action — efficiency born of hard-won experience.' },
|
||||
messenger: { key: 'messenger', name: 'Messenger', color: 0xb9bfc6, colorHex: '#b9bfc6', power: 'Give Treasure cards to anyone, any distance.',
|
||||
description: 'A seasoned courier who has crossed every terrain imaginable to deliver what matters most. Unlike other adventurers who must stand side-by-side to exchange cards, the Messenger can hand off Treasure cards to any teammate — regardless of where they are on the island.' },
|
||||
navigator: { key: 'navigator', name: 'Navigator', color: 0xe2b53c, colorHex: '#e2b53c', power: 'Move another adventurer up to 2 tiles.',
|
||||
description: 'A brilliant strategist who can read the island like a map. The Navigator does not just chart their own path — each turn they can spend actions to move another adventurer up to two tiles, positioning teammates exactly where the team needs them most.' },
|
||||
diver: { key: 'diver', name: 'Diver', color: 0x2b2b30, colorHex: '#2b2b30', power: 'Swim through flooded and missing tiles.',
|
||||
description: 'A deep-sea explorer who is equally at home beneath the surface as above it. Where the rising waters block every other adventurer, the Diver simply slips beneath the waves — swimming through flooded and even completely sunken tiles without a second thought.' },
|
||||
explorer: { key: 'explorer', name: 'Explorer', color: 0x49a25a, colorHex: '#49a25a', power: 'Move and shore up diagonally.',
|
||||
description: 'A nimble trailblazer who has mapped uncharted territory on six continents. The Explorer refuses to be boxed in — while others are limited to the four cardinal directions, this adventurer can move and shore up tiles diagonally, opening paths no one else can take.' },
|
||||
};
|
||||
|
||||
export const ROLE_KEYS = ['pilot', 'engineer', 'messenger', 'navigator', 'diver', 'explorer'];
|
||||
export const ROLE_KEYS = ['pilot', 'engineer', 'navigator', 'diver', 'explorer'];
|
||||
|
||||
// Special (non-treasure) Treasure-deck cards.
|
||||
export const SPECIAL = {
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ function makeRng(state) {
|
|||
}
|
||||
|
||||
// ---- construction ----------------------------------------------------------
|
||||
export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0 } = {}) {
|
||||
const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'messenger', 'navigator'];
|
||||
export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0, skipInitialFlood = false } = {}) {
|
||||
const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'navigator', 'diver'];
|
||||
const s = {
|
||||
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
|
||||
rngCursor: 0,
|
||||
|
|
@ -111,12 +111,17 @@ export function createInitialState({ roles, difficulty = 'normal', seed, humanSe
|
|||
}
|
||||
s.treasureDeck = deck;
|
||||
|
||||
// Flood deck — shuffle one card per tile; flood the first 6.
|
||||
// Flood deck — shuffle one card per tile; flood the first 6 (or defer for animation).
|
||||
s.floodDeck = shuffle(Object.keys(s.tiles), rng.next);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const id = s.floodDeck.shift();
|
||||
s.tiles[id].state = 'flooded';
|
||||
s.floodDiscard.push(id);
|
||||
if (skipInitialFlood) {
|
||||
s.pendingFlood = s.floodDeck.splice(0, 6);
|
||||
} else {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const id = s.floodDeck.shift();
|
||||
s.tiles[id].state = 'flooded';
|
||||
s.floodDiscard.push(id);
|
||||
}
|
||||
s.pendingFlood = [];
|
||||
}
|
||||
|
||||
rng.commit();
|
||||
|
|
@ -124,6 +129,58 @@ export function createInitialState({ roles, difficulty = 'normal', seed, humanSe
|
|||
return s;
|
||||
}
|
||||
|
||||
// Finalise deferred initial flood after the animation completes.
|
||||
export function applyPendingFlood(state) {
|
||||
const s = cloneState(state);
|
||||
for (const id of (s.pendingFlood ?? [])) {
|
||||
s.tiles[id].state = 'flooded';
|
||||
s.floodDiscard.push(id);
|
||||
}
|
||||
s.pendingFlood = [];
|
||||
return s;
|
||||
}
|
||||
|
||||
// Preview which tile IDs the next flood phase will draw (in order), without
|
||||
// modifying state. Uses the same RNG seeding as resolveFlood so reshuffles match.
|
||||
export function peekFloodDraw(state) {
|
||||
const count = floodDrawCount(state.waterLevel);
|
||||
let deck = state.floodDeck.slice();
|
||||
let discard = state.floodDiscard.slice();
|
||||
const rng = makeRng(state);
|
||||
const ids = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (deck.length === 0) {
|
||||
if (discard.length === 0) break;
|
||||
deck = shuffle(discard, rng.next);
|
||||
discard = [];
|
||||
}
|
||||
ids.push(deck.shift());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// Preview all cards that will be drawn from the treasure deck during endActions
|
||||
// (including any Waters Rise cards, which don't count toward the draw-2 quota).
|
||||
// Uses the same RNG seeding as endActions so reshuffles match exactly.
|
||||
export function peekTreasureDraw(state) {
|
||||
let deck = state.treasureDeck.slice();
|
||||
let discard = state.treasureDiscard.slice();
|
||||
const rng = makeRng(state);
|
||||
const cards = [];
|
||||
let drawn = 0;
|
||||
while (drawn < 2) {
|
||||
if (deck.length === 0) {
|
||||
if (discard.length === 0) break;
|
||||
deck = shuffle(discard, rng.next);
|
||||
discard = [];
|
||||
}
|
||||
const card = deck.shift();
|
||||
cards.push(card);
|
||||
if (card !== SPECIAL.WATERS_RISE) drawn++;
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function cloneState(state) {
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -138,6 +195,7 @@ export function cloneState(state) {
|
|||
saveTiles: state.priorities.saveTiles.slice(),
|
||||
},
|
||||
pendingSwim: state.pendingSwim ? { ...state.pendingSwim, options: state.pendingSwim.options.slice() } : null,
|
||||
pendingFlood: (state.pendingFlood ?? []).slice(),
|
||||
log: state.log.slice(),
|
||||
};
|
||||
}
|
||||
|
|
@ -229,15 +287,6 @@ export function legalActions(state, seat) {
|
|||
out.push({ type: 'shoreUp', seat, tiles: [shoreable[i], shoreable[j]] });
|
||||
}
|
||||
|
||||
// Give a Treasure card — to a player on the same tile (Messenger: anyone).
|
||||
const treasureCards = [...new Set(p.hand.filter((c) => c.startsWith('treasure:')))];
|
||||
for (const other of state.players) {
|
||||
if (other.seat === seat) continue;
|
||||
const reachable = p.role === 'messenger' || other.tileId === p.tileId;
|
||||
if (!reachable) continue;
|
||||
for (const card of treasureCards) out.push({ type: 'giveCard', seat, toSeat: other.seat, card });
|
||||
}
|
||||
|
||||
// Capture a treasure.
|
||||
const tile = state.tiles[p.tileId];
|
||||
if (tile.treasure && !p.captured[tile.treasure]) {
|
||||
|
|
@ -285,14 +334,6 @@ export function applyAction(state, seat, action) {
|
|||
for (const id of action.tiles) if (isFlooded(s, id)) s.tiles[id].state = 'dry';
|
||||
s.log.push({ kind: 'shoreUp', seat, tiles: action.tiles.slice() });
|
||||
break;
|
||||
case 'giveCard': {
|
||||
const idx = p.hand.indexOf(action.card);
|
||||
if (idx < 0) return state;
|
||||
p.hand.splice(idx, 1);
|
||||
s.players[action.toSeat].hand.push(action.card);
|
||||
s.log.push({ kind: 'giveCard', seat, toSeat: action.toSeat, card: action.card });
|
||||
break;
|
||||
}
|
||||
case 'capture': {
|
||||
let removed = 0;
|
||||
p.hand = p.hand.filter((c) => {
|
||||
|
|
@ -338,6 +379,22 @@ export function playHelicopter(state, seat, pawnSeats, destTileId) {
|
|||
return s;
|
||||
}
|
||||
|
||||
// Swap one card between two players — free action (no action cost, no tile check).
|
||||
export function swapCards(state, seatA, cardA, seatB, cardB) {
|
||||
if (seatA === seatB) return state;
|
||||
const pA = state.players[seatA];
|
||||
const pB = state.players[seatB];
|
||||
if (!pA || !pB) return state;
|
||||
const iA = pA.hand.indexOf(cardA);
|
||||
const iB = pB.hand.indexOf(cardB);
|
||||
if (iA < 0 || iB < 0) return state;
|
||||
const s = cloneState(state);
|
||||
s.players[seatA].hand[iA] = cardB;
|
||||
s.players[seatB].hand[iB] = cardA;
|
||||
s.log.push({ kind: 'swapCards', seatA, cardA, seatB, cardB });
|
||||
return s;
|
||||
}
|
||||
|
||||
export function canEscape(state) {
|
||||
const allCaptured = TREASURE_KEYS.every((k) => state.players.some((p) => p.captured[k]));
|
||||
const allOnLanding = state.players.every((p) => p.tileId === 'fools-landing');
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
this.deckMode = data.deckMode ?? 'standard';
|
||||
this.wordLength = data.wordLength ?? 4;
|
||||
this.secretRevealType = data.secretRevealType ?? 'standard';
|
||||
this.difficulty = data.difficulty ?? 'normal';
|
||||
this.difficulty = data.difficulty ?? 'novice';
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.selectedDeckMode = 'standard';
|
||||
this.selectedWordLength = 4;
|
||||
this.selectedSecretRevealType = 'standard';
|
||||
this.selectedDifficulty = 'normal'; // Forbidden Island water-level start
|
||||
this.selectedDifficulty = 'novice'; // Forbidden Island water-level start
|
||||
this._initializing = false;
|
||||
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue