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
|
// Card logistics: other holders should converge on the carrier to hand
|
||||||
// their matching cards over (the Messenger can do it from anywhere).
|
// their matching cards over (the Messenger can do it from anywhere).
|
||||||
for (const o of state.players) {
|
for (const o of state.players) {
|
||||||
if (o.seat === cs || o.role === 'messenger') continue;
|
if (o.seat === cs) continue;
|
||||||
const oc = handTreasureCounts(o)[key];
|
const oc = handTreasureCounts(o)[key];
|
||||||
if (oc > 0) v -= bfsDist(state, o.tileId, new Set([carrierTile])) * 5 * oc * focus;
|
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
|
// The six adventurer roles. `power` is read by the engine/AI to branch special
|
||||||
// abilities; `color` is the pawn colour.
|
// abilities; `color` is the pawn colour.
|
||||||
export const ROLES = {
|
export const ROLES = {
|
||||||
pilot: { key: 'pilot', name: 'Pilot', color: 0x4a90d9, colorHex: '#4a90d9', power: 'Once per turn, fly to any tile.' },
|
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.' },
|
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.' },
|
||||||
messenger: { key: 'messenger', name: 'Messenger', color: 0xb9bfc6, colorHex: '#b9bfc6', power: 'Give Treasure cards to anyone, any distance.' },
|
engineer: { key: 'engineer', name: 'Engineer', color: 0xd0473a, colorHex: '#d0473a', power: 'Shore up two tiles for one action.',
|
||||||
navigator: { key: 'navigator', name: 'Navigator', color: 0xe2b53c, colorHex: '#e2b53c', power: 'Move another adventurer up to 2 tiles.' },
|
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.' },
|
||||||
diver: { key: 'diver', name: 'Diver', color: 0x2b2b30, colorHex: '#2b2b30', power: 'Swim through flooded and missing tiles.' },
|
messenger: { key: 'messenger', name: 'Messenger', color: 0xb9bfc6, colorHex: '#b9bfc6', power: 'Give Treasure cards to anyone, any distance.',
|
||||||
explorer: { key: 'explorer', name: 'Explorer', color: 0x49a25a, colorHex: '#49a25a', power: 'Move and shore up diagonally.' },
|
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.
|
// Special (non-treasure) Treasure-deck cards.
|
||||||
export const SPECIAL = {
|
export const SPECIAL = {
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ function makeRng(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- construction ----------------------------------------------------------
|
// ---- construction ----------------------------------------------------------
|
||||||
export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0 } = {}) {
|
export function createInitialState({ roles, difficulty = 'normal', seed, humanSeat = 0, skipInitialFlood = false } = {}) {
|
||||||
const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'messenger', 'navigator'];
|
const roleKeys = (roles && roles.length) ? roles.slice(0, 4) : ['pilot', 'engineer', 'navigator', 'diver'];
|
||||||
const s = {
|
const s = {
|
||||||
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
|
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
|
||||||
rngCursor: 0,
|
rngCursor: 0,
|
||||||
|
|
@ -111,12 +111,17 @@ export function createInitialState({ roles, difficulty = 'normal', seed, humanSe
|
||||||
}
|
}
|
||||||
s.treasureDeck = deck;
|
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);
|
s.floodDeck = shuffle(Object.keys(s.tiles), rng.next);
|
||||||
for (let i = 0; i < 6; i++) {
|
if (skipInitialFlood) {
|
||||||
const id = s.floodDeck.shift();
|
s.pendingFlood = s.floodDeck.splice(0, 6);
|
||||||
s.tiles[id].state = 'flooded';
|
} else {
|
||||||
s.floodDiscard.push(id);
|
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();
|
rng.commit();
|
||||||
|
|
@ -124,6 +129,58 @@ export function createInitialState({ roles, difficulty = 'normal', seed, humanSe
|
||||||
return s;
|
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) {
|
export function cloneState(state) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -138,6 +195,7 @@ export function cloneState(state) {
|
||||||
saveTiles: state.priorities.saveTiles.slice(),
|
saveTiles: state.priorities.saveTiles.slice(),
|
||||||
},
|
},
|
||||||
pendingSwim: state.pendingSwim ? { ...state.pendingSwim, options: state.pendingSwim.options.slice() } : null,
|
pendingSwim: state.pendingSwim ? { ...state.pendingSwim, options: state.pendingSwim.options.slice() } : null,
|
||||||
|
pendingFlood: (state.pendingFlood ?? []).slice(),
|
||||||
log: state.log.slice(),
|
log: state.log.slice(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -229,15 +287,6 @@ export function legalActions(state, seat) {
|
||||||
out.push({ type: 'shoreUp', seat, tiles: [shoreable[i], shoreable[j]] });
|
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.
|
// Capture a treasure.
|
||||||
const tile = state.tiles[p.tileId];
|
const tile = state.tiles[p.tileId];
|
||||||
if (tile.treasure && !p.captured[tile.treasure]) {
|
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';
|
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() });
|
s.log.push({ kind: 'shoreUp', seat, tiles: action.tiles.slice() });
|
||||||
break;
|
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': {
|
case 'capture': {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
p.hand = p.hand.filter((c) => {
|
p.hand = p.hand.filter((c) => {
|
||||||
|
|
@ -338,6 +379,22 @@ export function playHelicopter(state, seat, pawnSeats, destTileId) {
|
||||||
return s;
|
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) {
|
export function canEscape(state) {
|
||||||
const allCaptured = TREASURE_KEYS.every((k) => state.players.some((p) => p.captured[k]));
|
const allCaptured = TREASURE_KEYS.every((k) => state.players.some((p) => p.captured[k]));
|
||||||
const allOnLanding = state.players.every((p) => p.tileId === 'fools-landing');
|
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.deckMode = data.deckMode ?? 'standard';
|
||||||
this.wordLength = data.wordLength ?? 4;
|
this.wordLength = data.wordLength ?? 4;
|
||||||
this.secretRevealType = data.secretRevealType ?? 'standard';
|
this.secretRevealType = data.secretRevealType ?? 'standard';
|
||||||
this.difficulty = data.difficulty ?? 'normal';
|
this.difficulty = data.difficulty ?? 'novice';
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
this.selectedDeckMode = 'standard';
|
this.selectedDeckMode = 'standard';
|
||||||
this.selectedWordLength = 4;
|
this.selectedWordLength = 4;
|
||||||
this.selectedSecretRevealType = 'standard';
|
this.selectedSecretRevealType = 'standard';
|
||||||
this.selectedDifficulty = 'normal'; // Forbidden Island water-level start
|
this.selectedDifficulty = 'novice'; // Forbidden Island water-level start
|
||||||
this._initializing = false;
|
this._initializing = false;
|
||||||
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue