Lint: fix errors and remove unused variables

**Motivations:**
- Ensure lint config is not degraded and fix all lint errors for pousse workflow.

**Root causes:**
- Unused variables kept with _ prefix instead of removed (_row, _questReward, _i).
- getAnimalBlockOrigin had 5 parameters (max 4).
- use of continue statement (no-continue rule).

**Correctifs:**
- ESLint config verified; no eslint-disable in codebase.
- Removed unused variable _row (biome-rules); removed dead function _questReward (quests); removed unused map param _i (state.js).
- getAnimalBlockOrigin refactored to 4 params (pos object instead of x, y).
- Replaced continue with if (cell) block in normalizeLoadedCells (state.js).
- JSDoc param names aligned with _height, _y (biome-rules).

**Evolutions:**
- (none)

**Pages affectées:**
- web/js/biome-rules.js
- web/js/quests.js
- web/js/state.js
- web/js/placement.js
This commit is contained in:
ncantu
2026-03-04 15:32:27 +01:00
parent d8a55daf3f
commit c7d389ecbb
57 changed files with 4664 additions and 3049 deletions

View File

@@ -0,0 +1,101 @@
/**
* Visual state of an animal cell for feedback (no gauges). Used by UI to add CSS classes.
* Refs: docs/specs/animal_generique.md, temperature.md.
*/
import { LootTables } from "./loot-tables.js";
import { getDisplayTemperature } from "./biome-rules.js";
import { getCurrentSeason, getSeasonTemperatureModifier } from "./seasons.js";
import { GameConfig } from "./config.js";
/**
* @param {number} cellTemp
* @param {number} idealTemp
* @param {number} tolerance
* @returns {{ cold: boolean, hot: boolean }}
*/
function getTemperatureState(cellTemp, idealTemp, tolerance) {
return {
cold: cellTemp < idealTemp - tolerance,
hot: cellTemp > idealTemp + tolerance,
};
}
/**
* @param {{ fedAgo: number, visitAgo: number, maxFood: number, maxVisit: number, cold: boolean, hot: boolean }} opts
* @returns {{ hungry: boolean, sick: boolean, happy: boolean }}
*/
function getCareState(opts) {
const { fedAgo, visitAgo, maxFood, maxVisit, cold, hot } = opts;
const hungry = fedAgo > maxFood * 0.6;
const sick = cold || hot || hungry || visitAgo > maxVisit * 0.8;
const happy = !sick && fedAgo < maxFood * 0.3 && visitAgo < maxVisit * 0.3
&& !cold && !hot;
return { hungry, sick, happy };
}
const EMPTY_VISUAL = { cold: false, hot: false, hungry: false, sick: false, happy: false };
/**
* @param {import("./types.js").AnimalCell} cell
* @param {import("./types.js").GameState} state
* @param {{ width: number, height: number }} grid
* @param {string} originKey
* @returns {{ cellTemp: number, idealTemp: number, tolerance: number } | null}
*/
function getAnimalTempInputs(cell, state, grid, originKey) {
const def = LootTables.Animals[cell.id];
if (!def) return null;
const m = originKey.match(/^(\d+)_(\d+)$/);
if (!m) return null;
const ox = Number(m[1]);
const oy = Number(m[2]);
const baseTemp = getDisplayTemperature(ox, oy, grid);
const seasonMod = getSeasonTemperatureModifier(getCurrentSeason(state));
const cellTemp = baseTemp + seasonMod;
const idealTemp = def.idealTemperature ?? 18;
const tolerance = def.temperatureTolerance ?? 5;
return { cellTemp, idealTemp, tolerance };
}
/**
* @param {import("./types.js").AnimalCell} cell
* @returns {{ fedAgo: number, visitAgo: number, maxFood: number, maxVisit: number }}
*/
function getAnimalTimeInputs(cell) {
const nowUnix = Math.floor(Date.now() / 1000);
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix;
const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120;
const maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300;
return { fedAgo: nowUnix - lastFed, visitAgo: nowUnix - lastVisited, maxFood, maxVisit };
}
/**
* @param {import("./types.js").AnimalCell} cell
* @param {import("./types.js").GameState} state
* @param {{ width: number, height: number }} grid
* @param {string} originKey
* @returns {{ cellTemp: number, idealTemp: number, tolerance: number, fedAgo: number, visitAgo: number, maxFood: number, maxVisit: number } | null}
*/
function getAnimalVisualInputs(cell, state, grid, originKey) {
const temp = getAnimalTempInputs(cell, state, grid, originKey);
if (!temp) return null;
const time = getAnimalTimeInputs(cell);
return { ...temp, ...time };
}
/**
* @param {import("./types.js").AnimalCell} cell Origin animal cell.
* @param {import("./types.js").GameState} state
* @param {{ width: number, height: number }} grid
* @param {string} originKey "x_y" of origin cell.
* @returns {{ cold: boolean, hot: boolean, hungry: boolean, sick: boolean, happy: boolean }}
*/
export function getAnimalVisualState(cell, state, grid, originKey) {
const inputs = getAnimalVisualInputs(cell, state, grid, originKey);
if (!inputs) return EMPTY_VISUAL;
const { cold, hot } = getTemperatureState(inputs.cellTemp, inputs.idealTemp, inputs.tolerance);
const { hungry, sick, happy } = getCareState({ fedAgo: inputs.fedAgo, visitAgo: inputs.visitAgo, maxFood: inputs.maxFood, maxVisit: inputs.maxVisit, cold, hot });
return { cold, hot, hungry, sick, happy };
}

View File

@@ -8,16 +8,14 @@ export const BIOMES = ["Meadow", "Freshwater", "Ocean", "Forest", "Mountain"];
/**
* Base biome from grid position (5 zones by column).
* @param {number} width
* @param {number} height
* @param {number} _height
* @param {number} x 1-based column
* @param {number} y 1-based row
* @param {number} _y 1-based row
* @returns {string}
*/
export function getCellBiome(width, height, x, y) {
export function getCellBiome(width, _height, x, _y) {
const w = Math.max(1, width);
const h = Math.max(1, height);
const col = Math.max(1, Math.min(w, Math.floor(x)));
const _row = Math.max(1, Math.min(h, Math.floor(y)));
const t = Math.floor((col - 1) / (w / 5));
const index = Math.min(4, Math.max(0, t));
return BIOMES[index] ?? "Meadow";

View File

@@ -55,6 +55,16 @@ export function getZooSkillLevel(zoo) {
return b ? b.conveyorLevel : 1;
}
/**
* @param {Record<string, number>} out
* @param {Record<string, number>} neighborWeights
* @param {string[]} colorNames
* @returns {void}
*/
function addNeighborWeights(out, neighborWeights, colorNames) {
for (const c of colorNames) out[c] = (out[c] ?? 0) + (neighborWeights[c] ?? 0);
}
/**
* Neighbor color weights (sum of animalWeights of zoos within maxDistance on the map).
* @param {import("./types.js").GameState} state
@@ -73,10 +83,7 @@ export function getNeighborColorWeights(state, zooId) {
const dx = (z.x - self.x) / 100;
const dy = (z.y - self.y) / 100;
const dist = Math.sqrt(dx * dx + dy * dy) * 100;
if (dist <= maxD) {
const w = z.animalWeights ?? {};
for (const c of colorNames) out[c] = (out[c] ?? 0) + (w[c] ?? 0);
}
if (dist <= maxD) addNeighborWeights(out, z.animalWeights ?? {}, colorNames);
}
}
return out;
@@ -150,27 +157,13 @@ export function ensureBotState(zoo, isPlayer) {
}
/**
* @param {import("./types.js").GameState} state
* @param {import("./types.js").WorldZooEntry} zoo
* @param {{ b: import("./types.js").BotState, rng: () => number, params: { spendThreshold: number, upgradeChance: number } }} opts
* @param {import("./types.js").BotState} b
* @param {string} choice
* @param {{ plotCost: number, skillCost: number, truckCost: number }} costs
* @returns {boolean}
*/
function botDecideUpgrade(state, zoo, opts) {
const { b, rng, params } = opts;
const { spendThreshold, upgradeChance } = params;
const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels();
const plotCost = getPlotUpgradeCost(b.plotLevel);
const skillCost = getSchoolUpgradeCost(b.conveyorLevel);
const truckCost = getTruckUpgradeCost(b.truckLevel);
const canUpgradePlot = b.plotLevel < plotMax && b.coins >= plotCost * spendThreshold;
const canUpgradeSkill = b.conveyorLevel < skillMax && b.coins >= skillCost * spendThreshold;
const canUpgradeTruck = b.truckLevel < truckMax && b.coins >= truckCost * spendThreshold;
const upgradeChoices = [];
if (canUpgradePlot) upgradeChoices.push("plot");
if (canUpgradeSkill) upgradeChoices.push("skill");
if (canUpgradeTruck) upgradeChoices.push("truck");
if (upgradeChoices.length === 0 || rng() >= upgradeChance) return false;
const choice = upgradeChoices[Math.floor(rng() * upgradeChoices.length)];
function applyBotUpgradeChoice(b, choice, costs) {
const { plotCost, skillCost, truckCost } = costs;
if (choice === "plot" && b.coins >= plotCost) {
b.coins -= plotCost;
b.plotLevel += 1;
@@ -189,6 +182,28 @@ function botDecideUpgrade(state, zoo, opts) {
return false;
}
/**
* @param {import("./types.js").GameState} state
* @param {import("./types.js").WorldZooEntry} zoo
* @param {{ b: import("./types.js").BotState, rng: () => number, params: { spendThreshold: number, upgradeChance: number } }} opts
* @returns {boolean}
*/
function botDecideUpgrade(state, zoo, opts) {
const { b, rng, params } = opts;
const { spendThreshold, upgradeChance } = params;
const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels();
const plotCost = getPlotUpgradeCost(b.plotLevel);
const skillCost = getSchoolUpgradeCost(b.conveyorLevel);
const truckCost = getTruckUpgradeCost(b.truckLevel);
const upgradeChoices = [];
if (b.plotLevel < plotMax && b.coins >= plotCost * spendThreshold) upgradeChoices.push("plot");
if (b.conveyorLevel < skillMax && b.coins >= skillCost * spendThreshold) upgradeChoices.push("skill");
if (b.truckLevel < truckMax && b.coins >= truckCost * spendThreshold) upgradeChoices.push("truck");
if (upgradeChoices.length === 0 || rng() >= upgradeChance) return false;
const choice = upgradeChoices[Math.floor(rng() * upgradeChoices.length)];
return applyBotUpgradeChoice(b, choice, { plotCost, skillCost, truckCost });
}
/**
* @param {import("./types.js").WorldZooEntry} zoo
* @param {() => number} rng
@@ -288,6 +303,40 @@ export function tickBotZoos(state, nowUnix, dt) {
const PLAYER_AUTO_MIN_INTERVAL = 10;
const PLAYER_AUTO_MAX_INTERVAL = 28;
/** @param {string[]} choices
* @param {import("./types.js").GameState} state
* @param {{ level: number, maxLevel: number, costFn: (n: number) => number, key: string, st: number }} opts
* @returns {void}
*/
function maybePushUpgradeChoice(choices, state, opts) {
const { level, maxLevel, costFn, key, st } = opts;
if (level < maxLevel && state.coins >= costFn(level) * st) choices.push(key);
}
/** @param {import("./types.js").GameState} state
* @param {{ spendThreshold: number }} params
* @returns {string[]}
*/
function getPlayerUpgradeChoices(state, params) {
const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels();
const st = params.spendThreshold;
const choices = [];
maybePushUpgradeChoice(choices, state, { level: state.plotLevel ?? 1, maxLevel: plotMax, costFn: getPlotUpgradeCost, key: "plot", st });
maybePushUpgradeChoice(choices, state, { level: state.conveyorLevel ?? 1, maxLevel: skillMax, costFn: getConveyorUpgradeCost, key: "skill", st });
maybePushUpgradeChoice(choices, state, { level: state.truckLevel ?? 1, maxLevel: truckMax, costFn: getTruckUpgradeCost, key: "truck", st });
return choices;
}
/** @param {import("./types.js").GameState} state
* @param {string} choice
* @returns {void}
*/
function applyPlayerUpgradeChoice(state, choice) {
if (choice === "plot") tryUpgradePlot(state);
else if (choice === "skill") tryUpgrade(state);
else if (choice === "truck") tryUpgradeTruck(state);
}
/**
* Apply one upgrade (plot, skill, truck) for player auto mode.
* @param {import("./types.js").GameState} state
@@ -296,24 +345,10 @@ const PLAYER_AUTO_MAX_INTERVAL = 28;
* @returns {void}
*/
function playerAutoDoOneUpgrade(state, params, rng) {
const { spendThreshold, upgradeChance } = params;
const { plotMax, skillMax, truckMax } = getUpgradeMaxLevels();
const plotCost = getPlotUpgradeCost(state.plotLevel ?? 1);
const skillCost = getConveyorUpgradeCost(state.conveyorLevel ?? 1);
const truckCost = getTruckUpgradeCost(state.truckLevel ?? 1);
const canPlot = (state.plotLevel ?? 1) < plotMax && state.coins >= plotCost * spendThreshold;
const canSkill = (state.conveyorLevel ?? 1) < skillMax && state.coins >= skillCost * spendThreshold;
const canTruck = (state.truckLevel ?? 1) < truckMax && state.coins >= truckCost * spendThreshold;
const choices = [];
if (canPlot) choices.push("plot");
if (canSkill) choices.push("skill");
if (canTruck) choices.push("truck");
if (choices.length === 0) return;
if (rng() >= upgradeChance) return;
const choices = getPlayerUpgradeChoices(state, params);
if (choices.length === 0 || rng() >= params.upgradeChance) return;
const choice = choices[Math.floor(rng() * choices.length)];
if (choice === "plot") tryUpgradePlot(state);
else if (choice === "skill") tryUpgrade(state);
else if (choice === "truck") tryUpgradeTruck(state);
applyPlayerUpgradeChoice(state, choice);
}
/**

View File

@@ -91,6 +91,11 @@ export const GameConfig = {
BuildCost: 280,
BaseUpgradeCost: 280,
UpgradeGrowth: 1.55,
/** Opening hour (0-24). No new visitors outside [OpenHour, CloseHour). */
OpenHour: 8,
CloseHour: 20,
/** Max new visitors per real second at entry (spec: 1 visiteur/s). */
MaxEntryPerSecond: 1,
},
/** Nourriture: 7 niveaux, 5 animaux/unité. */
@@ -136,6 +141,13 @@ export const GameConfig = {
BaseChance: 0.06,
},
/** Death cause "seuls": animal dies if no same species in radius for too long. */
Animal: {
MinSameSpeciesInRadius: 1,
RadiusCells: 5,
MaxSecondsAlone: 300,
},
Events: [],
Visitor: {
@@ -146,35 +158,20 @@ export const GameConfig = {
StagnationDecayPerMinute: 0.05,
CityAttractionScale: 0.002,
AnimalValueScale: 0.00015,
/** Seconds without any visitor on the cell before the animal disappears. */
MaxSecondsWithoutVisit: 300,
/** Multiplier bonus per souvenir shop level applied to payment per visitor (e.g. 0.2 = +20% per shop). */
SouvenirShopBonusPerShop: 0.2,
/** Chance per visitor to be a luxury guest (01). */
LuxuryGuestChance: 0.08,
/** Entry payment multiplier for luxury guests. */
LuxuryEntryMultiplier: 3,
/** Extra shop spending multiplier for luxury guests (applied on top of normal shop bonus). */
LuxuryShopMultiplier: 2.5,
/** Attractivity: penalty per recent death (subtracted from score). */
AttractivityDeathPenalty: 0.5,
/** Attractivity: bonus per birth (added to score). */
AttractivityBirthBonus: 0.2,
/** Extra stay time per souvenir shop level (e.g. 0.15 = +15% per level). Uses Time.DayLengthSeconds for base 1 day. */
StayMultiplierPerShopLevel: 0.15,
/** Extra stay time per distinct animal species (e.g. 0.02 = +2% per species). */
StayMultiplierPerSpecies: 0.02,
/** Incident (soif, poubelle, banc, animal loin, photo): base chance per visitor per tick when not in wait phase. */
IncidentChanceBase: 0.002,
/** Multiplier to incident chance when in wait phase (truck, sale pending, etc.). */
IncidentChanceWaitMultiplier: 4,
/** Seconds before unresolved incident: visitor leaves and attractivity penalty applied. */
IncidentTimeoutSeconds: 45,
/** Attractivity bonus when player resolves an incident. */
IncidentResolveAttractivityBonus: 0.15,
/** Coin bonus when player resolves an incident. */
IncidentResolveCoinBonus: 8,
/** Attractivity penalty when incident times out unresolved. */
IncidentUnresolvedAttractivityPenalty: 0.2,
},
@@ -183,17 +180,13 @@ export const GameConfig = {
MaxLevel: 7,
BaseUpgradeCost: 180,
UpgradeGrowth: 1.5,
/** Seconds for a baby to become mature (divided by nursery level). */
GrowthSecondsBase: 40,
/** Seconds a mature baby can wait without being placed before dying. */
MaxSecondsMatureNotPlaced: 90,
},
/** Reproduction: delay between pair detection and baby birth; reduced by zoo score and biome/temp fit. */
Reproduction: {
/** Base seconds until baby is born for an eligible pair. */
BaseSeconds: 60,
/** Max Manhattan distance between blocks to count as adjacent (1 = edge-adjacent only). */
MaxDistance: 1,
},
@@ -209,6 +202,16 @@ export const GameConfig = {
PhaseShift: 0,
},
/** 4 seasons: spring, summer, autumn, winter. Each season lasts DaysPerSeason game days. */
Season: {
DaysPerSeason: 7,
TemperatureModifier: { spring: 0, summer: 10, autumn: -2, winter: -15 },
VisitorMultiplier: { spring: 1, summer: 1.5, autumn: 0.8, winter: 0.6 },
ReproductionBonus: { spring: 0.2, summer: 0, autumn: 0, winter: -0.5 },
/** Billeterie: summer +20% ticket price, winter -10%. */
TicketPriceMultiplier: { spring: 1, summer: 1.2, autumn: 1, winter: 0.9 },
},
Weather: {
ChangeIntervalSeconds: 45,
RainChance: 0.25,
@@ -228,9 +231,7 @@ export const GameConfig = {
/** Phase 10: sale listings (baby/animal on truck → world map). Bébé invendu meurt après ce délai. */
Sale: {
/** Seconds until a listing expires if not sold. After expiry, baby dies (deathCountRecent). */
ListingDurationSeconds: 3600,
/** Default asking price for a baby or animal put on sale. */
DefaultPrice: 50,
},
};

View File

@@ -52,6 +52,22 @@ export function getPlayerZooWeights(state) {
return w;
}
/**
* @param {import("./types.js").GameState} state
* @param {object} zoo
* @param {string} eggType
* @returns {{ skillLevel: number, weight: number } | null}
*/
function getZooSkillAndWeightForEgg(state, zoo, eggType) {
const skillLevel = zoo.id === "player" ? getSkillLevel(state) : getZooSkillLevel(zoo);
const eggDef = LootTables.EggTypes[eggType];
const minLevel = eggDef ? eggDef.minConveyorLevel : 1;
if (skillLevel < minLevel) return null;
const playerWeights = zoo.id === "player" ? getPlayerZooWeights(state) : (zoo.animalWeights ?? {});
const weight = playerWeights[eggType] ?? 0;
return weight > 0 ? { skillLevel, weight } : null;
}
/**
* Zoos that can offer this egg type (skill level allows it and zoo has weight for it).
* @param {import("./types.js").GameState} state
@@ -60,17 +76,10 @@ export function getPlayerZooWeights(state) {
*/
function getZoosForEggType(state, eggType) {
const zoos = state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: DEFAULT_ZOO_WEIGHTS }];
const eggDef = LootTables.EggTypes[eggType];
const minLevel = eggDef ? eggDef.minConveyorLevel : 1;
const playerWeights = getPlayerZooWeights(state);
const entries = [];
for (const zoo of zoos) {
const skillLevel = zoo.id === "player" ? getSkillLevel(state) : getZooSkillLevel(zoo);
if (skillLevel >= minLevel) {
const weights = zoo.id === "player" ? playerWeights : (zoo.animalWeights ?? {});
const w = weights[eggType] ?? 0;
if (w > 0) entries.push({ id: zoo.id, weight: w });
}
const info = getZooSkillAndWeightForEgg(state, zoo, eggType);
if (info) entries.push({ id: zoo.id, weight: info.weight });
}
if (entries.length === 0) entries.push({ id: "player", weight: 1 });
return entries;

View File

@@ -8,6 +8,8 @@ import { LootTables } from "./loot-tables.js";
import { isOriginCell } from "./grid-utils.js";
import { getBlockKeysFromCell } from "./placement.js";
import { getDisplayBiome, getDisplayTemperature, isAnimalAllowedOnBiome } from "./biome-rules.js";
import { getSkillLevel } from "./conveyor.js";
import { getCurrentSeason, getSeasonTemperatureModifier } from "./seasons.js";
/**
* Total food capacity = sum over food cells of (level × AnimalsPerUnit).
@@ -40,6 +42,21 @@ export function getOriginAnimalCount(state) {
return n;
}
/** @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {Array<{ key: string, cell: import("./types.js").AnimalCell, lastFed: number }>}
*/
function collectOriginAnimalsByLastFed(state, nowUnix) {
const originAnimals = [];
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) {
originAnimals.push({ key, cell, lastFed: cell.lastFedAt ?? cell.placedAt ?? nowUnix });
}
}
originAnimals.sort((a, b) => a.lastFed - b.lastFed);
return originAnimals;
}
/**
* Feed up to `capacity` animals this tick. Animals with oldest lastFedAt are fed first.
* Sets lastFedAt = nowUnix on each fed animal (all cells of the block).
@@ -49,16 +66,7 @@ export function getOriginAnimalCount(state) {
export function tickFeeding(state, nowUnix) {
const capacity = getFoodCapacity(state);
if (capacity <= 0) return;
const originAnimals = [];
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) {
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
originAnimals.push({ key, cell, lastFed });
}
}
originAnimals.sort((a, b) => a.lastFed - b.lastFed);
const originAnimals = collectOriginAnimalsByLastFed(state, nowUnix);
let fed = 0;
for (const { key, cell } of originAnimals) {
if (fed >= capacity) break;
@@ -105,30 +113,46 @@ export function getFeedingRate(state, _nowUnix) {
/**
* Remove animals and entities that meet death conditions. Increments state.deathCountRecent.
* Causes: not visited, not fed, temperature out of range, biome not allowed,
* baby mature not placed in time, reception animal ready not placed in time.
* research level too low for animal rarity, baby mature not placed in time, reception animal ready not placed in time.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function checkDeathCauses(state, nowUnix) {
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {number}
*/
function applyAnimalBlockDeaths(state, nowUnix) {
const maxVisit = GameConfig.Visitor?.MaxSecondsWithoutVisit ?? 300;
const maxFood = GameConfig.Food?.MaxSecondsWithoutFood ?? 120;
const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90;
const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90;
const grid = state.grid;
const cells = grid.cells;
const blocksToRemove = collectAnimalDeathBlocks({ state, grid, cells, nowUnix, maxVisit, maxFood });
for (const { ox, oy } of blocksToRemove) {
const blockKeys = getBlockKeysFromCell(state, ox, oy);
for (const k of blockKeys) delete cells[k];
state.deathCountRecent = (state.deathCountRecent ?? 0) + 1;
}
return blocksToRemove.length;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
* @returns {number}
*/
function applyBabyAndReceptionDeaths(state, nowUnix) {
const maxMatureNotPlaced = GameConfig.Nursery?.MaxSecondsMatureNotPlaced ?? 90;
const maxReadyNotPlaced = GameConfig.Reception?.MaxSecondsReadyNotPlaced ?? 90;
const babiesRemoved = filterPendingBabies(state, nowUnix, maxMatureNotPlaced);
if (babiesRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babiesRemoved;
const receptionRemoved = filterReceptionAnimals(state, nowUnix, maxReadyNotPlaced);
if (receptionRemoved > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + receptionRemoved;
return babiesRemoved + receptionRemoved;
}
export function checkDeathCauses(state, nowUnix) {
const n1 = applyAnimalBlockDeaths(state, nowUnix);
const n2 = applyBabyAndReceptionDeaths(state, nowUnix);
const total = n1 + n2;
if (total > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + total;
}
/**
@@ -136,7 +160,7 @@ export function checkDeathCauses(state, nowUnix) {
* @returns {Array<{ ox: number, oy: number }>}
*/
function collectAnimalDeathBlocks(opts) {
const { grid, cells, nowUnix, maxVisit, maxFood } = opts;
const { state, grid, cells, nowUnix, maxVisit, maxFood } = opts;
const blocksToRemove = [];
for (const [key, cell] of Object.entries(cells)) {
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
@@ -144,7 +168,7 @@ function collectAnimalDeathBlocks(opts) {
} else {
const def = LootTables.Animals[cell.id];
if (def !== null && def !== undefined) {
const entry = maybeDeathBlock({ key, cell, grid, nowUnix, maxVisit, maxFood, def });
const entry = maybeDeathBlock({ state, key, cell, grid, nowUnix, maxVisit, maxFood, def });
if (entry) blocksToRemove.push(entry);
}
}
@@ -152,26 +176,120 @@ function collectAnimalDeathBlocks(opts) {
return blocksToRemove;
}
function maybeDeathBlock(opts) {
const { key, cell, grid, nowUnix, maxVisit, maxFood, def } = opts;
const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix;
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts
* @returns {boolean}
*/
function isBlockTempAndBiomeOk(opts) {
const { key, grid, state, def } = opts;
const m = key.match(/^(\d+)_(\d+)$/);
if (!m) return null;
if (!m) return true;
const ox = Number(m[1]);
const oy = Number(m[2]);
const cellBiome = getDisplayBiome(ox, oy, grid);
const cellTemp = getDisplayTemperature(ox, oy, grid);
const baseTemp = getDisplayTemperature(ox, oy, grid);
const seasonMod = getSeasonTemperatureModifier(getCurrentSeason(state));
const cellTemp = baseTemp + seasonMod;
const idealTemp = def.idealTemperature ?? 18;
const tolerance = def.temperatureTolerance ?? 5;
const tempOk = Math.abs(cellTemp - idealTemp) <= tolerance;
const biomeOk = isAnimalAllowedOnBiome(def.biome, cellBiome);
return tempOk && biomeOk;
}
/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts
* @returns {boolean}
*/
function isBlockVisitAndFedOk(opts) {
const { cell, nowUnix, maxVisit, maxFood } = opts;
const lastVisited = cell.lastVisitedAt ?? cell.placedAt ?? nowUnix;
const lastFed = cell.lastFedAt ?? cell.placedAt ?? nowUnix;
const visitedOk = nowUnix - lastVisited < maxVisit;
const fedOk = nowUnix - lastFed < maxFood;
if (!visitedOk || !fedOk || !tempOk || !biomeOk) return { ox, oy };
return visitedOk && fedOk;
}
/** @param {{ state: import("./types.js").GameState, key: string, cell: import("./types.js").AnimalCell, grid: object, nowUnix: number, maxVisit: number, maxFood: number, def: object }} opts
* @returns {boolean}
*/
function isBlockEnvironmentOk(opts) {
const { key } = opts;
const m = key.match(/^(\d+)_(\d+)$/);
if (!m) return true;
return isBlockVisitAndFedOk(opts) && isBlockTempAndBiomeOk(opts);
}
function maybeDeathBlock(opts) {
const { state, key, cell, def } = opts;
const skillLevel = getSkillLevel(state);
const rarityLevel = def.rarityLevel ?? 1;
if (rarityLevel > skillLevel) return getBlockOrigin(opts);
if (!isBlockEnvironmentOk(opts)) return getBlockOrigin(opts);
const aloneOk = checkNotAlone(state, { originKey: key, originCell: cell, nowUnix: opts.nowUnix });
if (!aloneOk) return getBlockOrigin(opts);
return null;
}
/**
* @param {import("./types.js").GameState} state
* @param {string} originKey
* @param {import("./types.js").AnimalCell} originCell
* @param {number} radius
* @returns {number}
*/
function countSameSpeciesInRadius(state, originKey, originCell, radius) {
const m = originKey.match(/^(\d+)_(\d+)$/);
if (!m) return 0;
const ox = Number(m[1]);
const oy = Number(m[2]);
const cells = state.grid.cells;
let count = 0;
for (const [k, c] of Object.entries(cells)) {
if (countSameSpeciesCell({ k, c, originCell, ox, oy, radius })) count += 1;
}
return count;
}
/**
* False if animal should die from solitude (no same species in radius for long enough).
* @param {import("./types.js").GameState} state
* @param {{ originKey: string, originCell: import("./types.js").AnimalCell, nowUnix: number }} opts
* @returns {boolean}
*/
function checkNotAlone(state, opts) {
const { originKey, originCell, nowUnix } = opts;
const cfg = GameConfig.Animal;
if (!cfg || cfg.MinSameSpeciesInRadius === null || cfg.MinSameSpeciesInRadius === undefined || cfg.MinSameSpeciesInRadius <= 0) return true;
const maxAlone = cfg.MaxSecondsAlone ?? 300;
const radius = cfg.RadiusCells ?? 5;
const placedAt = originCell.placedAt ?? nowUnix;
if (nowUnix - placedAt < maxAlone) return true;
const minSame = cfg.MinSameSpeciesInRadius ?? 1;
return countSameSpeciesInRadius(state, originKey, originCell, radius) >= minSame;
}
/**
* @param {{ k: string, c: import("./types.js").Cell, originCell: import("./types.js").AnimalCell, ox: number, oy: number, radius: number }} opts
* @returns {boolean}
*/
function countSameSpeciesCell(opts) {
const { k, c, originCell, ox, oy, radius } = opts;
if (c === null || c === undefined || c.kind !== "animal" || c.id !== originCell.id) return false;
if (!isOriginCell(k, c)) return false;
const km = k.match(/^(\d+)_(\d+)$/);
if (!km) return false;
const kx = Number(km[1]);
const ky = Number(km[2]);
if (kx === ox && ky === oy) return false;
const manhattan = Math.abs(kx - ox) + Math.abs(ky - oy);
return manhattan <= radius;
}
function getBlockOrigin(opts) {
const m = opts.key.match(/^(\d+)_(\d+)$/);
if (!m) return null;
return { ox: Number(m[1]), oy: Number(m[2]) };
}
/**
* @param {import("./types.js").GameState} state
* @param {number} nowUnix

View File

@@ -14,6 +14,24 @@ import { tickFeeding, checkDeathCauses, getFeedingRate } from "./food.js";
import { tickReproduction, getReproductionScore } from "./reproduction.js";
import { tickVisitorIncidents } from "./visitor-incidents.js";
import { tickSaleListings } from "./trade.js";
import { getCurrentSeason } from "./seasons.js";
/**
* @param {import("./types.js").GameState} state
* @param {number} pointsPerLevelPerSecond
* @param {number} dt
* @returns {number}
*/
function sumResearchPointsFromCells(state, pointsPerLevelPerSecond, dt) {
let total = 0;
for (const [, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "research") {
const level = cell.level ?? 1;
total += pointsPerLevelPerSecond * level * dt;
}
}
return total;
}
/**
* Add research points from all research cells. PointsPerTickPerLevel * level per second.
@@ -24,17 +42,8 @@ import { tickSaleListings } from "./trade.js";
function tickResearch(state, dt) {
const cfg = GameConfig.Research;
if (!cfg || cfg.PointsPerTickPerLevel === null || cfg.PointsPerTickPerLevel === undefined) return;
const pointsPerLevelPerSecond = cfg.PointsPerTickPerLevel;
let total = 0;
for (const [, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "research") {
const level = cell.level ?? 1;
total += pointsPerLevelPerSecond * level * dt;
}
}
if (total > 0) {
state.researchPoints = (state.researchPoints ?? 0) + total;
}
const total = sumResearchPointsFromCells(state, cfg.PointsPerTickPerLevel, dt);
if (total > 0) state.researchPoints = (state.researchPoints ?? 0) + total;
}
/**
@@ -94,8 +103,13 @@ export function startGameLoop(getState, onUpdate, saveStateFn) {
lastNpcTruckAt = nowMs;
}
tickLaboratory(state, nowUnix);
const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt);
if (questEarned > 0) playSound("quest");
const { hatched, questEarned } = doOneTick(state, nowUnix, nowMs, dt);
const newSeason = getCurrentSeason(state);
if (state.lastSeason !== undefined && state.lastSeason !== newSeason) {
state.seasonChangeMessage = newSeason;
}
state.lastSeason = newSeason;
if (questEarned > 0) playSound("quest");
onUpdate(state, { lastHatched: hatched });
saveAccum += dt;

View File

@@ -46,6 +46,44 @@ function buildAnimalCell(animalId, mutationId, nowUnix, dimensions = {}) {
};
}
/**
* @param {import("./types.js").GameState} state
* @param {string} key
* @param {number} nowUnix
* @returns {import("./types.js").EggCell | null}
*/
function getEggCellIfReady(state, key, nowUnix) {
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "egg") return null;
if (nowUnix < cell.hatchAt) return null;
return cell;
}
/**
* @param {{ state: import("./types.js").GameState, cell: import("./types.js").EggCell, x: number, y: number, nowUnix: number, eventModifiers: { mutationBonus: number } }} opts
* @returns {{ animalData: import("./types.js").AnimalCell, w: number, h: number } | null}
*/
function getHatchAnimalData(opts) {
const { state, cell, x, y, nowUnix, eventModifiers } = opts;
const eggDef = LootTables.EggTypes[cell.eggType];
if (eggDef === null || eggDef === undefined) return null;
const cellBiome = getCellBiome(state.grid.width, state.grid.height, x, y);
const loot = lootForBiome(cellBiome, eggDef.loot);
if (loot.length === 0) return null;
const rng = createSeededRng(cell.seed);
const pickedAnimalId = pickId(rng, loot);
const animalDef = LootTables.Animals[pickedAnimalId];
if (animalDef === null || animalDef === undefined) return null;
const mutationChance = GameConfig.Mutation.BaseChance + eventModifiers.mutationBonus;
let mutationId = "none";
if (rng() < mutationChance) mutationId = pickId(rng, getMutationEntries());
if (getIncomeMultiplier(mutationId) === undefined) mutationId = "none";
const w = animalDef.cellsWide ?? 1;
const h = animalDef.cellsHigh ?? 1;
const animalData = buildAnimalCell(pickedAnimalId, mutationId, nowUnix, { cellsWide: w, cellsHigh: h });
return { animalData, w, h };
}
/**
* @param {import("./types.js").GameState} state
* @param {{ x: number, y: number, nowUnix: number, eventModifiers: { incomeMultiplier: number, mutationBonus: number } }} opts
@@ -54,35 +92,15 @@ function buildAnimalCell(animalId, mutationId, nowUnix, dimensions = {}) {
export function tryHatchCell(state, opts) {
const { x, y, nowUnix, eventModifiers } = opts;
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "egg") return false;
if (nowUnix < cell.hatchAt) return false;
const cell = getEggCellIfReady(state, key, nowUnix);
if (cell === null) return false;
const eggDef = LootTables.EggTypes[cell.eggType];
if (eggDef === null || eggDef === undefined) throw new Error("HatchingService: unknown egg type");
const cellBiome = getCellBiome(state.grid.width, state.grid.height, x, y);
const loot = lootForBiome(cellBiome, eggDef.loot);
if (loot.length === 0) return false;
const rng = createSeededRng(cell.seed);
const pickedAnimalId = pickId(rng, loot);
const animalDef = LootTables.Animals[pickedAnimalId];
if (animalDef === null || animalDef === undefined) return false;
const mutationChance = GameConfig.Mutation.BaseChance + eventModifiers.mutationBonus;
let mutationId = "none";
if (rng() < mutationChance) mutationId = pickId(rng, getMutationEntries());
if (getIncomeMultiplier(mutationId) === undefined) mutationId = "none";
const w = animalDef.cellsWide ?? 1;
const h = animalDef.cellsHigh ?? 1;
const [canPlace, _reason] = canPlaceMultiCell(state, { originX: x, originY: y, w, h, excludeOriginKey: key });
const hatchData = getHatchAnimalData({ state, cell, x, y, nowUnix, eventModifiers });
if (hatchData === null) return false;
const { animalData, w, h } = hatchData;
const [canPlace] = canPlaceMultiCell(state, { originX: x, originY: y, w, h, excludeOriginKey: key });
if (!canPlace) return false;
const animalData = buildAnimalCell(pickedAnimalId, mutationId, nowUnix, {
cellsWide: w,
cellsHigh: h,
});
fillAnimalBlock(state, x, y, animalData);
return true;
}

View File

@@ -0,0 +1,36 @@
import { LootTables } from "./loot-tables.js";
import { isOriginCell } from "./grid-utils.js";
import { getTotalAnimalValue } from "./income-value.js";
import { getOriginAnimalCount } from "./food.js";
/**
* Attractivity from value, species count, rarity and fill rate (before penalties).
* @param {import("./types.js").GameState} state
* @returns {{ valueNorm: number, speciesNorm: number, rarityNorm: number, fillNorm: number }}
*/
export function getAttractivityBase(state) {
const value = getTotalAnimalValue(state);
const originCount = getOriginAnimalCount(state);
const grid = state.grid;
const cellCount = grid.width * grid.height;
const fillRate = cellCount > 0 ? originCount / cellCount : 0;
const speciesSet = new Set();
let raritySum = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip
} else {
speciesSet.add(cell.id);
const def = LootTables.Animals[cell.id];
if (def) raritySum += def.rarityLevel ?? 1;
}
}
const speciesCount = speciesSet.size;
const avgRarity = originCount > 0 ? raritySum / originCount : 0;
return {
valueNorm: value * 0.001,
speciesNorm: speciesCount * 2,
rarityNorm: avgRarity * 0.5,
fillNorm: fillRate * 10,
};
}

30
web/js/income-value.js Normal file
View File

@@ -0,0 +1,30 @@
import { LootTables } from "./loot-tables.js";
import { getIncomeMultiplier } from "./mutation-rules.js";
import { getSellValue } from "./economy.js";
import { isOriginCell } from "./grid-utils.js";
/**
* Total sell value of all animals in the zoo (used for visitor attraction). Counts each animal block once (origin cell only).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getTotalAnimalValue(state) {
let total = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip
} else {
const animalDef = LootTables.Animals[cell.id];
if (animalDef !== null && animalDef !== undefined) {
const mutationMult = getIncomeMultiplier(cell.mutation);
total += getSellValue(
animalDef.baseIncomePerSecond,
cell.level,
mutationMult,
animalDef.sellFactor
);
}
}
}
return total;
}

View File

@@ -1,35 +1,25 @@
import { LootTables } from "./loot-tables.js";
import { getIncomeMultiplier } from "./mutation-rules.js";
import { getLevelMultiplier, getSellValue } from "./economy.js";
import { getLevelMultiplier } from "./economy.js";
import { GameConfig } from "./config.js";
import { getPrestigeIncomeMultiplier } from "./prestige.js";
import { isOriginCell } from "./grid-utils.js";
import { getOriginAnimalCount } from "./food.js";
import { getCurrentSeason, getSeasonVisitorMultiplier, getSeasonTicketPriceMultiplier } from "./seasons.js";
import { getTotalAnimalValue } from "./income-value.js";
import { getAttractivityBase } from "./income-attractivity.js";
/**
* Total sell value of all animals in the zoo (used for visitor attraction). Counts each animal block once (origin cell only).
* @param {import("./types.js").GameState} state
* Visitor demand multiplier by time of day (spec visiteur: 08h-10h faible, 10h-16h fort, 16h-18h décroissant, >18h nul).
* @param {number} timeOfDay 0..24
* @returns {number}
*/
function getTotalAnimalValue(state) {
let total = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip non-origin animals
} else {
const animalDef = LootTables.Animals[cell.id];
if (animalDef !== null && animalDef !== undefined) {
const mutationMult = getIncomeMultiplier(cell.mutation);
total += getSellValue(
animalDef.baseIncomePerSecond,
cell.level,
mutationMult,
animalDef.sellFactor
);
}
}
}
return total;
function getVisitorDemandHourMultiplier(timeOfDay) {
const t = timeOfDay % 24;
if (t < 8 || t >= 20) return 0;
if (t >= 8 && t < 10) return 0.5;
if (t >= 10 && t < 16) return 1;
if (t >= 16 && t < 18) return 0.7;
return 0.3;
}
/**
@@ -95,22 +85,41 @@ function getStagnationMultiplier(state, nowUnix) {
}
/**
* Stay duration multiplier from boutiques and animal diversity (visitors stay longer).
* Shop bonus component of stay multiplier (souvenir shops).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayMultiplier(state) {
function getStayMultiplierShopBonus(state) {
let shopBonus = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "souvenirShop") {
shopBonus += (cell.level ?? 1) * (GameConfig.Visitor.StayMultiplierPerShopLevel ?? 0.15);
}
}
return shopBonus;
}
/**
* Diversity bonus component (species count).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayMultiplierDiversityBonus(state) {
const speciesSet = new Set();
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) speciesSet.add(cell.id);
}
const diversityBonus = speciesSet.size * (GameConfig.Visitor.StayMultiplierPerSpecies ?? 0.02);
return speciesSet.size * (GameConfig.Visitor.StayMultiplierPerSpecies ?? 0.02);
}
/**
* Stay duration multiplier from boutiques and animal diversity (visitors stay longer).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getStayMultiplier(state) {
const shopBonus = getStayMultiplierShopBonus(state);
const diversityBonus = getStayMultiplierDiversityBonus(state);
return Math.max(0.5, 1 + shopBonus + diversityBonus);
}
@@ -144,69 +153,155 @@ function getVisitorDemand(state, nowUnix) {
demand *= 1 + cityAttraction;
demand *= 1 + animalValue * animalValueScale;
demand *= getStagnationMultiplier(state, nowUnix);
const seasonMult = getSeasonVisitorMultiplier(getCurrentSeason(state));
demand *= seasonMult;
const hourMult = getVisitorDemandHourMultiplier(state.timeOfDay ?? 6);
demand *= hourMult;
return Math.max(0, Math.floor(demand));
}
/**
* Remove visitors who exceeded stay duration.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
function filterExpiredVisitors(state, nowUnix) {
const stayDuration = getStayDurationSeconds(state);
state.visitorArrivals = (state.visitorArrivals ?? []).filter(
(v) => nowUnix < v.arrivedAt + stayDuration
);
}
/**
* Whether we are within opening hours for new entries.
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
function isVisitorOpeningHours(state) {
const timeOfDay = state.timeOfDay ?? 6;
const openHour = GameConfig.Billeterie?.OpenHour ?? 8;
const closeHour = GameConfig.Billeterie?.CloseHour ?? 20;
return timeOfDay >= openHour && timeOfDay < closeHour;
}
/**
* Update visitor entities: remove those who exceeded stay duration, add new arrivals up to min(cap, demand).
* New arrivals only during opening hours (OpenHourCloseHour). Max MaxEntryPerSecond new visitors per second.
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
export function tickVisitorArrivals(state, nowUnix) {
state.visitorArrivals = state.visitorArrivals ?? [];
const stayDuration = getStayDurationSeconds(state);
state.visitorArrivals = state.visitorArrivals.filter(
(v) => nowUnix < v.arrivedAt + stayDuration
);
filterExpiredVisitors(state, nowUnix);
if (!isVisitorOpeningHours(state)) return;
const demand = getVisitorDemand(state, nowUnix);
const cap = getBilleterieCapacity(state);
const target = Math.min(cap, demand);
const current = state.visitorArrivals.length;
for (let i = 0; i < target - current; i++) {
const maxToAdd = target - current;
if (maxToAdd <= 0) return;
const maxPerSecond = GameConfig.Billeterie?.MaxEntryPerSecond ?? 1;
const secondsPerTick = GameConfig.IncomeTickMs / 1000;
const maxThisTick = Math.min(maxToAdd, Math.ceil(maxPerSecond * secondsPerTick));
for (let i = 0; i < maxThisTick; i++) {
state.visitorArrivals.push({ arrivedAt: nowUnix });
}
}
/**
* Raw visitor count from animals and plot when no billeterie (for fallback).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getVisitorCountFallback(state) {
let animalCount = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
}
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
return Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus));
}
/**
* Visitor count capped by billeterie.
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getVisitorCountCapped(state) {
const arrivals = state.visitorArrivals ?? [];
let visitorCount = arrivals.length;
if (visitorCount === 0 && getBilleterieCapacity(state) === 0) {
visitorCount = getVisitorCountFallback(state);
}
const billeterieCap = getBilleterieCapacity(state);
if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap;
return visitorCount;
}
/**
* Luxury shop multiplier component for souvenir bonus (>= 1).
* @returns {number}
*/
function getLuxuryShopMultiplier() {
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1;
if (luxuryChance > 0 && luxuryShopMult > 1) {
return 1 + luxuryChance * (luxuryShopMult - 1);
}
return 1;
}
/**
* Souvenir shop bonus multiplier (>= 1).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getSouvenirBonus(state) {
let shopCount = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1);
}
if (shopCount === 0) return 1;
const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2;
return (1 + shopCount * bonusPerShop) * getLuxuryShopMultiplier();
}
/**
* Luxury entry multiplier (>= 1).
* @returns {number}
*/
function getLuxuryEntryMultiplier() {
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1;
if (luxuryChance > 0 && luxuryEntryMult > 1) {
return 1 + luxuryChance * (luxuryEntryMult - 1);
}
return 1;
}
/**
* Payment per visitor (base × souvenir × luxury × season).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
function getPaymentPerVisitor(state) {
let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor;
paymentPerVisitor *= getSouvenirBonus(state);
paymentPerVisitor *= getLuxuryEntryMultiplier();
const ticketSeasonMult = getSeasonTicketPriceMultiplier(getCurrentSeason(state));
paymentPerVisitor *= ticketSeasonMult;
return paymentPerVisitor;
}
/**
* Visitor count and average payment per visitor per second. Includes luxury guest effect (LuxuryGuestChance, LuxuryEntryMultiplier, LuxuryShopMultiplier) in the average.
* @param {import("./types.js").GameState} state
* @returns {{ visitorCount: number, paymentPerVisitor: number }}
*/
function getVisitorParams(state) {
const arrivals = state.visitorArrivals ?? [];
let visitorCount = arrivals.length;
if (visitorCount === 0 && getBilleterieCapacity(state) === 0) {
let animalCount = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell.kind === "animal" && isOriginCell(key, cell)) animalCount += 1;
}
const visitorsPerAnimal = GameConfig.Visitor.VisitorsPerAnimal;
const plotBonus = (state.plotLevel ?? 1) * GameConfig.Visitor.PlotLevelBonus;
visitorCount = Math.max(0, Math.floor(animalCount * visitorsPerAnimal + plotBonus));
}
const billeterieCap = getBilleterieCapacity(state);
if (billeterieCap > 0 && visitorCount > billeterieCap) visitorCount = billeterieCap;
let paymentPerVisitor = GameConfig.Visitor.BasePaymentPerVisitor;
let souvenirBonus = 1;
let shopCount = 0;
for (const cell of Object.values(state.grid.cells)) {
if (cell && cell.kind === "souvenirShop") shopCount += (cell.level ?? 1);
}
if (shopCount > 0) {
const bonusPerShop = GameConfig.Visitor.SouvenirShopBonusPerShop ?? 0.2;
souvenirBonus = 1 + shopCount * bonusPerShop;
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryShopMult = GameConfig.Visitor.LuxuryShopMultiplier ?? 1;
if (luxuryChance > 0 && luxuryShopMult > 1) {
souvenirBonus *= 1 + luxuryChance * (luxuryShopMult - 1);
}
}
paymentPerVisitor *= souvenirBonus;
const luxuryChance = GameConfig.Visitor.LuxuryGuestChance ?? 0;
const luxuryEntryMult = GameConfig.Visitor.LuxuryEntryMultiplier ?? 1;
if (luxuryChance > 0 && luxuryEntryMult > 1) {
paymentPerVisitor *= 1 + luxuryChance * (luxuryEntryMult - 1);
}
const visitorCount = getVisitorCountCapped(state);
const paymentPerVisitor = getPaymentPerVisitor(state);
return { visitorCount, paymentPerVisitor };
}
@@ -220,28 +315,7 @@ export function getVisitorCount(state) {
* @returns {number}
*/
export function getAttractivityScore(state) {
const value = getTotalAnimalValue(state);
const originCount = getOriginAnimalCount(state);
const grid = state.grid;
const cellCount = grid.width * grid.height;
const fillRate = cellCount > 0 ? originCount / cellCount : 0;
const speciesSet = new Set();
let raritySum = 0;
for (const [key, cell] of Object.entries(state.grid.cells)) {
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip
} else {
speciesSet.add(cell.id);
const def = LootTables.Animals[cell.id];
if (def) raritySum += def.rarityLevel ?? 1;
}
}
const speciesCount = speciesSet.size;
const avgRarity = originCount > 0 ? raritySum / originCount : 0;
const valueNorm = value * 0.001;
const speciesNorm = speciesCount * 2;
const rarityNorm = avgRarity * 0.5;
const fillNorm = fillRate * 10;
const { valueNorm, speciesNorm, rarityNorm, fillNorm } = getAttractivityBase(state);
let score = valueNorm + speciesNorm + rarityNorm + fillNorm;
const deathPenalty = GameConfig.Visitor?.AttractivityDeathPenalty ?? 0.5;
const birthBonus = GameConfig.Visitor?.AttractivityBirthBonus ?? 0.2;

View File

@@ -19,53 +19,61 @@ function setMyZooId(id) {
myZooId = id;
}
async function runBootNoBase(rootEl) {
rootEl.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1>" +
"<p>Connectez-vous à un serveur pour jouer (compte et sauvegarde en base).</p>" +
"<div style=\"margin-top: 1rem;\"><label for=\"boot-api-url\">URL du serveur</label><input id=\"boot-api-url\" type=\"text\" placeholder=\"https://...\" style=\"display:block;margin-top:4px;width:100%;\" /></div>" +
"<button id=\"boot-connect\" type=\"button\" style=\"margin-top: 8px;\">Se connecter</button>" +
"<p id=\"boot-err\" class=\"boot-err\"></p></div>";
const urlInput = document.getElementById("boot-api-url");
try {
const stored = localStorage.getItem("builazoo_api_url");
if (stored) urlInput.value = stored;
} catch (_) {
// ignore localStorage
}
const errEl = document.getElementById("boot-err");
await new Promise((resolve) => {
document.getElementById("boot-connect").addEventListener("click", () => {
const url = urlInput.value.trim();
if (!url) {
errEl.textContent = "Indiquez l'URL du serveur.";
return;
}
setApiBaseUrl(url);
resolve();
});
});
rootEl.innerHTML = "";
}
async function runBootWithBase(rootEl) {
rootEl.innerHTML = "<div class=\"boot-panel\"><p>Chargement…</p></div>";
while (true) {
try {
state = await bootstrapFromApi(setMyZooId, rootEl);
break;
} catch (e) {
console.error("bootstrapFromApi failed", e);
rootEl.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1><p class=\"boot-err\">Erreur de connexion au serveur.</p><button id=\"boot-retry\" type=\"button\">Réessayer</button></div>";
const errP = rootEl.querySelector(".boot-err");
if (errP && e && e.message) errP.textContent = e.message;
await new Promise((resolve) => {
document.getElementById("boot-retry").addEventListener("click", () => resolve());
});
}
}
rootEl.innerHTML = "";
}
(async () => {
let base = getApiBase();
if (!base) {
root.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1>" +
"<p>Connectez-vous à un serveur pour jouer (compte et sauvegarde en base).</p>" +
"<div style=\"margin-top: 1rem;\"><label for=\"boot-api-url\">URL du serveur</label><input id=\"boot-api-url\" type=\"text\" placeholder=\"https://...\" style=\"display:block;margin-top:4px;width:100%;\" /></div>" +
"<button id=\"boot-connect\" type=\"button\" style=\"margin-top: 8px;\">Se connecter</button>" +
"<p id=\"boot-err\" class=\"boot-err\"></p></div>";
const urlInput = document.getElementById("boot-api-url");
try {
const stored = localStorage.getItem("builazoo_api_url");
if (stored) urlInput.value = stored;
} catch (_) {
// ignore localStorage
}
const errEl = document.getElementById("boot-err");
await new Promise((resolve) => {
document.getElementById("boot-connect").addEventListener("click", () => {
const url = urlInput.value.trim();
if (!url) {
errEl.textContent = "Indiquez l'URL du serveur.";
return;
}
setApiBaseUrl(url);
resolve();
});
});
root.innerHTML = "";
await runBootNoBase(root);
base = getApiBase();
}
if (base) {
root.innerHTML = "<div class=\"boot-panel\"><p>Chargement…</p></div>";
while (true) {
try {
state = await bootstrapFromApi(setMyZooId, root);
break;
} catch (e) {
console.error("bootstrapFromApi failed", e);
root.innerHTML = "<div class=\"boot-panel\"><h1>Construis un zoo</h1><p class=\"boot-err\">Erreur de connexion au serveur.</p><button id=\"boot-retry\" type=\"button\">Réessayer</button></div>";
const errP = root.querySelector(".boot-err");
if (errP && e && e.message) errP.textContent = e.message;
await new Promise((resolve) => {
document.getElementById("boot-retry").addEventListener("click", () => resolve());
});
}
}
root.innerHTML = "";
await runBootWithBase(root);
}
if (state) {

View File

@@ -12,18 +12,17 @@ import {
import { GameConfig } from "./config.js";
/**
* All keys that belong to the same animal block as the cell at (x, y). If not an animal or single-cell, returns [cellKey(x,y)].
* Origin and dimensions for an animal cell (possibly from originKey). Returns null if not animal.
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {string[]}
* @param {import("./types.js").Cell} cell
* @param {string} key
* @param {{ x: number, y: number }} pos
* @returns {{ ox: number, oy: number, w: number, h: number } | null}
*/
export function getBlockKeysFromCell(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "animal") return [key];
let ox = x;
let oy = y;
function getAnimalBlockOrigin(state, cell, key, pos) {
if (cell === null || cell === undefined || cell.kind !== "animal") return null;
let ox = pos.x;
let oy = pos.y;
let w = cell.cellsWide ?? 1;
let h = cell.cellsHigh ?? 1;
if (cell.originKey !== null && cell.originKey !== undefined) {
@@ -38,7 +37,41 @@ export function getBlockKeysFromCell(state, x, y) {
}
}
}
return getBlockKeys(ox, oy, w, h);
return { ox, oy, w, h };
}
/**
* All keys that belong to the same animal block as the cell at (x, y). If not an animal or single-cell, returns [cellKey(x,y)].
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {string[]}
*/
export function getBlockKeysFromCell(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
const origin = getAnimalBlockOrigin(state, cell, key, { x, y });
if (origin === null) return [key];
return getBlockKeys(origin.ox, origin.oy, origin.w, origin.h);
}
/**
* Build set of cell keys to exclude when placing (block at excludeOriginKey).
* @param {import("./types.js").GameState} state
* @param {string} [excludeOriginKey]
* @returns {Set<string>}
*/
function buildExcludeSet(state, excludeOriginKey) {
const excludeSet = new Set();
if (excludeOriginKey === null || excludeOriginKey === undefined) return excludeSet;
const orig = state.grid.cells[excludeOriginKey];
if (orig && orig.kind === "animal") {
const [ox, oy] = excludeOriginKey.split("_").map(Number);
const ow = orig.cellsWide ?? 1;
const oh = orig.cellsHigh ?? 1;
getBlockKeys(ox, oy, ow, oh).forEach((k) => excludeSet.add(k));
}
return excludeSet;
}
/**
@@ -49,16 +82,7 @@ export function getBlockKeysFromCell(state, x, y) {
*/
export function canPlaceMultiCell(state, opts) {
const { originX, originY, w, h, excludeOriginKey } = opts;
const excludeSet = new Set();
if (excludeOriginKey !== null && excludeOriginKey !== undefined) {
const orig = state.grid.cells[excludeOriginKey];
if (orig && orig.kind === "animal") {
const [ox, oy] = excludeOriginKey.split("_").map(Number);
const ow = orig.cellsWide ?? 1;
const oh = orig.cellsHigh ?? 1;
getBlockKeys(ox, oy, ow, oh).forEach((k) => excludeSet.add(k));
}
}
const excludeSet = buildExcludeSet(state, excludeOriginKey);
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
const nx = originX + dx;
@@ -124,6 +148,25 @@ export function placeEgg(state, opts) {
return [true, undefined];
}
/**
* Move an animal block from (ox,oy) to (toX, toY). Caller ensures source is animal and keys differ.
* @param {import("./types.js").GameState} state
* @param {{ blockKeys: string[], ox: number, oy: number, toX: number, toY: number, source: import("./types.js").AnimalCell }} opts
* @returns {[boolean, string?]}
*/
function moveAnimalBlock(state, opts) {
const { blockKeys, ox, oy, toX, toY, source } = opts;
const w = source.cellsWide ?? 1;
const h = source.cellsHigh ?? 1;
const originKey = cellKey(ox, oy);
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h, excludeOriginKey: originKey });
if (!ok) return [false, reason];
const animalData = { ...source, originKey: cellKey(toX, toY), cellsWide: w, cellsHigh: h };
for (const k of blockKeys) delete state.grid.cells[k];
fillAnimalBlock(state, toX, toY, animalData);
return [true, undefined];
}
/**
* Déplace le contenu d'une case vers une case vide (œuf ou animal). For multi-cell animals, moves the whole block.
* @param {import("./types.js").GameState} state
@@ -139,29 +182,9 @@ export function moveCell(state, opts) {
if (source === null || source === undefined) return [false, "NoSource"];
if (source.kind === "animal") {
const blockKeys = getBlockKeysFromCell(state, fromX, fromY);
let ox = fromX;
let oy = fromY;
let w = source.cellsWide ?? 1;
let h = source.cellsHigh ?? 1;
if (source.originKey !== null && source.originKey !== undefined) {
const m = source.originKey.match(/^(\d+)_(\d+)$/);
if (m) {
ox = Number(m[1]);
oy = Number(m[2]);
const origin = state.grid.cells[source.originKey];
if (origin && origin.kind === "animal") {
w = origin.cellsWide ?? 1;
h = origin.cellsHigh ?? 1;
}
}
}
const originKey = cellKey(ox, oy);
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h, excludeOriginKey: originKey });
if (!ok) return [false, reason];
const animalData = { ...source, originKey: toKey, cellsWide: w, cellsHigh: h };
for (const k of blockKeys) delete state.grid.cells[k];
fillAnimalBlock(state, toX, toY, animalData);
return [true, undefined];
const origin = getAnimalBlockOrigin(state, source, fromKey, { x: fromX, y: fromY });
if (origin === null) return [false, "NoSource"];
return moveAnimalBlock(state, { blockKeys, ox: origin.ox, oy: origin.oy, toX, toY, source });
}
const [ok, reason] = canPlace(state, toX, toY);
if (!ok) return [false, reason];

View File

@@ -18,15 +18,6 @@ function daySeed(dateKey) {
return h;
}
/**
* @param {import("./types.js").GameState} state
* @param {number} level
* @returns {number}
*/
function _questReward(state, level) {
return GameConfig.Quests.RewardBase + level * GameConfig.Quests.RewardPerLevel;
}
/**
* @param {import("./types.js").GameState} state
* @returns {import("./types.js").Quest[]}
@@ -54,7 +45,9 @@ export function generateDailyQuests(state) {
return state.quests;
}
/** @param {import("./types.js").GameState} state */
/** @param {import("./types.js").GameState} state
* @returns {{ eggsPlaced: number, animalsSold: number, conveyorUpgrades: number, plotUpgrades: number, coinsEarned: number }}
*/
function getQuestProgress(state) {
const s = state.stats ?? { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, coinsEarned: 0 };
return {
@@ -66,6 +59,14 @@ function getQuestProgress(state) {
};
}
const QUEST_PROGRESS_KEYS = {
questPlaceEggs: "eggsPlaced",
questSellAnimals: "animalsSold",
questUpgradeConveyor: "conveyorUpgrades",
questUpgradePlot: "plotUpgrades",
questEarnCoins: "coinsEarned",
};
/**
* @param {import("./types.js").GameState} state
* @returns {number} coins awarded from completed quests this tick
@@ -75,16 +76,10 @@ export function tickQuests(state) {
const progress = getQuestProgress(state);
let earned = 0;
for (const q of state.quests ?? []) {
if (q.done) {
// already done
} else {
let current = null;
if (q.descriptionKey === "questPlaceEggs") current = progress.eggsPlaced;
else if (q.descriptionKey === "questSellAnimals") current = progress.animalsSold;
else if (q.descriptionKey === "questUpgradeConveyor") current = progress.conveyorUpgrades;
else if (q.descriptionKey === "questUpgradePlot") current = progress.plotUpgrades;
else if (q.descriptionKey === "questEarnCoins") current = progress.coinsEarned ?? 0;
if (current !== null && current !== undefined) {
if (!q.done) {
const progressKey = QUEST_PROGRESS_KEYS[q.descriptionKey];
const current = progressKey !== undefined ? (progress[progressKey] ?? 0) : null;
if (current !== null) {
q.current = Math.min(current, q.target);
if (q.current >= q.target) {
q.done = true;

View File

@@ -9,6 +9,19 @@ import { cellKey, isOriginCell } from "./grid-utils.js";
import { getBlockKeysFromCell } from "./placement.js";
import { getDisplayBiome, getDisplayTemperature } from "./biome-rules.js";
import { addPendingBaby } from "./zoo.js";
import { getCurrentSeason, getSeasonReproductionBonus, getSeasonTemperatureModifier } from "./seasons.js";
/**
* Reproduction season bonus. In winter, cold-adapted biomes (Mountain) are exempt from the -50% malus (spec: sauf espèces adaptées).
* @param {string} season
* @param {import("./loot-tables.js").LootTables["Animals"][string]} def
* @returns {number}
*/
function getEffectiveReproductionSeasonBonus(season, def) {
const base = getSeasonReproductionBonus(season);
if (season === "winter" && base < 0 && def?.biome === "Mountain") return 0;
return base;
}
/**
* Zoo reproduction score (stub for phase 7). Higher = shorter delay until baby.
@@ -81,16 +94,17 @@ function blocksAreAdjacent(state, keyA, keyB) {
}
/**
* All eligible reproduction pairs: same animalId, at least one fromOtherZoo, adjacent.
* Returns unique pairs with keyA < keyB lexicographically.
* Collect origin animal entries (key, animalId, fromOtherZoo) from grid.
* @param {import("./types.js").GameState} state
* @returns {Array<{ keyA: string, keyB: string, animalId: string }>}
* @returns {Array<{ key: string, animalId: string, fromOtherZoo: boolean }>}
*/
export function findReproductionPairs(state) {
function collectOriginAnimals(state) {
const cells = state.grid.cells;
const origins = [];
for (const [key, cell] of Object.entries(cells)) {
if (cell !== null && cell !== undefined && cell.kind === "animal" && isOriginCell(key, cell)) {
if (cell === null || cell === undefined || cell.kind !== "animal" || !isOriginCell(key, cell)) {
// skip
} else {
const def = LootTables.Animals[cell.id];
if (def !== null && def !== undefined) {
origins.push({
@@ -101,6 +115,16 @@ export function findReproductionPairs(state) {
}
}
}
return origins;
}
/**
* Form unique pairs from origins (same animalId, at least one fromOtherZoo, adjacent).
* @param {import("./types.js").GameState} state
* @param {Array<{ key: string, animalId: string, fromOtherZoo: boolean }>} origins
* @returns {Array<{ keyA: string, keyB: string, animalId: string }>}
*/
function formReproductionPairs(state, origins) {
const pairs = [];
for (let i = 0; i < origins.length; i++) {
for (let j = i + 1; j < origins.length; j++) {
@@ -116,6 +140,17 @@ export function findReproductionPairs(state) {
return pairs;
}
/**
* All eligible reproduction pairs: same animalId, at least one fromOtherZoo, adjacent.
* Returns unique pairs with keyA < keyB lexicographically.
* @param {import("./types.js").GameState} state
* @returns {Array<{ keyA: string, keyB: string, animalId: string }>}
*/
export function findReproductionPairs(state) {
const origins = collectOriginAnimals(state);
return formReproductionPairs(state, origins);
}
/**
* Unique pair key for deduplication.
* @param {string} keyA
@@ -196,13 +231,16 @@ function addNewPairsToTimers(state, nowUnix, timers, existingSet) {
const m1 = keyA.match(/^(\d+)_(\d+)$/);
const m2 = keyB.match(/^(\d+)_(\d+)$/);
if (m1 && m2) {
const season = getCurrentSeason(state);
const seasonTempMod = getSeasonTemperatureModifier(season);
const biome1 = getDisplayBiome(Number(m1[1]), Number(m1[2]), grid);
const biome2 = getDisplayBiome(Number(m2[1]), Number(m2[2]), grid);
const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid);
const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid);
const temp1 = getDisplayTemperature(Number(m1[1]), Number(m1[2]), grid) + seasonTempMod;
const temp2 = getDisplayTemperature(Number(m2[1]), Number(m2[2]), grid) + seasonTempMod;
const biomeFactor = (getBiomeReproductionFactor(def, biome1) + getBiomeReproductionFactor(def, biome2)) / 2;
const tempFactor = (getTemperatureFactor(def, temp1) + getTemperatureFactor(def, temp2)) / 2;
const factor = Math.max(0.2, score * biomeFactor * tempFactor);
const seasonBonus = getEffectiveReproductionSeasonBonus(season, def);
const factor = Math.max(0.2, score * biomeFactor * tempFactor * (1 + seasonBonus));
const delay = Math.max(5, baseSeconds / factor);
timers.push({ keyA, keyB, animalId, dueAt: nowUnix + Math.floor(delay) });
existingSet.add(pk);

79
web/js/seasons.js Normal file
View File

@@ -0,0 +1,79 @@
/**
* Season cycle: 4 seasons from game day. Used for temperature, visitors, reproduction, billeterie.
* Refs: docs/specs/inventaire_saisons.md, temperature.md, visiteur.md, reproduction.md, billeterie.md.
*/
import { GameConfig } from "./config.js";
/** @typedef {"spring"|"summer"|"autumn"|"winter"} SeasonId */
const SEASON_ORDER = /** @type {SeasonId[]} */ (["spring", "summer", "autumn", "winter"]);
/**
* Current season from state game day.
* @param {import("./types.js").GameState} state
* @returns {SeasonId}
*/
export function getCurrentSeason(state) {
const cfg = GameConfig.Season;
if (!cfg || !cfg.DaysPerSeason) return "spring";
const gameDay = state.gameDayTotal ?? 0;
const seasonIndex = Math.floor(gameDay / cfg.DaysPerSeason) % 4;
return SEASON_ORDER[seasonIndex] ?? "spring";
}
/**
* Day index within current season (0..DaysPerSeason-1).
* @param {import("./types.js").GameState} state
* @returns {number}
*/
export function getSeasonDay(state) {
const cfg = GameConfig.Season;
if (!cfg || !cfg.DaysPerSeason) return 0;
const gameDay = state.gameDayTotal ?? 0;
return gameDay % cfg.DaysPerSeason;
}
/**
* Temperature modifier for display/calculations (°C). From temperature.md.
* @param {SeasonId} season
* @returns {number}
*/
export function getSeasonTemperatureModifier(season) {
const cfg = GameConfig.Season?.TemperatureModifier;
if (!cfg) return 0;
return cfg[season] ?? 0;
}
/**
* Visitor demand multiplier. From visiteur.md (Impact Saisons).
* @param {SeasonId} season
* @returns {number}
*/
export function getSeasonVisitorMultiplier(season) {
const cfg = GameConfig.Season?.VisitorMultiplier;
if (!cfg) return 1;
return cfg[season] ?? 1;
}
/**
* Reproduction chance bonus (e.g. spring +0.2, winter -0.5). From reproduction.md.
* @param {SeasonId} season
* @returns {number}
*/
export function getSeasonReproductionBonus(season) {
const cfg = GameConfig.Season?.ReproductionBonus;
if (!cfg) return 0;
return cfg[season] ?? 0;
}
/**
* Billeterie ticket price multiplier (summer +20%, winter -10%). From billeterie.md.
* @param {SeasonId} season
* @returns {number}
*/
export function getSeasonTicketPriceMultiplier(season) {
const cfg = GameConfig.Season?.TicketPriceMultiplier;
if (!cfg) return 1;
return cfg[season] ?? 1;
}

View File

@@ -54,14 +54,19 @@ function buildDefaultCells() {
return buildDefaultRow1Cells();
}
function getDefaultStats() {
return { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 };
}
function buildStatePayload(width, height, worldZoos, cells) {
const grid = { width, height, cells };
return {
version: GameConfig.StateVersion,
coins: 200,
conveyorLevel: 1,
plotLevel: 1,
truckLevel: 1,
grid: { width, height, cells },
grid,
pendingEggTokens: [],
nextTokenId: 1,
conveyorOffers: [],
@@ -73,15 +78,14 @@ function buildStatePayload(width, height, worldZoos, cells) {
laboratoryOffer: null,
prestigeLevel: 0,
timeOfDay: 6,
gameDayTotal: 0,
lastSeason: "spring",
weather: "sun",
lastWeatherChangeAt: 0,
quests: [],
lastQuestDay: "",
stats: { eggsPlaced: 0, animalsSold: 0, conveyorUpgrades: 0, plotUpgrades: 0, truckUpgrades: 0, coinsEarned: 0 },
mapZoom: 1,
mapPanX: 0,
mapPanY: 0,
worldMapLevel: 1,
stats: getDefaultStats(),
mapZoom: 1, mapPanX: 0, mapPanY: 0, worldMapLevel: 1,
autoMode: false,
autoModeProfile: "balanced",
researchPoints: 0,
@@ -145,16 +149,22 @@ function applyLoadStateWorldZoos(data) {
data.worldZoos = [...GameConfig.WorldMap.Zoos];
}
if (data.worldZoos !== null && data.worldZoos !== undefined && Array.isArray(data.worldZoos)) {
const keys = getColorNames();
data.worldZoos = data.worldZoos.map((z, _i) => ({
...z,
animalWeights: z.animalWeights && keys.some((k) => k in (z.animalWeights ?? {}))
? z.animalWeights
: normalizeZooWeights(z.animalWeights),
}));
data.worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player"));
applyWorldZoosWeightsAndBots(data);
}
if (data.worldZoos === null || data.worldZoos === undefined) data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }];
if (data.worldZoos === null || data.worldZoos === undefined) {
data.worldZoos = [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }];
}
}
function applyWorldZoosWeightsAndBots(data) {
const keys = getColorNames();
data.worldZoos = data.worldZoos.map((z) => ({
...z,
animalWeights: z.animalWeights && keys.some((k) => k in (z.animalWeights ?? {}))
? z.animalWeights
: normalizeZooWeights(z.animalWeights),
}));
data.worldZoos.forEach((zoo) => ensureBotState(zoo, zoo.id === "player"));
}
/** Set data[key] to defaultVal when data[key] is null or undefined. defaultVal may be a function (called for value). */
@@ -170,6 +180,8 @@ const LOAD_STATE_SCALAR_DEFAULTS = [
["nextTokenId", 1],
["prestigeLevel", 0],
["timeOfDay", 6],
["gameDayTotal", 0],
["lastSeason", "spring"],
["weather", "sun"],
["lastWeatherChangeAt", 0],
["quests", []],
@@ -211,15 +223,24 @@ function applyLoadStateLegacyCells(data) {
if (c12 && (c12.kind === "plotUpgrade" || c12.kind === "worldMapUpgrade")) delete data.grid.cells["1_2"];
}
function normalizeOneAnimalCell(cell, now) {
if (cell.kind !== "animal") return;
if (cell.id && !LootTables.Animals[cell.id]) cell.id = "c0_r0";
if (cell.lastVisitedAt === null || cell.lastVisitedAt === undefined) cell.lastVisitedAt = now;
if (cell.lastFedAt === null || cell.lastFedAt === undefined) cell.lastFedAt = cell.placedAt ?? now;
}
function normalizeOneEggCell(cell) {
if (cell.kind === "egg" && cell.eggType && !LootTables.EggTypes[cell.eggType]) cell.eggType = "Color_1";
}
function normalizeLoadedCells(cells) {
const now = Math.floor(Date.now() / 1000);
for (const key of Object.keys(cells)) {
const cell = cells[key];
if (cell) {
if (cell.kind === "animal" && cell.id && !LootTables.Animals[cell.id]) cell.id = "c0_r0";
if (cell.kind === "animal" && (cell.lastVisitedAt === null || cell.lastVisitedAt === undefined)) cell.lastVisitedAt = now;
if (cell.kind === "animal" && (cell.lastFedAt === null || cell.lastFedAt === undefined)) cell.lastFedAt = cell.placedAt ?? now;
if (cell.kind === "egg" && cell.eggType && !LootTables.EggTypes[cell.eggType]) cell.eggType = "Color_1";
if (cell.kind === "animal") normalizeOneAnimalCell(cell, now);
if (cell.kind === "egg") normalizeOneEggCell(cell);
}
}
}

View File

@@ -98,6 +98,8 @@ export const weatherLabel = { sun: "Ensoleillé", cloudy: "Nuageux", rain: "Plui
export const prestigeLabel = "Prestige (reset avec bonus permanent)";
export const prestigeButton = "Réinitialiser (Prestige +%d)";
export const prestigeHint = "Réinitialise tout et ajoute un bonus permanent de revenus. Coût min. : %d pièces.";
export const seasonLabel = { spring: "Printemps", summer: "Été", autumn: "Automne", winter: "Hiver" };
export const seasonChangeToast = "C'est le %s !";
export const visitorsLabel = "Visiteurs";
export const musicLabel = "Musique";
export const incidentLabel = {

View File

@@ -7,7 +7,12 @@ import { GameConfig } from "./config.js";
export function tickTime(state, dtWallSeconds) {
const dayLength = GameConfig.Time.DayLengthSeconds;
const phase = (state.timeOfDay ?? 6) + (dtWallSeconds * 24) / dayLength;
state.timeOfDay = phase >= 24 ? phase - 24 : phase;
if (phase >= 24) {
state.timeOfDay = phase - 24;
state.gameDayTotal = (state.gameDayTotal ?? 0) + 1;
} else {
state.timeOfDay = phase;
}
}
/**

View File

@@ -91,7 +91,7 @@ export function addReceptionAnimalToSale(state, receptionCellKey) {
/**
* Remove expired sale listings. If listing was a baby (isBaby), increment deathCountRecent (bébé invendu meurt).
* Call from game loop each tick.
* If listing was an adult (isBaby false), also increment deathCountRecent (vente échouée = mort adulte).
* @param {import("./types.js").GameState} state
* @param {number} nowUnix
*/
@@ -99,24 +99,29 @@ export function tickSaleListings(state, nowUnix) {
const listings = state.saleListings ?? [];
const kept = [];
let babyDeaths = 0;
let adultDeaths = 0;
for (const listing of listings) {
if (nowUnix < listing.endAt) {
kept.push(listing);
} else if (listing.isBaby) {
babyDeaths += 1;
} else {
adultDeaths += 1;
}
}
state.saleListings = kept;
if (babyDeaths > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + babyDeaths;
const totalDeaths = babyDeaths + adultDeaths;
if (totalDeaths > 0) state.deathCountRecent = (state.deathCountRecent ?? 0) + totalDeaths;
}
/**
* Compute sell result for animal at (x,y). Returns [false, reason] or [true, { blockKeys, sellValue }].
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, number | string]}
* @returns {[false, string] | [true, { blockKeys: string[], sellValue: number }]}
*/
export function sellAnimalToNpc(state, x, y) {
function getSellAnimalResult(state, x, y) {
const key = cellKey(x, y);
const cell = state.grid.cells[key];
if (cell === null || cell === undefined || cell.kind !== "animal") return [false, "NoAnimal"];
@@ -133,6 +138,19 @@ export function sellAnimalToNpc(state, x, y) {
mutationMultiplier,
animalDef.sellFactor
);
return [true, { blockKeys, sellValue }];
}
/**
* @param {import("./types.js").GameState} state
* @param {number} x
* @param {number} y
* @returns {[boolean, number | string]}
*/
export function sellAnimalToNpc(state, x, y) {
const result = getSellAnimalResult(state, x, y);
if (result[0] === false) return [false, result[1]];
const { blockKeys, sellValue } = result[1];
for (const k of blockKeys) delete state.grid.cells[k];
state.coins += sellValue;
state.lastEvolutionAt = Math.floor(Date.now() / 1000);

View File

@@ -43,6 +43,9 @@
* laboratoryOffer?: { eggType: string, price: number, endAt: number } | null,
* prestigeLevel?: number,
* timeOfDay?: number,
* gameDayTotal?: number,
* lastSeason?: string,
* seasonChangeMessage?: string,
* weather?: string,
* lastWeatherChangeAt?: number,
* quests?: Quest[],
@@ -72,7 +75,6 @@
* feedingRate?: number,
* reproductionScore?: number,
* attractivityScore?: number,
* attractivityScore?: number,
* reproductionTimers?: Array<{ keyA: string, keyB: string, animalId: string, dueAt: number }>,
* visitorArrivals?: VisitorEntry[],
* attractivityBonusFromIncidents?: number,

273
web/js/ui-grid-cells.js Normal file
View File

@@ -0,0 +1,273 @@
import {
getNurseryBuildCost,
getSouvenirShopBuildCost,
getResearchBuildCost,
getBilleterieBuildCost,
getFoodBuildCost,
getReceptionBuildCost,
getBiomeChangeColorBuildCost,
getBiomeChangeTempBuildCost,
getSchoolUpgradeCost,
getReceptionUpgradeCost,
getNurseryUpgradeCost,
getSouvenirShopUpgradeCost,
getResearchUpgradeCost,
getBilleterieUpgradeCost,
getFoodUpgradeCost,
getBiomeChangeColorUpgradeCost,
getBiomeChangeTempUpgradeCost,
} from "./economy.js";
import { getAnimalVisualState } from "./animal-visual-state.js";
import { GameConfig } from "./config.js";
import { eggTypeLabel, animalLabel } from "./texts-fr.js";
const EGG_EMOJI = "🥚";
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, animalEmoji: Record<string, string> }} ctx
* @param {HTMLElement} divEl
* @param {import("./types.js").ReceptionCell} receptionCell
* @param {string} cellKey
*/
export function fillReceptionCell(ctx, divEl, receptionCell, cellKey) {
const { state, animalEmoji } = ctx;
divEl.classList.add("reception");
const level = receptionCell.level ?? 1;
const maxLevel = GameConfig.Reception?.MaxLevel ?? 7;
const canUpgrade = level < maxLevel && state.coins >= getReceptionUpgradeCost(level);
if (canUpgrade) divEl.classList.add("can-upgrade");
const recAnimal = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === cellKey);
const nowUnix = Math.floor(Date.now() / 1000);
if (recAnimal) {
const isReady = nowUnix >= recAnimal.readyAt;
const emoji = animalEmoji[recAnimal.animalId] ?? "🐾";
const label = isReady ? "Animal prêt" : "Acclimatation…";
divEl.classList.add("cell-draggable");
divEl.draggable = isReady;
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
divEl.innerHTML = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>${arrow}`;
if (isReady) divEl.dataset.receptionCellKey = cellKey;
} else {
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
divEl.innerHTML = `<span class="cell-emoji">📥</span><span class="cell-label">Accueil ${level}</span>${arrow}`;
}
}
/**
* @param {{ state: import("./types.js").GameState, animalEmoji: Record<string, string> }} ctx
* @param {HTMLElement} divEl
* @param {import("./types.js").NurseryCell} nurseryCell
* @param {string} cellKey
*/
export function fillNurseryCell(ctx, divEl, nurseryCell, cellKey) {
const { state, animalEmoji } = ctx;
divEl.classList.add("nursery");
const nurseryLevel = nurseryCell.level ?? 1;
const nurseryMax = GameConfig.Nursery?.MaxLevel ?? 7;
const canUpgradeNursery = nurseryLevel < nurseryMax && state.coins >= getNurseryUpgradeCost(nurseryLevel);
if (canUpgradeNursery) divEl.classList.add("can-upgrade");
const pendingBaby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === cellKey);
const token = nurseryCell.tokenId !== null && nurseryCell.tokenId !== undefined
? state.pendingEggTokens.find((tok) => tok.tokenId === nurseryCell.tokenId) : null;
if (pendingBaby) {
const nowUnix = Math.floor(Date.now() / 1000);
const isMature = nowUnix >= pendingBaby.readyAt;
const emoji = animalEmoji[pendingBaby.animalId] ?? "🐾";
const label = isMature ? "Bébé prêt" : "Bébé…";
divEl.classList.add("cell-draggable");
divEl.draggable = isMature;
divEl.innerHTML = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>`;
if (isMature) divEl.dataset.nurseryCellKey = cellKey;
} else if (token) {
divEl.classList.add("cell-draggable");
divEl.draggable = true;
const label = eggTypeLabel[token.eggType] ?? token.eggType;
divEl.innerHTML = `<span class="cell-emoji">${EGG_EMOJI}</span><span class="cell-label">${label}</span>`;
divEl.dataset.tokenId = String(nurseryCell.tokenId);
} else {
const arrow = canUpgradeNursery ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
divEl.innerHTML = `<span class="cell-emoji">🐣</span><span class="cell-label">Nurserie ${nurseryLevel}</span>${arrow}`;
}
}
/**
* @param {{ state: import("./types.js").GameState, animalEmoji: Record<string, string> }} ctx
* @param {HTMLElement} divEl
* @param {import("./types.js").AnimalCell} animalCell
* @param {string} cellKey
*/
export function fillAnimalCell(ctx, divEl, animalCell, cellKey) {
const { state, animalEmoji } = ctx;
divEl.classList.add("animal", "cell-draggable");
divEl.draggable = true;
const w = animalCell.cellsWide ?? 1;
const h = animalCell.cellsHigh ?? 1;
const isMulti = w > 1 || h > 1;
const isOrigin = animalCell.originKey === null || animalCell.originKey === undefined || animalCell.originKey === cellKey;
const originKey = isOrigin ? cellKey : (animalCell.originKey ?? cellKey);
const originCell = isOrigin ? animalCell : state.grid.cells[originKey];
if (originCell && originCell.kind === "animal") {
const visual = getAnimalVisualState(originCell, state, state.grid, originKey);
if (visual.cold) divEl.classList.add("animal-cold");
if (visual.hot) divEl.classList.add("animal-hot");
if (visual.hungry) divEl.classList.add("animal-hungry");
if (visual.sick) divEl.classList.add("animal-sick");
if (visual.happy) divEl.classList.add("animal-happy");
}
if (isMulti) divEl.classList.add("multi-cell");
if (isMulti && isOrigin) divEl.classList.add("multi-cell-origin");
const emoji = animalEmoji[animalCell.id] ?? "🐾";
const label = animalLabel[animalCell.id] ?? animalCell.id;
divEl.innerHTML = `<span class="cell-emoji">${emoji}</span><span class="cell-label">${label}</span>`;
}
/**
* @param {{ state: import("./types.js").GameState }} ctx
* @param {HTMLElement} div
* @param {number} x
* @param {number} y
*/
export function fillEmptyCell(ctx, div, x, y) {
const { state } = ctx;
const emptyCellChoice = ctx.emptyCellChoice.current;
div.classList.add("empty");
if (emptyCellChoice && emptyCellChoice.x === x && emptyCellChoice.y === y) {
const nurseryCost = getNurseryBuildCost();
const shopCost = getSouvenirShopBuildCost();
const researchCost = getResearchBuildCost();
const billeterieCost = getBilleterieBuildCost();
const foodCost = getFoodBuildCost();
const receptionCost = getReceptionBuildCost();
const biomeColorCost = getBiomeChangeColorBuildCost();
const biomeTempCost = getBiomeChangeTempBuildCost();
const canNursery = state.coins >= nurseryCost;
const canShop = state.coins >= shopCost;
const canResearch = state.coins >= researchCost;
const canBilleterie = state.coins >= billeterieCost;
const canFood = state.coins >= foodCost;
const canReception = state.coins >= receptionCost;
const canBiomeColor = state.coins >= biomeColorCost;
const canBiomeTemp = state.coins >= biomeTempCost;
div.innerHTML = `<button type="button" class="cell-choice-btn" data-choice="nursery" ${canNursery ? "" : "disabled"}>🐣 Nurserie (${nurseryCost})</button><button type="button" class="cell-choice-btn" data-choice="shop" ${canShop ? "" : "disabled"}>🛒 Boutique (${shopCost})</button><button type="button" class="cell-choice-btn" data-choice="research" ${canResearch ? "" : "disabled"}>🔬 Recherche (${researchCost})</button><button type="button" class="cell-choice-btn" data-choice="billeterie" ${canBilleterie ? "" : "disabled"}>🎫 Billeterie (${billeterieCost})</button><button type="button" class="cell-choice-btn" data-choice="food" ${canFood ? "" : "disabled"}>🥗 Nourriture (${foodCost})</button><button type="button" class="cell-choice-btn" data-choice="reception" ${canReception ? "" : "disabled"}>📥 Accueil (${receptionCost})</button><button type="button" class="cell-choice-btn" data-choice="biomeColor" ${canBiomeColor ? "" : "disabled"}>🎨 Couleur (${biomeColorCost})</button><button type="button" class="cell-choice-btn" data-choice="biomeTemp" ${canBiomeTemp ? "" : "disabled"}>🌡️ Temp (${biomeTempCost})</button>`;
div.classList.add("empty-choice");
} else {
div.textContent = "";
}
}
function fillSchoolCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("school");
const schoolMaxLevel = (GameConfig.School && GameConfig.School.MaxLevel) || 8;
const canUpgradeSchool = cell.level < schoolMaxLevel && state.coins >= getSchoolUpgradeCost(cell.level);
if (canUpgradeSchool) div.classList.add("can-upgrade");
const arrow = canUpgradeSchool ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🏫</span><span class="cell-label">École ${cell.level}</span>${arrow}`;
}
function fillSouvenirShopCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("souvenir-shop");
const shopLevel = cell.level ?? 1;
const shopMax = GameConfig.SouvenirShop?.MaxLevel ?? 7;
const canUpgradeShop = shopLevel < shopMax && state.coins >= getSouvenirShopUpgradeCost(shopLevel);
if (canUpgradeShop) div.classList.add("can-upgrade");
const arrow = canUpgradeShop ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🛒</span><span class="cell-label">Boutique ${shopLevel}</span>${arrow}`;
}
function fillResearchCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("research");
const level = cell.level ?? 1;
const maxLevel = GameConfig.Research?.MaxLevel ?? 7;
const canUpgrade = level < maxLevel && state.coins >= getResearchUpgradeCost(level);
if (canUpgrade) div.classList.add("can-upgrade");
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🔬</span><span class="cell-label">Recherche ${level}</span>${arrow}`;
}
function fillBilleterieCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("billeterie");
const level = cell.level ?? 1;
const maxLevel = GameConfig.Billeterie?.MaxLevel ?? 7;
const canUpgrade = level < maxLevel && state.coins >= getBilleterieUpgradeCost(level);
if (canUpgrade) div.classList.add("can-upgrade");
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🎫</span><span class="cell-label">Billeterie ${level}</span>${arrow}`;
}
function fillFoodCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("food");
const level = cell.level ?? 1;
const maxLevel = GameConfig.Food?.MaxLevel ?? 7;
const canUpgrade = level < maxLevel && state.coins >= getFoodUpgradeCost(level);
if (canUpgrade) div.classList.add("can-upgrade");
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🥗</span><span class="cell-label">Nourriture ${level}</span>${arrow}`;
}
function fillBiomeChangeColorCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("biome-change-color");
const level = cell.level ?? 1;
const maxLevel = GameConfig.BiomeChangeColor?.MaxLevel ?? 7;
const canUpgrade = level < maxLevel && state.coins >= getBiomeChangeColorUpgradeCost(level);
if (canUpgrade) div.classList.add("can-upgrade");
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🎨</span><span class="cell-label">Couleur ${level}</span>${arrow}`;
}
function fillBiomeChangeTempCell(ctx, div, cell) {
const { state } = ctx;
div.classList.add("biome-change-temp");
const level = cell.level ?? 1;
const maxLevel = GameConfig.BiomeChangeTemp?.MaxLevel ?? 7;
const canUpgrade = level < maxLevel && state.coins >= getBiomeChangeTempUpgradeCost(level);
if (canUpgrade) div.classList.add("can-upgrade");
const arrow = canUpgrade ? '<span class="cell-upgrade-arrow" aria-hidden="true">▲</span>' : "";
div.innerHTML = `<span class="cell-emoji">🌡️</span><span class="cell-label">Temp ${level}</span>${arrow}`;
}
function fillEggCell(ctx, div, cell) {
div.classList.add("egg", "cell-draggable");
div.draggable = true;
const label = eggTypeLabel[cell.eggType] ?? cell.eggType;
div.innerHTML = `<span class="cell-emoji">${EGG_EMOJI}</span><span class="cell-label">${label}</span>`;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { current: { x: number, y: number } | null }, selectedTokenId: { current: number | null }, lastActionWasDrop: { current: boolean }, clampSelection: () => void, animalEmoji: Record<string, string> }} ctx
* @param {HTMLElement} div
* @param {{ cell: import("./types.js").GridCell | null | undefined, key: string, x: number, y: number }} cellInfo
*/
export function fillCellContent(ctx, div, cellInfo) {
const { cell, key, x, y } = cellInfo;
if (cell === null || cell === undefined) {
fillEmptyCell(ctx, div, x, y);
return;
}
const filler = FILL_BY_KIND[cell.kind];
if (filler) {
filler(ctx, div, cell, key);
return;
}
fillAnimalCell(ctx, div, cell, key);
}
const FILL_BY_KIND = {
school: (ctx, div, cell) => fillSchoolCell(ctx, div, cell),
nursery: (ctx, div, cell, key) => fillNurseryCell(ctx, div, cell, key),
souvenirShop: (ctx, div, cell) => fillSouvenirShopCell(ctx, div, cell),
research: (ctx, div, cell) => fillResearchCell(ctx, div, cell),
billeterie: (ctx, div, cell) => fillBilleterieCell(ctx, div, cell),
food: (ctx, div, cell) => fillFoodCell(ctx, div, cell),
reception: (ctx, div, cell, key) => fillReceptionCell(ctx, div, cell, key),
biomeChangeColor: (ctx, div, cell) => fillBiomeChangeColorCell(ctx, div, cell),
biomeChangeTemp: (ctx, div, cell) => fillBiomeChangeTempCell(ctx, div, cell),
egg: (ctx, div, cell) => fillEggCell(ctx, div, cell),
};
export { EGG_EMOJI };

136
web/js/ui-grid-drag.js Normal file
View File

@@ -0,0 +1,136 @@
/**
* Drag-related helpers for grid cells: set DataTransfer for dragstart, attach drag listeners, handle drop.
*/
import { tryBuyEgg, tryPlaceEgg, placeMatureBabyOnCell, placeReceptionAnimalOnCell } from "./zoo.js";
import { moveCell } from "./placement.js";
import { t, errorMessage } from "./texts-fr.js";
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDrop: { current: boolean } }} ctx
* @param {DragEvent} e
* @param {{ x: number, y: number, empty: boolean }} target
* @returns {boolean}
*/
export function handleCellDropNurseryReceptionToken(ctx, e, target) {
const { state, setState, setError, playSound } = ctx;
const { x: toX, y: toY, empty } = target;
const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key");
if (nurseryCellKey && empty) {
const nowUnix = Math.floor(Date.now() / 1000);
const [ok, reason] = placeMatureBabyOnCell(state, { nurseryCellKey, toX, toY, nowUnix });
if (ok) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
ctx.lastActionWasDrop.current = true;
setState();
return true;
}
const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key");
if (receptionCellKey && empty) {
const nowUnix = Math.floor(Date.now() / 1000);
const [ok, reason] = placeReceptionAnimalOnCell(state, { receptionCellKey, toX, toY, nowUnix });
if (ok) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
ctx.lastActionWasDrop.current = true;
setState();
return true;
}
const tokenIdStr = e.dataTransfer.getData("application/x-builazoo-tokenid");
if (tokenIdStr && empty) {
const tokenId = Number(tokenIdStr);
if (!Number.isNaN(tokenId)) {
const nowUnix = Math.floor(Date.now() / 1000);
const [placeOk, placeReason] = tryPlaceEgg(state, { tokenId, x: toX, y: toY, nowUnix });
if (placeOk) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[placeReason] ?? placeReason)); playSound("error"); }
ctx.lastActionWasDrop.current = true;
setState();
}
return true;
}
return false;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDrop: { current: boolean } }} ctx
* @param {DragEvent} e
* @param {{ x: number, y: number, empty: boolean }} target
* @returns {boolean}
*/
export function handleCellDropEggTypeOrMove(ctx, e, target) {
const { state, setState, setError, playSound } = ctx;
const { x: toX, y: toY, empty } = target;
const eggTypeFromConveyor = e.dataTransfer.getData("application/x-builazoo-eggtype");
if (eggTypeFromConveyor && empty) {
const [buyOk, buyResult] = tryBuyEgg(state, eggTypeFromConveyor);
if (!buyOk) { setError(String(t.buyFailed).replace("%s", errorMessage[buyResult] ?? buyResult)); playSound("error"); } else {
const tokenId = buyResult.tokenId;
const nowUnix = Math.floor(Date.now() / 1000);
const [placeOk, placeReason] = tryPlaceEgg(state, { tokenId, x: toX, y: toY, nowUnix });
if (placeOk) { setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[placeReason] ?? placeReason)); playSound("error"); }
}
ctx.lastActionWasDrop.current = true;
setState();
return true;
}
const raw = e.dataTransfer.getData("text/plain");
if (!raw || !/^\d+_\d+$/.test(raw)) return true;
const [sx, sy] = raw.split("_").map(Number);
const [ok, reason] = moveCell(state, { fromX: sx, fromY: sy, toX, toY });
if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); else setError("");
ctx.lastActionWasDrop.current = true;
setState();
return true;
}
/**
* @param {HTMLElement} div
* @param {import("./types.js").GridCell | null | undefined} cell
* @param {{ x: number, y: number }} pos
* @param {DataTransfer} dt
*/
export function setCellDragData(div, cell, pos, dt) {
const { x, y } = pos;
let dragX = x;
let dragY = y;
if (div.dataset.nurseryCellKey) {
dt.setData("application/x-builazoo-nursery-cell-key", div.dataset.nurseryCellKey);
dt.effectAllowed = "move";
} else if (div.dataset.receptionCellKey) {
dt.setData("application/x-builazoo-reception-cell-key", div.dataset.receptionCellKey);
dt.effectAllowed = "move";
} else if (cell && cell.kind === "animal" && cell.originKey !== null && cell.originKey !== undefined) {
const m = cell.originKey.match(/^(\d+)_(\d+)$/);
if (m) { dragX = Number(m[1]); dragY = Number(m[2]); }
}
if (!div.dataset.nurseryCellKey && !div.dataset.receptionCellKey) dt.setData("text/plain", `${dragX}_${dragY}`);
if (cell && cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) {
dt.setData("application/x-builazoo-tokenid", String(cell.tokenId));
}
dt.effectAllowed = dt.effectAllowed || "move";
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement }} ctx
* @param {{ div: HTMLElement, cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts
*/
export function attachDragListeners(ctx, opts) {
const { div, cell } = opts;
const { gridEl } = ctx;
const isDraggable = cell !== null && cell !== undefined && (
cell.kind === "egg" || cell.kind === "animal" ||
(cell.kind === "nursery" && (cell.tokenId !== null && cell.tokenId !== undefined || div.dataset.nurseryCellKey)) ||
(cell.kind === "reception" && div.dataset.receptionCellKey)
);
if (!isDraggable) return;
div.addEventListener("dragstart", (e) => {
setCellDragData(div, cell, { x: opts.x, y: opts.y }, e.dataTransfer);
div.classList.add("dragging");
const ghost = div.cloneNode(true);
ghost.classList.add("drag-ghost");
ghost.style.opacity = "1";
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 24, 24);
div.addEventListener("dragend", () => { ghost.remove(); }, { once: true });
});
div.addEventListener("dragend", () => {
div.classList.remove("dragging");
gridEl.querySelectorAll(".cell").forEach((c) => c.classList.remove("dragover"));
});
}

257
web/js/ui-grid-handlers.js Normal file
View File

@@ -0,0 +1,257 @@
import { tryPlaceEgg, getNurseryCellKeysOrdered } from "./zoo.js";
import { tryUpgradeSchool } from "./conveyor.js";
import {
tryBuildNursery,
tryBuildSouvenirShop,
tryBuildResearch,
tryBuildBilleterie,
tryBuildFood,
tryBuildReception,
tryBuildBiomeChangeColor,
tryBuildBiomeChangeTemp,
tryUpgradeNursery,
tryUpgradeSouvenirShop,
tryUpgradeResearch,
tryUpgradeBilleterie,
tryUpgradeFood,
tryUpgradeReception,
tryUpgradeBiomeChangeColor,
tryUpgradeBiomeChangeTemp,
} from "./placement.js";
import { t, errorMessage } from "./texts-fr.js";
import { attachDragListeners, handleCellDropNurseryReceptionToken, handleCellDropEggTypeOrMove } from "./ui-grid-drag.js";
const BUILD_FNS = [
["nursery", tryBuildNursery],
["shop", tryBuildSouvenirShop],
["research", tryBuildResearch],
["billeterie", tryBuildBilleterie],
["food", tryBuildFood],
["reception", tryBuildReception],
["biomeColor", tryBuildBiomeChangeColor],
["biomeTemp", tryBuildBiomeChangeTemp],
];
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, selected: { x: number, y: number } }} ctx
* @param {string} choice
* @param {number} x
* @param {number} y
*/
export function handleCellClickChoice(ctx, choice, x, y) {
const { state, setError } = ctx;
for (const [name, fn] of BUILD_FNS) {
if (choice === name) {
const [ok, reason] = fn(state, x, y);
if (!ok) setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason));
else setError("");
ctx.emptyCellChoice.current = null;
return;
}
}
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} ctx
* @param {import("./types.js").GridCell} cell
* @param {number} x
* @param {number} y
* @returns {boolean}
*/
export function handleCellClickUpgradeShops(ctx, cell, x, y) {
const { state, setState, setError, playSound } = ctx;
if (cell.kind === "souvenirShop") {
const [ok, reason] = tryUpgradeSouvenirShop(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "SouvenirShopMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return true;
}
if (cell.kind === "research") {
const [ok, reason] = tryUpgradeResearch(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "ResearchMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return true;
}
if (cell.kind === "billeterie") {
const [ok, reason] = tryUpgradeBilleterie(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BilleterieMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return true;
}
if (cell.kind === "food") {
const [ok, reason] = tryUpgradeFood(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "FoodMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return true;
}
return false;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} ctx
* @param {import("./types.js").GridCell} cell
* @param {{ x: number, y: number, key: string }} pos
* @returns {boolean}
*/
export function handleCellClickUpgradeRest(ctx, cell, pos) {
const { x, y, key } = pos;
const { state, setState, setError, playSound } = ctx;
if (cell.kind === "reception") {
const hasAnimal = (state.receptionAnimals ?? []).some((r) => r.receptionCellKey === key);
if (!hasAnimal) {
const [ok, reason] = tryUpgradeReception(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "ReceptionMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
}
setState();
return true;
}
if (cell.kind === "biomeChangeColor") {
const [ok, reason] = tryUpgradeBiomeChangeColor(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BiomeChangeColorMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return true;
}
if (cell.kind === "biomeChangeTemp") {
const [ok, reason] = tryUpgradeBiomeChangeTemp(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "BiomeChangeTempMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return true;
}
if (cell.kind === "school") {
const [ok, reason] = tryUpgradeSchool(state, x, y);
if (!ok) { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); } else { setError(""); playSound("schoolUpgrade"); }
setState();
return true;
}
return false;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, selectedTokenId: number | null, emptyCellChoice: { x: number, y: number } | null }} ctx
* @param {boolean} empty
* @param {number} x
* @param {number} y
*/
export function handleCellClickPlaceOrSelect(ctx, empty, x, y) {
const { state, setState, setError, playSound } = ctx;
const nurseryKeys = getNurseryCellKeysOrdered(state);
let firstTokenId = null;
for (const k of nurseryKeys) {
const c = state.grid.cells[k];
if (c && c.kind === "nursery" && c.tokenId !== null && c.tokenId !== undefined) {
firstTokenId = c.tokenId;
break;
}
}
const tokenId = ctx.selectedTokenId.current ?? firstTokenId;
if (empty && tokenId !== null && tokenId !== undefined) {
const nowUnix = Math.floor(Date.now() / 1000);
const [ok, reason] = tryPlaceEgg(state, { tokenId, x, y, nowUnix });
if (ok) { ctx.selectedTokenId.current = null; setError(""); playSound("place"); } else { setError(String(t.errorPrefix).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
setState();
return;
}
if (empty) ctx.emptyCellChoice.current = { x, y };
setState();
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement }} ctx
* @param {{ div: HTMLElement, cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts
*/
export function attachCellListeners(ctx, opts) {
const { div, cell, x, y, key } = opts;
attachDragListeners(ctx, opts);
div.addEventListener("dragover", (e) => {
e.preventDefault();
const hasEggType = e.dataTransfer.types.includes("application/x-builazoo-eggtype");
const hasTokenId = e.dataTransfer.types.includes("application/x-builazoo-tokenid");
const hasNurseryKey = e.dataTransfer.types.includes("application/x-builazoo-nursery-cell-key");
const hasReceptionKey = e.dataTransfer.types.includes("application/x-builazoo-reception-cell-key");
e.dataTransfer.dropEffect = hasEggType || hasTokenId ? "copy" : "move";
if ((cell === null || cell === undefined) && (hasEggType || hasTokenId || hasNurseryKey || hasReceptionKey)) {
div.classList.add("dragover");
}
});
div.addEventListener("dragleave", () => div.classList.remove("dragover"));
div.addEventListener("drop", (e) => {
e.preventDefault();
div.classList.remove("dragover");
handleCellDrop(ctx, e, { toX: Number(div.dataset.x), toY: Number(div.dataset.y), cell });
});
div.addEventListener("click", (e) => handleCellClick(ctx, { e, cell, x, y, key }));
div.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); div.click(); }
});
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement }} ctx
* @param {DragEvent} e
* @param {{ toX: number, toY: number, cell: import("./types.js").GridCell | null | undefined }} target
*/
function handleCellDrop(ctx, e, target) {
const { toX, toY, cell } = target;
const empty = cell === null || cell === undefined;
if (handleCellDropNurseryReceptionToken(ctx, e, { x: toX, y: toY, empty })) return;
handleCellDropEggTypeOrMove(ctx, e, { x: toX, y: toY, empty });
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, selected: { x: number, y: number } }} ctx
* @param {{ e: Event, cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts
*/
function handleCellClick(ctx, opts) {
const { e, cell, x, y, key } = opts;
const { state, setState, setError, playSound } = ctx;
if (ctx.lastActionWasDrop.current) {
ctx.lastActionWasDrop.current = false;
return;
}
const choiceBtn = e.target.closest(".cell-choice-btn");
const empty = cell === null || cell === undefined;
if (choiceBtn && empty && ctx.emptyCellChoice.current && ctx.emptyCellChoice.current.x === x && ctx.emptyCellChoice.current.y === y) {
handleCellClickChoice(ctx, choiceBtn.dataset.choice, x, y);
setState();
return;
}
if (cell !== null && cell !== undefined && cell.kind === "nursery" && cell.tokenId !== null && cell.tokenId !== undefined) {
ctx.selectedTokenId.current = cell.tokenId;
setState();
return;
}
if (cell !== null && cell !== undefined && cell.kind === "nursery" && (cell.tokenId === null || cell.tokenId === undefined)) {
const hasBaby = (state.pendingBabies ?? []).some((p) => p.nurseryCellKey === key);
if (!hasBaby) {
const [ok, reason] = tryUpgradeNursery(state, x, y);
if (ok) { setError(""); playSound("upgrade"); } else if (reason !== "NurseryMaxLevel") { setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason)); playSound("error"); }
}
setState();
return;
}
if (handleCellClickUpgrade(ctx, { cell, x, y, key })) return;
ctx.selected.x = x;
ctx.selected.y = y;
ctx.clampSelection();
if (empty && ctx.emptyCellChoice.current && ctx.emptyCellChoice.current.x === x && ctx.emptyCellChoice.current.y === y) {
ctx.emptyCellChoice.current = null;
setState();
return;
}
handleCellClickPlaceOrSelect(ctx, empty, x, y);
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} ctx
* @param {{ cell: import("./types.js").GridCell | null | undefined, x: number, y: number, key: string }} opts
* @returns {boolean}
*/
function handleCellClickUpgrade(ctx, opts) {
const { cell, x, y, key } = opts;
if (cell === null || cell === undefined) return false;
if (handleCellClickUpgradeShops(ctx, cell, x, y)) return true;
if (handleCellClickUpgradeRest(ctx, cell, { x, y, key })) return true;
return false;
}
export { handleCellDrop, handleCellClick };

47
web/js/ui-grid.js Normal file
View File

@@ -0,0 +1,47 @@
import { getDisplayBiome, getDisplayTemperature, getTemperatureBand } from "./biome-rules.js";
import { fillCellContent } from "./ui-grid-cells.js";
import { attachCellListeners } from "./ui-grid-handlers.js";
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, animalEmoji: Record<string, string> }} ctx
* @param {number} x
* @param {number} y
* @returns {HTMLElement}
*/
function buildOneCell(ctx, x, y) {
const { state, getHatched, selected } = ctx;
const key = `${x}_${y}`;
const cell = state.grid.cells[key];
const div = document.createElement("div");
div.className = "cell";
const biome = getDisplayBiome(x, y, state.grid);
const temp = getDisplayTemperature(x, y, state.grid);
const tempBand = getTemperatureBand(temp);
div.classList.add(`biome-${biome.toLowerCase()}`, `temp-${tempBand}`);
const hatchedList = getHatched();
if (hatchedList.some((h) => h.x === x && h.y === y)) div.classList.add("just-hatched");
div.setAttribute("role", "button");
div.setAttribute("tabindex", "0");
div.dataset.x = String(x);
div.dataset.y = String(y);
const isSelected = selected.x === x && selected.y === y;
if (isSelected) div.classList.add("selected");
fillCellContent(ctx, div, { cell, key, x, y });
attachCellListeners(ctx, { div, cell, x, y, key });
return div;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, gridEl: HTMLElement, getHatched: () => Array<{ x: number, y: number }>, selected: { x: number, y: number }, emptyCellChoice: { x: number, y: number } | null, selectedTokenId: number | null, lastActionWasDrop: boolean, clampSelection: () => void, animalEmoji: Record<string, string> }} ctx
*/
export function renderGrid(ctx) {
const { state, gridEl } = ctx;
gridEl.style.gridTemplateColumns = `repeat(${state.grid.width}, 48px)`;
gridEl.style.gridTemplateRows = `repeat(${state.grid.height}, 48px)`;
gridEl.innerHTML = "";
for (let y = 1; y <= state.grid.height; y++) {
for (let x = 1; x <= state.grid.width; x++) {
gridEl.appendChild(buildOneCell(ctx, x, y));
}
}
}

36
web/js/ui-helpers.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* @param {string} labelContent
* @param {string} tooltipText
* @returns {HTMLElement}
*/
export function makeHelpWrap(labelContent, tooltipText) {
const wrap = document.createElement("div");
wrap.className = "help-wrap";
const label = document.createElement("span");
label.textContent = labelContent;
const icon = document.createElement("span");
icon.className = "help-icon";
icon.setAttribute("aria-label", "Aide");
icon.textContent = "?";
const bubble = document.createElement("div");
bubble.className = "tooltip-bubble";
bubble.textContent = tooltipText;
wrap.append(label, icon, bubble);
return wrap;
}
/**
* @param {HTMLElement} parent
* @param {string} titleText
* @param {string} helpText
* @returns {void}
*/
export function addSectionTitle(parent, titleText, helpText) {
const section = document.createElement("div");
section.className = "section-with-help";
const h2 = document.createElement("h2");
h2.textContent = titleText;
section.appendChild(h2);
section.appendChild(makeHelpWrap("", helpText));
parent.appendChild(section);
}

View File

@@ -0,0 +1,166 @@
import { tryBuyEgg, tryBuyBaby, tryBuyAnimal } from "./zoo.js";
import { pickSaleTargetZoo } from "./conveyor.js";
import { sellAnimalToNpc, addMatureBabyToSale, addReceptionAnimalToSale } from "./trade.js";
import { playSound } from "./audio.js";
import { t, errorMessage } from "./texts-fr.js";
import { getApiBase, createSale } from "./api-client.js";
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {boolean}
*/
function handleTruckDropBaby(e, setup) {
const babyOffer = e.dataTransfer.getData("application/x-builazoo-baby-offer");
if (!babyOffer) return false;
const [animalId, priceStr] = babyOffer.split(":");
const price = Number(priceStr) || 80;
const [ok, result] = tryBuyBaby(setup.state, animalId, price);
if (!ok) setup.setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result));
else setup.setError("");
playSound(ok ? "buy" : "error");
setup.setState();
return true;
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {boolean}
*/
function handleTruckDropAnimal(e, setup) {
const animalOffer = e.dataTransfer.getData("application/x-builazoo-animal-offer");
if (!animalOffer) return false;
const [animalId, priceStr] = animalOffer.split(":");
const price = Number(priceStr) || 120;
const [ok, result] = tryBuyAnimal(setup.state, animalId, price);
if (!ok) setup.setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result));
else setup.setError("");
playSound(ok ? "buy" : "error");
setup.setState();
return true;
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {boolean}
*/
function handleTruckDropEgg(e, setup) {
const eggType = e.dataTransfer.getData("application/x-builazoo-eggtype");
if (!eggType) return false;
const toZooId = e.dataTransfer.getData("application/x-builazoo-offer-zooid") || "player";
const [ok, result] = tryBuyEgg(setup.state, eggType);
if (!ok) {
setup.setError(String(t.buyFailed).replace("%s", errorMessage[result] ?? result));
playSound("error");
} else {
setup.setError("");
playSound("buy");
setup.state.eggPurchaseTruck = { eggType, fromZooId: "player", toZooId, startAt: Date.now() };
}
setup.setState();
return true;
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {void}
*/
export function handleWorldMapTruckDrop(e, setup) {
e.preventDefault();
const el = e.currentTarget;
if (el && el instanceof HTMLElement) el.classList.remove("dragover");
if (handleTruckDropBaby(e, setup)) return;
if (handleTruckDropAnimal(e, setup)) return;
handleTruckDropEgg(e, setup);
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean } }} setup
* @returns {boolean}
*/
function applyNurseryDrop(e, setup) {
const nurseryCellKey = e.dataTransfer.getData("application/x-builazoo-nursery-cell-key");
if (!nurseryCellKey) return false;
const [ok, result] = addMatureBabyToSale(setup.state, nurseryCellKey);
if (!ok) {
setup.setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result));
playSound("error");
} else {
setup.setError("");
playSound("sell");
const listing = setup.state.saleListings[setup.state.saleListings.length - 1];
if (getApiBase() && listing) {
createSale({ animalId: listing.animalId, isBaby: true, price: listing.price, endAt: new Date(listing.endAt * 1000).toISOString(), reproductionScoreAtSale: listing.reproductionScoreAtSale }).then(({ id }) => { listing.serverId = id; setup.setState(); }).catch(() => {});
}
}
setup.lastActionWasDropRef.current = true;
setup.setState();
return true;
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean } }} setup
* @returns {boolean}
*/
function applyReceptionDrop(e, setup) {
const receptionCellKey = e.dataTransfer.getData("application/x-builazoo-reception-cell-key");
if (!receptionCellKey) return false;
const [ok, result] = addReceptionAnimalToSale(setup.state, receptionCellKey);
if (!ok) {
setup.setError(String(t.errorPrefix).replace("%s", errorMessage[result] ?? result));
playSound("error");
} else {
setup.setError("");
playSound("sell");
const listing = setup.state.saleListings[setup.state.saleListings.length - 1];
if (getApiBase() && listing) {
createSale({ animalId: listing.animalId, isBaby: false, price: listing.price, endAt: new Date(listing.endAt * 1000).toISOString(), reproductionScoreAtSale: listing.reproductionScoreAtSale }).then(({ id }) => { listing.serverId = id; setup.setState(); }).catch(() => {});
}
}
setup.lastActionWasDropRef.current = true;
setup.setState();
return true;
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean } }} setup
* @returns {boolean}
*/
function applyGridCellSell(e, setup) {
const raw = e.dataTransfer.getData("text/plain");
if (!raw || !/^\d+_\d+$/.test(raw)) return false;
const [sx, sy] = raw.split("_").map(Number);
const [ok, result] = sellAnimalToNpc(setup.state, sx, sy);
if (!ok) {
setup.setError(String(t.sellFailed).replace("%s", errorMessage[result] ?? result));
playSound("error");
} else {
setup.setError("");
playSound("sell");
setup.state.truckSale = { toZooId: pickSaleTargetZoo(setup.state), startAt: Date.now() };
}
setup.lastActionWasDropRef.current = true;
setup.setState();
return true;
}
/**
* @param {DragEvent} e
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, lastActionWasDropRef: { current: boolean }, sellZoneJustDroppedRef: { current: boolean } }} setup
* @returns {void}
*/
export function handleSellZoneDrop(e, setup) {
e.preventDefault();
const el = e.currentTarget;
if (el && el instanceof HTMLElement) el.classList.remove("dragover");
setup.sellZoneJustDroppedRef.current = true;
if (applyNurseryDrop(e, setup)) return;
if (applyReceptionDrop(e, setup)) return;
applyGridCellSell(e, setup);
}

View File

@@ -0,0 +1,224 @@
import { tryUpgradeWorldMap, tryUpgradePlot } from "./zoo.js";
import { tryUpgradeTruck } from "./conveyor.js";
import { getTruckUpgradeCost } from "./economy.js";
import { playSound } from "./audio.js";
import { t, errorMessage, sellZoneTitle, sellZoneShortLabel } from "./texts-fr.js";
import { GameConfig } from "./config.js";
import { handleWorldMapTruckDrop, handleSellZoneDrop } from "./ui-render-dom-drops.js";
/**
* @param {HTMLElement} panelWorld
* @param {{ state: import("./types.js").GameState }} setup
* @returns {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement }}
*/
function buildWorldMapWrap(panelWorld, setup) {
const { state } = setup;
const worldMapWrap = document.createElement("div");
worldMapWrap.className = "world-map-wrap world-map-wrap-square";
const worldMapEl = document.createElement("div");
worldMapEl.className = "world-map world-map-biomes";
const mapLevel = state.worldMapLevel ?? 1;
const zoom = Math.min(0.65 + (mapLevel - 1) * 0.2, 1.45);
worldMapEl.style.transformOrigin = "50% 50%";
worldMapEl.style.transform = `scale(${zoom})`;
worldMapWrap.appendChild(worldMapEl);
const worldMapTruckEl = document.createElement("div");
worldMapTruckEl.className = "world-map-truck";
worldMapTruckEl.setAttribute("aria-hidden", "true");
worldMapWrap.appendChild(worldMapTruckEl);
const worldMapNpcTrucksEl = document.createElement("div");
worldMapNpcTrucksEl.className = "world-map-trucks";
worldMapNpcTrucksEl.setAttribute("aria-hidden", "true");
worldMapWrap.appendChild(worldMapNpcTrucksEl);
panelWorld.appendChild(worldMapWrap);
return { worldMapEl, worldMapTruckEl, worldMapNpcTrucksEl };
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {HTMLElement}
*/
function buildWorldMapUpgradeZone(setup) {
const { state, setState, setError } = setup;
const worldMapUpgradeZone = document.createElement("div");
worldMapUpgradeZone.className = "world-map-upgrade-zone";
worldMapUpgradeZone.setAttribute("aria-label", "Agrandir la carte");
worldMapUpgradeZone.title = "Agrandir la carte";
worldMapUpgradeZone.innerHTML = "<span class=\"world-map-upgrade-zone-icon\" aria-hidden=\"true\">🗺️</span><span class=\"world-map-upgrade-zone-label\">Agrandir carte</span><span class=\"world-map-upgrade-zone-cost\" aria-hidden=\"true\"></span><span class=\"world-map-upgrade-zone-arrow\" aria-hidden=\"true\">▲</span>";
worldMapUpgradeZone.setAttribute("role", "button");
worldMapUpgradeZone.setAttribute("tabindex", "0");
worldMapUpgradeZone.addEventListener("click", () => {
const [ok, reason] = tryUpgradeWorldMap(state);
if (!ok) {
setError(String(t.upgradeWorldMapFailed).replace("%s", errorMessage[reason] ?? reason));
playSound("error");
} else {
setError("");
playSound("worldMapUpgrade");
}
setState();
});
worldMapUpgradeZone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
worldMapUpgradeZone.click();
}
});
return worldMapUpgradeZone;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {HTMLElement}
*/
function buildWorldMapTruckDropZone(setup) {
const worldMapTruckDropZone = document.createElement("div");
worldMapTruckDropZone.className = "world-map-truck-drop-zone";
worldMapTruckDropZone.setAttribute("aria-label", "Camion pour acheter un œuf");
worldMapTruckDropZone.title = "Glissez un œuf ici pour l'acheter";
worldMapTruckDropZone.innerHTML = "<span class=\"world-map-truck-drop-icon\" aria-hidden=\"true\">🚚</span><span class=\"world-map-truck-drop-label\">Acheter œuf</span>";
worldMapTruckDropZone.addEventListener("dragover", (e) => {
e.preventDefault();
const hasOffer = e.dataTransfer.types.includes("application/x-builazoo-eggtype")
|| e.dataTransfer.types.includes("application/x-builazoo-baby-offer")
|| e.dataTransfer.types.includes("application/x-builazoo-animal-offer");
e.dataTransfer.dropEffect = hasOffer ? "copy" : "none";
if (hasOffer) worldMapTruckDropZone.classList.add("dragover");
});
worldMapTruckDropZone.addEventListener("dragleave", () => worldMapTruckDropZone.classList.remove("dragover"));
worldMapTruckDropZone.addEventListener("drop", (ev) => handleWorldMapTruckDrop(ev, setup));
return worldMapTruckDropZone;
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {{ worldMapUpgradeZone: HTMLElement, worldMapCounters: HTMLElement, worldMapTruckDropZone: HTMLElement, worldMapActions: HTMLElement }}
*/
function buildWorldMapActions(setup) {
const worldMapActions = document.createElement("div");
worldMapActions.className = "world-map-actions";
const worldMapUpgradeZone = buildWorldMapUpgradeZone(setup);
worldMapActions.appendChild(worldMapUpgradeZone);
const worldMapCounters = document.createElement("div");
worldMapCounters.className = "world-map-counters";
worldMapCounters.setAttribute("aria-label", "Compteurs carte du monde");
worldMapActions.appendChild(worldMapCounters);
const worldMapTruckDropZone = buildWorldMapTruckDropZone(setup);
worldMapActions.appendChild(worldMapTruckDropZone);
return { worldMapUpgradeZone, worldMapCounters, worldMapTruckDropZone, worldMapActions };
}
/**
* @param {HTMLElement} panelWorld
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void }} setup
* @returns {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, worldMapUpgradeZone: HTMLElement, worldMapCounters: HTMLElement }}
*/
export function buildWorldMapSection(panelWorld, setup) {
const wrapResult = buildWorldMapWrap(panelWorld, setup);
const actionsResult = buildWorldMapActions(setup);
panelWorld.appendChild(actionsResult.worldMapActions);
return { ...wrapResult, worldMapUpgradeZone: actionsResult.worldMapUpgradeZone, worldMapCounters: actionsResult.worldMapCounters };
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} setup
* @returns {HTMLElement}
*/
function buildPlotUpgradeZone(setup) {
const { state, setState, setError } = setup;
const plotUpgradeZone = document.createElement("div");
plotUpgradeZone.className = "plot-upgrade-zone";
plotUpgradeZone.setAttribute("aria-label", t.upgradePlot);
plotUpgradeZone.title = t.upgradePlot;
plotUpgradeZone.innerHTML = "<span class=\"plot-upgrade-zone-icon\" aria-hidden=\"true\">📐</span><span class=\"plot-upgrade-zone-label\">Agrandir zoo</span><span class=\"plot-upgrade-zone-arrow\" aria-hidden=\"true\">▲</span>";
plotUpgradeZone.setAttribute("role", "button");
plotUpgradeZone.setAttribute("tabindex", "0");
plotUpgradeZone.addEventListener("click", () => {
const [ok, reason] = tryUpgradePlot(state);
if (!ok) {
setError(String(t.upgradePlotFailed).replace("%s", errorMessage[reason] ?? reason));
playSound("error");
} else {
setError("");
playSound("plotUpgrade");
}
setState();
});
plotUpgradeZone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
plotUpgradeZone.click();
}
});
return plotUpgradeZone;
}
/**
* @param {HTMLElement} panelZoo
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDropRef: { current: boolean }, sellZoneJustDroppedRef: { current: boolean } }} setup
* @returns {{ gridEl: HTMLElement, plotUpgradeZone: HTMLElement, sellZone: HTMLElement }}
*/
export function buildGridSection(panelZoo, setup) {
const gridWrap = document.createElement("div");
gridWrap.className = "grid-wrap";
const gridEl = document.createElement("div");
gridEl.className = "grid";
gridWrap.appendChild(gridEl);
const plotUpgradeZone = buildPlotUpgradeZone(setup);
gridWrap.appendChild(plotUpgradeZone);
const sellZone = document.createElement("div");
sellZone.className = "sell-zone";
sellZone.setAttribute("aria-label", sellZoneTitle);
sellZone.title = sellZoneTitle;
sellZone.innerHTML = "<span class=\"sell-zone-icon\" aria-hidden=\"true\">🚚</span><span class=\"sell-zone-label\">" + sellZoneShortLabel + "</span><span class=\"sell-zone-upgrade-arrow\" aria-hidden=\"true\">▲</span>";
attachSellZoneListeners(sellZone, setup);
gridWrap.appendChild(sellZone);
const visitorsLayer = document.createElement("div");
visitorsLayer.className = "visitors-layer";
visitorsLayer.setAttribute("aria-hidden", "true");
gridWrap.appendChild(visitorsLayer);
panelZoo.appendChild(gridWrap);
return { gridEl, plotUpgradeZone, sellZone };
}
/**
* @param {HTMLElement} sellZone
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, lastActionWasDropRef: { current: boolean }, sellZoneJustDroppedRef: { current: boolean } }} setup
* @returns {void}
*/
function attachSellZoneListeners(sellZone, setup) {
sellZone.addEventListener("dragover", (e) => {
e.preventDefault();
const hasCell = e.dataTransfer.types.includes("text/plain");
e.dataTransfer.dropEffect = hasCell ? "move" : "none";
if (hasCell) sellZone.classList.add("dragover");
});
sellZone.addEventListener("dragleave", () => sellZone.classList.remove("dragover"));
sellZone.addEventListener("drop", (e) => handleSellZoneDrop(e, setup));
sellZone.addEventListener("click", () => handleSellZoneClick(setup));
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, sellZoneJustDroppedRef: { current: boolean } }} setup
* @returns {void}
*/
function handleSellZoneClick(setup) {
if (setup.sellZoneJustDroppedRef.current) {
setup.sellZoneJustDroppedRef.current = false;
return;
}
const state = setup.state;
const truckLevel = state.truckLevel ?? 1;
const truckMax = (GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5;
if (truckLevel >= truckMax) return;
if (state.coins < getTruckUpgradeCost(truckLevel)) return;
const [ok, reason] = tryUpgradeTruck(state);
if (!ok) {
setup.setError(String(t.upgradeConveyorFailed).replace("%s", errorMessage[reason] ?? reason));
playSound("error");
} else {
setup.setError("");
playSound("truckUpgrade");
}
setup.setState();
}

274
web/js/ui-render-dom.js Normal file
View File

@@ -0,0 +1,274 @@
import { refreshOffers, getSkillLevel } from "./conveyor.js";
import { getPlotUpgradeCost, getTruckUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js";
import { getVisitorCount } from "./income.js";
import { getTimePhase } from "./time-weather.js";
import { canPrestige, doPrestige } from "./prestige.js";
import { playSound, isMusicEnabled } from "./audio.js";
import { questDescription, timePhaseLabel, weatherLabel, prestigeHint } from "./texts-fr.js";
import { GameConfig } from "./config.js";
import { renderWorldMap } from "./ui-world-map.js";
import { renderGrid } from "./ui-grid.js";
import { buildGameBar } from "./ui-render-gamebar.js";
import { buildWorldMapSection, buildGridSection } from "./ui-render-dom-panels.js";
/**
* @param {{ errEl: HTMLElement }} _setup
* @returns {{ tabsWrap: HTMLElement, tabContent: HTMLElement, panelZoo: HTMLElement, panelWorld: HTMLElement }}
*/
function createTabsStructure(_setup) {
const tabsWrap = document.createElement("div");
tabsWrap.className = "tabs-wrap";
tabsWrap.setAttribute("aria-label", "Carte du zoo et carte du monde");
const tabContent = document.createElement("div");
tabContent.className = "tabs-content";
const panelZoo = document.createElement("div");
panelZoo.className = "tab-panel active";
panelZoo.id = "tab-panel-zoo";
panelZoo.setAttribute("role", "tabpanel");
panelZoo.setAttribute("aria-labelledby", "view-toggle");
const panelWorld = document.createElement("div");
panelWorld.className = "tab-panel";
panelWorld.id = "tab-panel-world";
panelWorld.setAttribute("role", "tabpanel");
panelWorld.setAttribute("aria-labelledby", "view-toggle");
return { tabsWrap, tabContent, panelZoo, panelWorld };
}
/**
* @param {Array<{ descriptionKey: string, target: number, current: number, done: boolean }>} quests
* @returns {string}
*/
function formatQuestListHtml(quests) {
return (quests ?? []).map((q) => {
const desc = questDescription[q.descriptionKey];
const text = desc ? String(desc).replace("%d", String(q.target)) : q.descriptionKey;
const done = q.done ? " ✓" : "";
return `<div class="quest-item ${q.done ? "done" : ""}">${text} : ${q.current}/${q.target}${done}</div>`;
}).join("");
}
/**
* @param {object} gameBarResult
* @param {object} setup
* @returns {void}
*/
function updateStatusBody(gameBarResult, setup) {
const { state } = setup;
const { selected } = setup;
const {
statusBarCoins,
statusBarPlot,
statusBarCell,
statusBarSkill,
statusBarVisitors,
statusBarOffers,
statusBarTimeWeather,
musicBtn,
autoModeBtn,
prestigeBtn,
questListEl,
} = gameBarResult;
statusBarCoins.valueEl.textContent = String(Math.floor(state.coins));
statusBarPlot.valueEl.textContent = String(Math.floor(state.plotLevel));
statusBarCell.valueEl.textContent = `${Math.floor(selected.x)} ${Math.floor(selected.y)}`;
statusBarSkill.valueEl.textContent = String(Math.floor(getSkillLevel(state)));
const visitors = getVisitorCount(state);
statusBarVisitors.valueEl.textContent = String(Math.floor(visitors));
const offersCount = (state.conveyorOffers ?? []).length;
statusBarOffers.valueEl.textContent = String(Math.floor(offersCount));
const timePhase = getTimePhase(state.timeOfDay ?? 6);
const weatherVal = weatherLabel[state.weather] ?? state.weather;
statusBarTimeWeather.valueEl.textContent = `${timePhaseLabel[timePhase.phase]} · ${weatherVal}`;
statusBarTimeWeather.item.className = "status-bar-item status-bar-time-weather weather-" + (state.weather ?? "sun");
musicBtn.classList.toggle("muted", !isMusicEnabled());
autoModeBtn.setAttribute("aria-pressed", state.autoMode ? "true" : "false");
autoModeBtn.title = state.autoMode ? "Mode automatique (désactiver)" : "Mode automatique (activer)";
autoModeBtn.setAttribute("aria-label", state.autoMode ? "Mode automatique actif" : "Activer le mode automatique");
autoModeBtn.textContent = state.autoMode ? "🤖" : "✋";
prestigeBtn.title = String(prestigeHint).replace("%d", String(GameConfig.Prestige.MinCoinsToReset));
prestigeBtn.disabled = !canPrestige(state);
questListEl.innerHTML = formatQuestListHtml(state.quests);
}
/**
* @param {object} gameBarResult
* @param {object} setup
* @returns {() => void}
*/
function createUpdateStatus(gameBarResult, setup) {
return function updateStatus() {
updateStatusBody(gameBarResult, setup);
};
}
/**
* @param {object} opts
* @returns {() => void}
*/
function createFullRender(opts) {
const {
setup,
sellZone,
plotUpgradeZone,
worldMapUpgradeZone,
worldMapCounters,
worldMapCtx,
gridCtx,
updateStatus,
} = opts;
const { state } = setup;
return function fullRender() {
setup.clampSelection();
updateStatus();
const canUpTruck = (state.truckLevel ?? 1) < ((GameConfig.Truck && GameConfig.Truck.MaxLevel) || 5)
&& state.coins >= getTruckUpgradeCost(state.truckLevel ?? 1);
sellZone.classList.toggle("can-upgrade", canUpTruck);
const truckArrow = sellZone.querySelector(".sell-zone-upgrade-arrow");
if (truckArrow) truckArrow.style.display = canUpTruck ? "" : "none";
const plotMaxLevel = GameConfig.Plot.MaxLevel || 8;
const canUpgradePlot = (state.plotLevel ?? 1) < plotMaxLevel && state.coins >= getPlotUpgradeCost(state.plotLevel ?? 1);
plotUpgradeZone.classList.toggle("can-upgrade", canUpgradePlot);
const plotArrow = plotUpgradeZone.querySelector(".plot-upgrade-zone-arrow");
if (plotArrow) plotArrow.style.display = canUpgradePlot ? "" : "none";
updateWorldMapUpgradeAndCounters(worldMapUpgradeZone, worldMapCounters, state);
const eggPurchase = state.eggPurchaseTruck;
if (eggPurchase && eggPurchase.startAt) {
const truckLevel = state.truckLevel ?? 1;
const baseMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500;
const durationMs = Math.max(1000, (baseMs * 2) / truckLevel);
if (Date.now() - eggPurchase.startAt >= durationMs) delete state.eggPurchaseTruck;
}
renderWorldMap(worldMapCtx);
renderGrid(gridCtx);
};
}
/**
* @param {HTMLElement} worldMapUpgradeZone
* @param {HTMLElement} worldMapCounters
* @param {import("./types.js").GameState} state
* @returns {void}
*/
function updateWorldMapUpgradeAndCounters(worldMapUpgradeZone, worldMapCounters, state) {
const mapCfg = GameConfig.WorldMap && GameConfig.WorldMap.MapUpgrade;
const mapMaxLevel = mapCfg ? mapCfg.MaxLevel : 5;
const currentMapLevel = state.worldMapLevel ?? 1;
const mapResearchCost = getWorldMapUpgradeResearchCost(currentMapLevel);
const canUpgradeMap = currentMapLevel < mapMaxLevel && (state.researchPoints ?? 0) >= mapResearchCost;
worldMapUpgradeZone.classList.toggle("can-upgrade", canUpgradeMap);
worldMapUpgradeZone.title = currentMapLevel < mapMaxLevel
? `Agrandir la carte (${mapResearchCost} unités de recherche)`
: "Agrandir la carte";
const mapCostEl = worldMapUpgradeZone.querySelector(".world-map-upgrade-zone-cost");
if (mapCostEl) mapCostEl.textContent = currentMapLevel < mapMaxLevel ? ` ${mapResearchCost} 🔬` : "";
const mapArrow = worldMapUpgradeZone.querySelector(".world-map-upgrade-zone-arrow");
if (mapArrow) mapArrow.style.display = canUpgradeMap ? "" : "none";
const babiesForSale = (state.saleListings ?? []).filter((s) => s.isBaby).length;
const animalsForSale = (state.saleListings ?? []).filter((s) => !s.isBaby).length;
const labsCount = GameConfig.WorldMap && GameConfig.WorldMap.Laboratory ? 1 : 0;
const zoosCount = (state.worldZoos ?? []).length;
const citiesCount = (GameConfig.WorldMap && GameConfig.WorldMap.Cities) ? GameConfig.WorldMap.Cities.length : 0;
worldMapCounters.textContent = "";
const counterEntries = [
["Bébés à vendre", babiesForSale],
["Animaux à vendre", animalsForSale],
["Laboratoires", labsCount],
["Zoos", zoosCount],
["Villes", citiesCount],
];
for (const [label, value] of counterEntries) {
const span = document.createElement("span");
span.className = "world-map-counter";
span.title = label;
span.setAttribute("aria-label", `${label}: ${value}`);
span.textContent = `${label}: ${value}`;
worldMapCounters.appendChild(span);
}
}
/**
* @param {{ setup: object, gameBarResult: object, worldMapResult: object, gridResult: object }} opts
* @returns {{ worldMapCtx: object, gridCtx: object }}
*/
function buildFinishContexts(opts) {
const { setup, worldMapResult, gridResult } = opts;
const worldMapCtx = {
worldMapEl: worldMapResult.worldMapEl,
worldMapTruckEl: worldMapResult.worldMapTruckEl,
worldMapNpcTrucksEl: worldMapResult.worldMapNpcTrucksEl,
state: setup.state,
setState: setup.setState,
setError: setup.setError,
playSound,
animalEmoji: setup.animalEmoji,
pendingTokenByEggType: setup.pendingTokenByEggType,
};
const gridCtx = {
state: setup.state,
setState: setup.setState,
setError: setup.setError,
playSound,
gridEl: gridResult.gridEl,
getHatched: setup.getHatched,
selected: setup.selected,
emptyCellChoice: setup.emptyCellChoiceRef,
selectedTokenId: setup.selectedTokenIdRef,
lastActionWasDrop: setup.lastActionWasDropRef,
clampSelection: setup.clampSelection,
animalEmoji: setup.animalEmoji,
};
return { worldMapCtx, gridCtx };
}
/**
* @param {{ root: HTMLElement, setup: object, gameBarResult: object, worldMapResult: object, gridResult: object, updateStatus: () => void }} opts
* @returns {() => void}
*/
function finishBuildUIDOM(opts) {
const { setup, gameBarResult, worldMapResult, gridResult, updateStatus } = opts;
const { worldMapCtx, gridCtx } = buildFinishContexts(opts);
renderWorldMap(worldMapCtx);
renderGrid(gridCtx);
gameBarResult.prestigeBtn.addEventListener("click", () => {
if (!canPrestige(setup.state)) return;
doPrestige(setup.state);
refreshOffers(setup.state, Math.floor(Date.now() / 1000));
setup.setError("");
playSound("upgrade");
setup.setState();
});
const fullRender = createFullRender({
setup,
sellZone: gridResult.sellZone,
plotUpgradeZone: gridResult.plotUpgradeZone,
worldMapUpgradeZone: worldMapResult.worldMapUpgradeZone,
worldMapCounters: worldMapResult.worldMapCounters,
worldMapCtx,
gridCtx,
updateStatus,
});
fullRender();
return fullRender;
}
/**
* @param {HTMLElement} root
* @param {object} setup
* @returns {() => void}
*/
export function buildUIDOM(root, setup) {
const tabs = createTabsStructure(setup);
const { panelZoo, panelWorld, tabsWrap, tabContent } = tabs;
const gameBarResult = buildGameBar(setup, panelZoo, panelWorld);
const worldMapResult = buildWorldMapSection(panelWorld, setup);
const gridResult = buildGridSection(panelZoo, setup);
tabsWrap.appendChild(setup.errEl);
tabContent.appendChild(panelZoo);
tabContent.appendChild(panelWorld);
tabsWrap.appendChild(tabContent);
gameBarResult.gameBarActions.insertBefore(gameBarResult.viewSwitcherWrap, gameBarResult.gameBarActions.firstChild);
root.appendChild(gameBarResult.gameBar);
root.appendChild(tabsWrap);
const updateStatus = createUpdateStatus(gameBarResult, setup);
return finishBuildUIDOM({ root, setup, gameBarResult, worldMapResult, gridResult, updateStatus });
}

View File

@@ -0,0 +1,95 @@
import {
autoProfileFamilyLabel,
autoProfileSpecialisationLabel,
autoProfilePickerTitle,
autoProfilePickerFamilyStep,
autoProfilePickerSpecialisationStep,
autoProfileCancel,
} from "./texts-fr.js";
import { getProfilesByFamily, AUTO_MODE_FAMILY_IDS } from "./auto-mode-profiles.js";
/**
* @param {(p: Partial<import("./types.js").GameState>) => void} updateState
* @param {HTMLElement} pickerWrap
* @returns {void}
*/
function buildAutoProfilePickerFamilyStep(updateState, pickerWrap) {
const stepLabel = document.createElement("div");
stepLabel.className = "auto-profile-picker-step";
stepLabel.textContent = autoProfilePickerFamilyStep;
pickerWrap.appendChild(stepLabel);
const familyBtns = document.createElement("div");
familyBtns.className = "auto-profile-picker-families";
for (const fid of AUTO_MODE_FAMILY_IDS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "auto-profile-picker-family-btn";
btn.textContent = autoProfileFamilyLabel[fid] ?? `Famille ${fid}`;
btn.addEventListener("click", () => updateState({ autoProfilePickerFamily: fid }));
familyBtns.appendChild(btn);
}
pickerWrap.appendChild(familyBtns);
}
/**
* @param {string|number} familyId
* @param {(p: Partial<import("./types.js").GameState>) => void} updateState
* @param {HTMLElement} pickerWrap
* @returns {void}
*/
function buildAutoProfilePickerSpecStep(familyId, updateState, pickerWrap) {
const stepLabel = document.createElement("div");
stepLabel.className = "auto-profile-picker-step";
stepLabel.textContent = autoProfilePickerSpecialisationStep;
pickerWrap.appendChild(stepLabel);
const profiles = getProfilesByFamily(familyId);
const specWrap = document.createElement("div");
specWrap.className = "auto-profile-picker-specialisations";
for (const prof of profiles) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "auto-profile-picker-spec-btn";
btn.textContent = autoProfileSpecialisationLabel[String(prof.id)] ?? `Profil ${prof.id}`;
btn.addEventListener("click", () => {
updateState({
autoModeProfileId: prof.id,
autoMode: true,
autoProfilePickerOpen: false,
autoProfilePickerFamily: undefined,
});
});
specWrap.appendChild(btn);
}
pickerWrap.appendChild(specWrap);
}
/**
* @param {{ state: import("./types.js").GameState, updateState?: (p: Partial<import("./types.js").GameState>) => void }} setup
* @param {HTMLElement} gameBarActions
* @returns {void}
*/
export function buildAutoProfilePicker(setup, gameBarActions) {
const { state, updateState } = setup;
if (!state.autoProfilePickerOpen || !updateState) return;
const pickerWrap = document.createElement("div");
pickerWrap.className = "auto-profile-picker-wrap";
pickerWrap.setAttribute("role", "dialog");
pickerWrap.setAttribute("aria-label", autoProfilePickerTitle);
const pickerTitle = document.createElement("div");
pickerTitle.className = "auto-profile-picker-title";
pickerTitle.textContent = autoProfilePickerTitle;
pickerWrap.appendChild(pickerTitle);
const familyId = state.autoProfilePickerFamily;
if (familyId === null || familyId === undefined) {
buildAutoProfilePickerFamilyStep(updateState, pickerWrap);
} else {
buildAutoProfilePickerSpecStep(familyId, updateState, pickerWrap);
}
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "auto-profile-picker-cancel";
cancelBtn.textContent = autoProfileCancel;
cancelBtn.addEventListener("click", () => updateState({ autoProfilePickerOpen: false, autoProfilePickerFamily: undefined }));
pickerWrap.appendChild(cancelBtn);
gameBarActions.appendChild(pickerWrap);
}

271
web/js/ui-render-gamebar.js Normal file
View File

@@ -0,0 +1,271 @@
import { makeHelpWrap } from "./ui-helpers.js";
import { setMusicEnabled, isMusicEnabled } from "./audio.js";
import {
t,
prestigeLabel,
prestigeHint,
visitorsLabel,
musicLabel,
restartButton,
helpRestart,
questsTitle,
} from "./texts-fr.js";
import { getApiBase, getSales } from "./api-client.js";
import { buildAutoProfilePicker } from "./ui-render-gamebar-picker.js";
/**
* @param {string} iconEmoji
* @param {string} tooltipText
* @param {string} initialValue
* @returns {{ item: HTMLElement, valueEl: HTMLElement }}
*/
function addStatusItem(iconEmoji, tooltipText, initialValue) {
const item = document.createElement("span");
item.className = "status-bar-item";
const icon = document.createElement("span");
icon.className = "status-bar-icon";
icon.setAttribute("aria-hidden", "true");
icon.title = tooltipText;
icon.textContent = iconEmoji;
const value = document.createElement("span");
value.className = "status-bar-value";
value.textContent = initialValue;
item.append(icon, value);
return { item, valueEl: value };
}
/**
* @returns {{ gameBar: HTMLElement }}
*/
function buildGameBarTitle() {
const gameBar = document.createElement("div");
gameBar.className = "game-bar";
gameBar.setAttribute("aria-label", "Barre du jeu");
const gameBarTitleWrap = document.createElement("div");
gameBarTitleWrap.className = "game-bar-title-wrap";
const gameBarTitle = document.createElement("h1");
gameBarTitle.className = "game-bar-title";
gameBarTitle.textContent = t.title;
const titleHelp = makeHelpWrap("", t.helpStatus);
titleHelp.querySelector(".tooltip-bubble").classList.add("below");
gameBarTitleWrap.append(gameBarTitle, titleHelp);
gameBar.appendChild(gameBarTitleWrap);
return { gameBar };
}
/**
* @param {HTMLElement} gameBar
* @returns {{ statusBarCoins: { item: HTMLElement, valueEl: HTMLElement }, statusBarPlot: object, statusBarCell: object, statusBarSkill: object, statusBarVisitors: object, statusBarOffers: object, statusBarTimeWeather: object }}
*/
function buildStatusBar(gameBar) {
const statusBar = document.createElement("div");
statusBar.className = "status-bar";
statusBar.setAttribute("aria-label", "Indicateurs");
const statusBarCoins = addStatusItem("🪙", "Pièces", "0");
const statusBarPlot = addStatusItem("📐", "Parcelle", "1");
const statusBarCell = addStatusItem("📍", "Case sélectionnée", "1 1");
const statusBarSkill = addStatusItem("🎓", "Compétences", "1");
const statusBarVisitors = addStatusItem("👤", visitorsLabel, "0");
const statusBarOffers = addStatusItem("🥚", "Œufs à vendre", "0");
const statusBarTimeWeather = addStatusItem("🌤️", "Météo et heure", "—");
statusBar.append(
statusBarCoins.item, statusBarPlot.item, statusBarCell.item, statusBarSkill.item,
statusBarVisitors.item, statusBarOffers.item, statusBarTimeWeather.item
);
gameBar.appendChild(statusBar);
return { statusBarCoins, statusBarPlot, statusBarCell, statusBarSkill, statusBarVisitors, statusBarOffers, statusBarTimeWeather };
}
/**
* @param {HTMLElement} panelZoo
* @param {HTMLElement} panelWorld
* @param {{ state: import("./types.js").GameState, setState: () => void }} setup
* @returns {{ viewSwitcherWrap: HTMLElement, viewToggleBtn: HTMLElement }}
*/
function buildViewSwitcher(panelZoo, panelWorld, setup) {
const { state, setState } = setup;
const viewSwitcherWrap = document.createElement("div");
viewSwitcherWrap.className = "game-bar-view-switcher";
viewSwitcherWrap.setAttribute("aria-label", "Zoo ou carte du monde");
const viewToggleBtn = document.createElement("button");
viewToggleBtn.className = "game-bar-btn game-bar-view-btn";
viewToggleBtn.type = "button";
viewToggleBtn.id = "view-toggle";
viewToggleBtn.setAttribute("aria-label", "Afficher la carte du monde");
viewToggleBtn.title = "Carte du monde (cliquer pour afficher)";
viewToggleBtn.textContent = "🗺️";
function setViewToggleIcon(isZooActive) {
viewToggleBtn.textContent = isZooActive ? "🦒" : "🗺️";
viewToggleBtn.setAttribute("aria-label", isZooActive ? "Afficher la carte du monde" : "Afficher la carte du zoo");
viewToggleBtn.title = isZooActive ? "Carte du monde (cliquer pour afficher)" : "Carte du zoo (cliquer pour afficher)";
}
viewToggleBtn.addEventListener("click", () => {
const showZoo = !panelZoo.classList.contains("active");
if (showZoo) {
panelZoo.classList.add("active");
panelWorld.classList.remove("active");
setViewToggleIcon(true);
} else {
panelWorld.classList.add("active");
panelZoo.classList.remove("active");
setViewToggleIcon(false);
if (getApiBase()) {
getSales().then((data) => { state.salesFromApi = data; setState(); }).catch(() => {});
}
}
});
setViewToggleIcon(true);
viewSwitcherWrap.appendChild(viewToggleBtn);
return { viewSwitcherWrap, viewToggleBtn };
}
/**
* @returns {HTMLElement}
*/
function buildMusicBtn() {
const musicBtn = document.createElement("button");
musicBtn.className = "game-bar-btn game-bar-btn-music" + (isMusicEnabled() ? "" : " muted");
musicBtn.type = "button";
musicBtn.setAttribute("aria-label", musicLabel);
musicBtn.title = musicLabel;
musicBtn.textContent = "🎵";
musicBtn.addEventListener("click", () => {
const next = !isMusicEnabled();
setMusicEnabled(next);
try {
localStorage.setItem("builazoo_music", next ? "1" : "0");
} catch (_) {
// ignore localStorage
}
musicBtn.classList.toggle("muted", !next);
});
return musicBtn;
}
/**
* @param {{ state: import("./types.js").GameState, updateState?: (p: Partial<import("./types.js").GameState>) => void }} setup
* @returns {HTMLElement}
*/
function buildAutoModeBtn(setup) {
const { state, updateState } = setup;
const autoModeBtn = document.createElement("button");
autoModeBtn.className = "game-bar-btn game-bar-btn-auto-mode";
autoModeBtn.type = "button";
autoModeBtn.id = "auto-mode-btn";
autoModeBtn.setAttribute("aria-pressed", state.autoMode ? "true" : "false");
autoModeBtn.title = state.autoMode ? "Mode automatique (désactiver)" : "Mode automatique (activer)";
autoModeBtn.setAttribute("aria-label", state.autoMode ? "Mode automatique actif" : "Activer le mode automatique");
autoModeBtn.textContent = state.autoMode ? "🤖" : "✋";
autoModeBtn.addEventListener("click", () => {
if (state.autoMode && updateState) {
updateState({ autoMode: false });
} else if (updateState) {
updateState({ autoProfilePickerOpen: true, autoProfilePickerFamily: undefined });
}
});
return autoModeBtn;
}
/**
* @param {{ onRestart?: () => void }} setup
* @param {HTMLElement} gameBarActions
* @returns {{ prestigeBtn: HTMLElement, restartBtn: HTMLElement }}
*/
function buildPrestigeRestart(setup, gameBarActions) {
const { onRestart } = setup;
const prestigeBtn = document.createElement("button");
prestigeBtn.className = "game-bar-btn game-bar-btn-prestige";
prestigeBtn.type = "button";
prestigeBtn.setAttribute("aria-label", prestigeLabel);
prestigeBtn.title = prestigeHint;
prestigeBtn.textContent = "⭐";
gameBarActions.appendChild(prestigeBtn);
const restartBtn = document.createElement("button");
restartBtn.className = "game-bar-btn game-bar-btn-restart";
restartBtn.type = "button";
restartBtn.setAttribute("aria-label", restartButton);
restartBtn.title = helpRestart;
restartBtn.textContent = "🔄";
if (onRestart) {
restartBtn.addEventListener("click", () => onRestart());
} else {
restartBtn.disabled = true;
}
gameBarActions.appendChild(restartBtn);
return { prestigeBtn, restartBtn };
}
/**
* @param {HTMLElement} gameBarActions
* @returns {{ questListEl: HTMLElement }}
*/
function buildQuestDropdown(gameBarActions) {
const questWrap = document.createElement("div");
questWrap.className = "game-bar-quest-wrap";
const questBtn = document.createElement("button");
questBtn.className = "game-bar-btn game-bar-btn-quest";
questBtn.type = "button";
questBtn.setAttribute("aria-label", questsTitle);
questBtn.setAttribute("aria-expanded", "false");
questBtn.title = questsTitle;
questBtn.textContent = "📋";
const questDropdown = document.createElement("div");
questDropdown.className = "quest-dropdown";
questDropdown.setAttribute("role", "dialog");
questDropdown.setAttribute("aria-label", questsTitle);
const questDropdownTitle = document.createElement("div");
questDropdownTitle.className = "quest-dropdown-title";
questDropdownTitle.textContent = questsTitle;
questDropdown.appendChild(questDropdownTitle);
const questListEl = document.createElement("div");
questListEl.className = "quest-list";
questDropdown.appendChild(questListEl);
questWrap.appendChild(questBtn);
questWrap.appendChild(questDropdown);
questBtn.addEventListener("click", (e) => {
e.stopPropagation();
const open = questWrap.classList.toggle("open");
questBtn.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", () => {
questWrap.classList.remove("open");
questBtn.setAttribute("aria-expanded", "false");
});
questDropdown.addEventListener("click", (e) => e.stopPropagation());
gameBarActions.appendChild(questWrap);
return { questListEl };
}
/**
* @param {{ state: import("./types.js").GameState, setState: () => void, updateState?: (p: Partial<import("./types.js").GameState>) => void, onRestart?: () => void }} setup
* @param {HTMLElement} panelZoo
* @param {HTMLElement} panelWorld
* @returns {{ gameBar: HTMLElement, gameBarActions: HTMLElement, statusBarCoins: object, statusBarPlot: object, statusBarCell: object, statusBarSkill: object, statusBarVisitors: object, statusBarOffers: object, statusBarTimeWeather: object, viewSwitcherWrap: HTMLElement, viewToggleBtn: HTMLElement, musicBtn: HTMLElement, autoModeBtn: HTMLElement, prestigeBtn: HTMLElement, restartBtn: HTMLElement, questListEl: HTMLElement }}
*/
export function buildGameBar(setup, panelZoo, panelWorld) {
const { gameBar } = buildGameBarTitle();
const statusBars = buildStatusBar(gameBar);
const gameBarActions = document.createElement("div");
gameBarActions.className = "game-bar-actions";
const viewSwitcher = buildViewSwitcher(panelZoo, panelWorld, setup);
const musicBtn = buildMusicBtn();
const autoModeBtn = buildAutoModeBtn(setup);
gameBarActions.appendChild(autoModeBtn);
buildAutoProfilePicker(setup, gameBarActions);
gameBarActions.insertBefore(viewSwitcher.viewSwitcherWrap, gameBarActions.firstChild);
const { prestigeBtn, restartBtn } = buildPrestigeRestart(setup, gameBarActions);
const { questListEl } = buildQuestDropdown(gameBarActions);
gameBar.appendChild(gameBarActions);
return {
gameBar,
gameBarActions,
...statusBars,
viewSwitcherWrap: viewSwitcher.viewSwitcherWrap,
viewToggleBtn: viewSwitcher.viewToggleBtn,
musicBtn,
autoModeBtn,
prestigeBtn,
restartBtn,
questListEl,
};
}

View File

@@ -0,0 +1,33 @@
import { GameConfig } from "./config.js";
/**
* @param {{ worldMapEl: HTMLElement }} ctx
*/
export function renderCities(ctx) {
const cities = GameConfig.WorldMap?.Cities ?? [];
for (const city of cities) {
const cityEl = document.createElement("div");
cityEl.className = "world-map-city";
cityEl.style.left = `${city.x}%`;
cityEl.style.top = `${city.y}%`;
const maxVisitors = city.maxVisitorsTowardZoos ?? 0;
cityEl.title = maxVisitors > 0 ? `${city.name} — max ${maxVisitors} visiteurs vers zoos` : city.name;
cityEl.setAttribute("aria-label", maxVisitors > 0 ? `${city.name}, ${maxVisitors} visiteurs max vers zoos` : city.name);
const icon = document.createElement("span");
icon.setAttribute("aria-hidden", "true");
icon.textContent = "🏙️";
cityEl.appendChild(icon);
const cityLabel = document.createElement("div");
cityLabel.className = "world-map-city-label";
cityLabel.textContent = city.name;
cityEl.appendChild(cityLabel);
if (maxVisitors > 0) {
const cityMax = document.createElement("div");
cityMax.className = "world-map-city-max-visitors";
cityMax.textContent = `max ${maxVisitors}`;
cityMax.setAttribute("aria-hidden", "true");
cityEl.appendChild(cityMax);
}
ctx.worldMapEl.appendChild(cityEl);
}
}

View File

@@ -0,0 +1,162 @@
import { addPendingBaby, addReceptionAnimal } from "./zoo.js";
import { acceptSale, rejectSale, deliverSale, placeBid } from "./api-client.js";
import {
animalLabel,
salesPanelMySales,
salesPanelToRecover,
salesPanelAuctions,
salesBtnAccept,
salesBtnReject,
salesBtnDeliver,
salesBtnBid,
salesPendingValidation,
salesValidationInMinutes,
salesBidInputAriaLabel,
noFreeNursery,
noFreeReception,
} from "./texts-fr.js";
/**
* @param {HTMLElement} panel
* @param {{ asSeller?: Array<{ id: string, animal_id: string, is_baby: boolean, initial_price: number, best_bid_amount?: number | null }> }} api
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
*/
export function addSalesPanelSellerSection(panel, api, ctx) {
if (!api.asSeller || api.asSeller.length === 0) return;
const { state, setState, setError, animalEmoji } = ctx;
const sellerTitle = document.createElement("div");
sellerTitle.className = "sales-panel-title";
sellerTitle.textContent = salesPanelMySales;
panel.appendChild(sellerTitle);
for (const s of api.asSeller) {
const row = document.createElement("div");
row.className = "sales-panel-row";
const emoji = animalEmoji[s.animal_id] ?? "🐾";
const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id);
row.innerHTML = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${s.initial_price} 💰</span>`;
if (s.best_bid_amount !== null) {
const btnWrap = document.createElement("div");
btnWrap.className = "sales-panel-actions";
const acceptBtn = document.createElement("button");
acceptBtn.type = "button";
acceptBtn.textContent = salesBtnAccept;
acceptBtn.className = "sales-btn-accept";
acceptBtn.addEventListener("click", () => {
acceptSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); });
});
const rejectBtn = document.createElement("button");
rejectBtn.type = "button";
rejectBtn.textContent = salesBtnReject;
rejectBtn.className = "sales-btn-reject";
rejectBtn.addEventListener("click", () => {
rejectSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); });
});
btnWrap.appendChild(acceptBtn);
btnWrap.appendChild(rejectBtn);
row.appendChild(btnWrap);
}
panel.appendChild(row);
}
}
/**
* @param {{ id: string, animal_id: string, is_baby: boolean, status?: string, validated_at?: string }} s
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
* @returns {HTMLElement}
*/
export function createBuyerDeliverRow(s, ctx) {
const { state, setState, setError, animalEmoji } = ctx;
const row = document.createElement("div");
row.className = "sales-panel-row";
const emoji = animalEmoji[s.animal_id] ?? "🐾";
const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id);
row.innerHTML = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span>`;
const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0;
const nowMs = Date.now();
const pendingValidation = s.status === "sold" && validatedAtMs > nowMs;
if (pendingValidation) {
const remainingMin = Math.ceil((validatedAtMs - nowMs) / 60000);
const pendingEl = document.createElement("span");
pendingEl.className = "sales-pending-validation";
pendingEl.setAttribute("aria-label", salesPendingValidation);
pendingEl.textContent = `${salesValidationInMinutes.replace("%s", String(remainingMin))}`;
row.appendChild(pendingEl);
}
const deliverBtn = document.createElement("button");
deliverBtn.type = "button";
deliverBtn.textContent = salesBtnDeliver;
deliverBtn.className = "sales-btn-deliver";
deliverBtn.disabled = pendingValidation;
deliverBtn.addEventListener("click", () => {
const [ok, keyOrReason] = s.is_baby ? addPendingBaby(state, s.animal_id, true) : addReceptionAnimal(state, s.animal_id);
if (!ok) {
setError(keyOrReason === "NoFreeNursery" ? noFreeNursery : keyOrReason === "NoFreeReception" ? noFreeReception : String(keyOrReason));
setState();
return;
}
setState();
deliverSale(s.id).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); });
});
row.appendChild(deliverBtn);
return row;
}
/**
* @param {HTMLElement} panel
* @param {{ asBuyerUndelivered?: Array<{ id: string, animal_id: string, is_baby: boolean, status?: string, validated_at?: string }> }} api
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
*/
export function addSalesPanelBuyerSection(panel, api, ctx) {
if (!api.asBuyerUndelivered || api.asBuyerUndelivered.length === 0) return;
const buyerTitle = document.createElement("div");
buyerTitle.className = "sales-panel-title";
buyerTitle.textContent = salesPanelToRecover;
panel.appendChild(buyerTitle);
for (const s of api.asBuyerUndelivered) {
panel.appendChild(createBuyerDeliverRow(s, ctx));
}
}
/**
* @param {HTMLElement} panel
* @param {{ active?: Array<{ id: string, seller_zoo_id: string, animal_id: string, is_baby: boolean, initial_price: number, best_bid_amount?: number | null }> }} api
* @param {string} playerZooId
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
*/
export function addSalesPanelActiveSection(panel, api, playerZooId, ctx) {
if (!api.active || api.active.length === 0) return;
const { state, setState, setError, animalEmoji } = ctx;
const activeTitle = document.createElement("div");
activeTitle.className = "sales-panel-title";
activeTitle.textContent = salesPanelAuctions;
panel.appendChild(activeTitle);
for (const s of api.active) {
if (s.seller_zoo_id === playerZooId) {
// skip own listings in active list
} else {
const row = document.createElement("div");
row.className = "sales-panel-row sales-panel-row-bid";
const emoji = animalEmoji[s.animal_id] ?? "🐾";
const label = s.is_baby ? `Bébé ${animalLabel[s.animal_id] ?? s.animal_id}` : (animalLabel[s.animal_id] ?? s.animal_id);
const minBid = (s.best_bid_amount ?? s.initial_price) + 1;
row.innerHTML = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${s.initial_price} 💰</span>`;
const input = document.createElement("input");
input.type = "number";
input.min = String(minBid);
input.value = String(minBid);
input.className = "sales-bid-input";
input.setAttribute("aria-label", salesBidInputAriaLabel);
const bidBtn = document.createElement("button");
bidBtn.type = "button";
bidBtn.textContent = salesBtnBid;
bidBtn.className = "sales-btn-bid";
bidBtn.addEventListener("click", () => {
const amount = Number(input.value) || minBid;
placeBid(s.id, amount).then(() => { state.salesFromApi = undefined; setState(); }).catch((e) => { setError(e.message || "Erreur"); setState(); });
});
row.appendChild(input);
row.appendChild(bidBtn);
panel.appendChild(row);
}
}
}

View File

@@ -0,0 +1,213 @@
import { tryBuyLabEgg } from "./zoo.js";
import { GameConfig } from "./config.js";
import { eggTypeLabel, errorMessage, t } from "./texts-fr.js";
const EGG_EMOJI = "🥚";
/**
* @param {{ state: import("./types.js").GameState, setError: (s: string) => void, playSound: (s: string) => void, setState: () => void, pendingTokenByEggType: Record<string, number> }} ctx
* @param {{ eggType: string }} labOffer
* @param {boolean} ok
* @param {{ tokenId?: number } | string} result
*/
function handleLabOfferClick(ctx, labOffer, ok, result) {
if (!ok) {
const msg = errorMessage[result] ?? result;
ctx.setError(String(t.buyFailed).replace("%s", msg));
ctx.playSound("error");
ctx.setState();
return;
}
ctx.setError("");
ctx.playSound("buy");
ctx.pendingTokenByEggType[labOffer.eggType] = result.tokenId;
ctx.setState();
}
/**
* @param {{ eggType: string, price: number }} labOffer
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, pendingTokenByEggType: Record<string, number> }} ctx
* @returns {HTMLElement}
*/
function createLabOfferButton(labOffer, ctx) {
const { state } = ctx;
const el = document.createElement("div");
el.className = "offer-btn world-map-offer world-map-offer-single world-map-lab-offer";
el.setAttribute("role", "button");
el.setAttribute("tabindex", "0");
el.setAttribute("draggable", "true");
const name = eggTypeLabel[labOffer.eggType] ?? labOffer.eggType;
el.innerHTML = `<span class="offer-emoji">${EGG_EMOJI}</span><span class="offer-label">${name}</span><span class="offer-price">${labOffer.price} pièces</span>`;
let dragStarted = false;
el.addEventListener("dragstart", (e) => {
dragStarted = true;
e.dataTransfer.setData("application/x-builazoo-eggtype", labOffer.eggType);
e.dataTransfer.effectAllowed = "copy";
el.classList.add("dragging");
});
el.addEventListener("dragend", () => { dragStarted = false; el.classList.remove("dragging"); });
el.addEventListener("click", () => {
if (dragStarted) return;
const [ok, result] = tryBuyLabEgg(state, labOffer.eggType);
handleLabOfferClick(ctx, labOffer, ok, result);
});
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); el.click(); }
});
return el;
}
/**
* @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, pendingTokenByEggType: Record<string, number> }} ctx
*/
export function renderLab(ctx) {
const lab = GameConfig.WorldMap?.Laboratory;
if (!lab) return;
const { state, worldMapEl } = ctx;
const labNode = document.createElement("div");
labNode.className = "world-map-lab";
labNode.style.left = `${lab.x}%`;
labNode.style.top = `${lab.y}%`;
labNode.dataset.poi = "laboratory";
const labNameEl = document.createElement("div");
labNameEl.className = "world-map-zoo-name";
labNameEl.textContent = lab.name ?? "Laboratoire";
labNode.appendChild(labNameEl);
const labSlotEl = document.createElement("div");
labSlotEl.className = "world-map-zoo-slot";
const labOffer = state.laboratoryOffer;
if (labOffer) {
labSlotEl.appendChild(createLabOfferButton(labOffer, ctx));
} else {
const iconEl = document.createElement("span");
iconEl.className = "world-map-zoo-icon";
iconEl.setAttribute("aria-hidden", "true");
iconEl.textContent = "🔬";
labSlotEl.appendChild(iconEl);
}
labNode.appendChild(labSlotEl);
worldMapEl.appendChild(labNode);
}
/**
* @param {{ worldMapTruckEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx
* @param {Array<import("./types.js").WorldZoo>} zoos
* @param {number} truckMs
* @returns {boolean} true if truck was updated (need setTimeout)
*/
function updateTruckSale(ctx, zoos, truckMs) {
const { worldMapTruckEl, state, setState } = ctx;
const truckSale = state.truckSale;
if (!truckSale || !truckSale.toZooId) return false;
const elapsed = Date.now() - (truckSale.startAt || 0);
if (elapsed >= truckMs) {
delete state.truckSale;
return false;
}
const fromZoo = zoos.find((z) => z.id === "player");
const toZoo = zoos.find((z) => z.id === truckSale.toZooId);
if (!fromZoo || !toZoo) return false;
const progress = elapsed / truckMs;
const x = fromZoo.x + (toZoo.x - fromZoo.x) * progress;
const y = fromZoo.y + (toZoo.y - fromZoo.y) * progress;
worldMapTruckEl.style.display = "block";
worldMapTruckEl.style.left = `${x}%`;
worldMapTruckEl.style.top = `${y}%`;
worldMapTruckEl.textContent = "🚚";
setTimeout(setState, 50);
return true;
}
/**
* @param {{ worldMapTruckEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx
* @param {Array<import("./types.js").WorldZoo>} zoos
* @param {number} truckMs
* @returns {boolean} true if truck was updated (need setTimeout)
*/
function updateEggPurchaseTruck(ctx, zoos, truckMs) {
const { worldMapTruckEl, state, setState } = ctx;
const eggPurchase = state.eggPurchaseTruck;
const truckLevel = state.truckLevel ?? 1;
if (!eggPurchase || !eggPurchase.startAt) return false;
const durationMs = Math.max(1000, (truckMs * 2) / truckLevel);
const elapsed = Date.now() - eggPurchase.startAt;
if (elapsed >= durationMs) {
delete state.eggPurchaseTruck;
worldMapTruckEl.style.display = "none";
return false;
}
const fromZoo = zoos.find((z) => z.id === eggPurchase.fromZooId);
const toZoo = zoos.find((z) => z.id === eggPurchase.toZooId);
if (!fromZoo || !toZoo) return false;
const progress = elapsed / durationMs;
const leg = progress < 0.5 ? progress * 2 : (progress - 0.5) * 2;
const x = progress < 0.5
? fromZoo.x + (toZoo.x - fromZoo.x) * leg
: toZoo.x + (fromZoo.x - toZoo.x) * leg;
const y = progress < 0.5
? fromZoo.y + (toZoo.y - fromZoo.y) * leg
: toZoo.y + (fromZoo.y - toZoo.y) * leg;
worldMapTruckEl.style.display = "block";
worldMapTruckEl.style.left = `${x}%`;
worldMapTruckEl.style.top = `${y}%`;
worldMapTruckEl.textContent = "🚚";
setTimeout(setState, 50);
return true;
}
/**
* @param {{ worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx
* @param {Array<import("./types.js").WorldZoo>} zoos
*/
function updatePlayerTruck(ctx, zoos) {
const { worldMapTruckEl } = ctx;
const truckMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500;
if (updateTruckSale(ctx, zoos, truckMs)) return;
if (updateEggPurchaseTruck(ctx, zoos, truckMs)) return;
worldMapTruckEl.style.display = "none";
}
/**
* @param {{ worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx
* @param {Array<import("./types.js").WorldZoo>} zoos
* @param {number} truckMs
*/
function renderNpcTrucks(ctx, zoos, truckMs) {
const { worldMapNpcTrucksEl, state } = ctx;
worldMapNpcTrucksEl.innerHTML = "";
const npcTrucks = state.worldTruckSales ?? [];
for (const truck of npcTrucks) {
const fromZoo = zoos.find((z) => z.id === truck.fromZooId);
const toZoo = zoos.find((z) => z.id === truck.toZooId);
if (fromZoo && toZoo) {
const elapsed = Date.now() - (truck.startAt || 0);
if (elapsed < truckMs) {
const progress = elapsed / truckMs;
const x = fromZoo.x + (toZoo.x - fromZoo.x) * progress;
const y = fromZoo.y + (toZoo.y - fromZoo.y) * progress;
const truckDiv = document.createElement("div");
truckDiv.className = "world-map-truck world-map-truck-npc";
truckDiv.style.left = `${x}%`;
truckDiv.style.top = `${y}%`;
truckDiv.textContent = "🚚";
worldMapNpcTrucksEl.appendChild(truckDiv);
}
}
}
}
/**
* @param {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void }} ctx
* @param {Array<import("./types.js").WorldZoo>} zoos
*/
export function renderTruckAndNpcTrucks(ctx, zoos) {
const truckMs = (GameConfig.WorldMap && GameConfig.WorldMap.TruckAnimationMs) || 2500;
const truckSale = ctx.state.truckSale;
const eggPurchase = ctx.state.eggPurchaseTruck;
updatePlayerTruck(ctx, zoos);
renderNpcTrucks(ctx, zoos, truckMs);
const npcTrucks = ctx.state.worldTruckSales ?? [];
if (npcTrucks.length > 0 || (truckSale && truckSale.toZooId) || (eggPurchase && eggPurchase.startAt)) {
setTimeout(ctx.setState, 50);
}
}

242
web/js/ui-world-map.js Normal file
View File

@@ -0,0 +1,242 @@
import { getCellBiome } from "./biome-rules.js";
import { mapServerListingToClient } from "./api-client.js";
import { defaultAnimalWeights } from "./state.js";
import { eggTypeLabel, animalLabel, salesPanelAriaLabel } from "./texts-fr.js";
import { addSalesPanelSellerSection, addSalesPanelBuyerSection, addSalesPanelActiveSection } from "./ui-world-map-sales.js";
import { renderCities } from "./ui-world-map-cities.js";
import { renderLab, renderTruckAndNpcTrucks } from "./ui-world-map-trucks.js";
const EGG_EMOJI = "🥚";
const WORLD_MAP_GRID_COLS = 12;
const WORLD_MAP_GRID_ROWS = 8;
/**
* @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
* @param {import("./api-client.js").SalesFromApi | null} api
* @param {string} playerZooId
*/
function renderSalesPanel(ctx, api, playerZooId) {
const salesPanel = document.createElement("div");
salesPanel.className = "world-map-sales-panel";
salesPanel.setAttribute("aria-label", salesPanelAriaLabel);
if (api) {
addSalesPanelSellerSection(salesPanel, api, ctx);
addSalesPanelBuyerSection(salesPanel, api, ctx);
addSalesPanelActiveSection(salesPanel, api, playerZooId, ctx);
}
if (salesPanel.childNodes.length > 0) ctx.worldMapEl.appendChild(salesPanel);
}
/**
* @param {{ worldMapEl: HTMLElement }} ctx
*/
function renderCellsLayer(ctx) {
const cellsLayer = document.createElement("div");
cellsLayer.className = "world-map-cells";
cellsLayer.setAttribute("aria-hidden", "true");
cellsLayer.style.gridTemplateColumns = `repeat(${WORLD_MAP_GRID_COLS}, 1fr)`;
cellsLayer.style.gridTemplateRows = `repeat(${WORLD_MAP_GRID_ROWS}, 1fr)`;
for (let row = 0; row < WORLD_MAP_GRID_ROWS; row++) {
for (let col = 0; col < WORLD_MAP_GRID_COLS; col++) {
const cellDiv = document.createElement("div");
cellDiv.className = "world-map-cell";
const biome = getCellBiome(WORLD_MAP_GRID_COLS, WORLD_MAP_GRID_ROWS, col + 1, row + 1);
cellDiv.classList.add(`world-map-cell-${biome.toLowerCase()}`);
cellsLayer.appendChild(cellDiv);
}
}
ctx.worldMapEl.appendChild(cellsLayer);
}
/**
* @param {HTMLElement} slotEl
* @param {Array<{ animalId: string, isBaby: boolean, price: number }>} listings
* @param {{ animalEmoji: Record<string, string> }} ctx
*/
function addZooSlotListings(slotEl, listings, ctx) {
for (const listing of listings.slice(0, 3)) {
const el = document.createElement("div");
el.className = "world-map-sale-listing";
const emoji = ctx.animalEmoji[listing.animalId] ?? "🐾";
const label = listing.isBaby ? `Bébé ${animalLabel[listing.animalId] ?? listing.animalId}` : (animalLabel[listing.animalId] ?? listing.animalId);
el.innerHTML = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${label}</span><span class="offer-price">${listing.price} 💰</span>`;
el.title = "En vente sur la carte (phase 10)";
slotEl.appendChild(el);
}
}
/**
* @param {HTMLElement} slotEl
* @param {{ eggType: string, price: number }} offer
* @param {string} zooId
* @param {{ state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void }} ctx
*/
function addZooSlotNpcOffer(slotEl, offer, zooId, ctx) {
const { setState, setError } = ctx;
const el = document.createElement("div");
el.className = "offer-btn world-map-offer world-map-offer-single";
el.setAttribute("role", "button");
el.setAttribute("tabindex", "0");
el.setAttribute("draggable", "true");
const name = eggTypeLabel[offer.eggType] ?? offer.eggType;
el.innerHTML = `<span class="offer-emoji">${EGG_EMOJI}</span><span class="offer-label">${name}</span><span class="offer-price">${offer.price} pièces</span>`;
let dragStarted = false;
el.addEventListener("dragstart", (e) => {
dragStarted = true;
e.dataTransfer.setData("application/x-builazoo-eggtype", offer.eggType);
e.dataTransfer.setData("application/x-builazoo-offer-zooid", zooId);
e.dataTransfer.effectAllowed = "copy";
el.classList.add("dragging");
});
el.addEventListener("dragend", () => {
dragStarted = false;
el.classList.remove("dragging");
});
el.addEventListener("click", () => {
if (dragStarted) return;
setError("Glissez l'œuf sur le camion pour l'acheter.");
setState();
});
el.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
el.click();
}
});
slotEl.appendChild(el);
}
/**
* @param {HTMLElement} slotEl
* @param {{ animalId: string, price: number } | null} babyOffer
* @param {{ animalId: string, price: number } | null} animalOffer
* @param {{ animalEmoji: Record<string, string> }} ctx
*/
function addZooSlotPlayerOffers(slotEl, babyOffer, animalOffer, ctx) {
if (babyOffer) {
const el = document.createElement("div");
el.className = "offer-btn world-map-offer";
el.setAttribute("draggable", "true");
const emoji = ctx.animalEmoji[babyOffer.animalId] ?? "🐾";
const name = animalLabel[babyOffer.animalId] ?? babyOffer.animalId;
el.innerHTML = `<span class="offer-emoji">${emoji}</span><span class="offer-label">Bébé ${name}</span><span class="offer-price">${babyOffer.price}</span>`;
el.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("application/x-builazoo-baby-offer", `${babyOffer.animalId}:${babyOffer.price}`);
e.dataTransfer.effectAllowed = "copy";
});
slotEl.appendChild(el);
}
if (animalOffer) {
const el = document.createElement("div");
el.className = "offer-btn world-map-offer";
el.setAttribute("draggable", "true");
const emoji = ctx.animalEmoji[animalOffer.animalId] ?? "🐾";
const name = animalLabel[animalOffer.animalId] ?? animalOffer.animalId;
el.innerHTML = `<span class="offer-emoji">${emoji}</span><span class="offer-label">${name}</span><span class="offer-price">${animalOffer.price}</span>`;
el.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("application/x-builazoo-animal-offer", `${animalOffer.animalId}:${animalOffer.price}`);
e.dataTransfer.effectAllowed = "copy";
});
slotEl.appendChild(el);
}
}
/**
* @param {{ slotEl: HTMLElement, zoo: import("./types.js").WorldZoo, isPlayer: boolean, zooListings: Array<{ animalId: string, isBaby: boolean, price: number }>, oneOffer: import("./conveyor.js").ConveyorOffer | null, playerBabyOffer: { animalId: string, price: number } | null, playerAnimalOffer: { animalId: string, price: number } | null, ctx: { animalEmoji: Record<string, string>, setState: () => void, setError: (s: string) => void } }} opts
*/
function fillZooSlotContent(opts) {
const { slotEl, zoo, isPlayer, zooListings, oneOffer, playerBabyOffer, playerAnimalOffer, ctx } = opts;
if (isPlayer && zooListings.length > 0) {
addZooSlotListings(slotEl, zooListings, ctx);
} else if (oneOffer) {
addZooSlotNpcOffer(slotEl, oneOffer, zoo.id, ctx);
} else if (isPlayer && (playerBabyOffer || playerAnimalOffer)) {
addZooSlotPlayerOffers(slotEl, playerBabyOffer, playerAnimalOffer, ctx);
} else {
const iconEl = document.createElement("span");
iconEl.className = "world-map-zoo-icon";
iconEl.setAttribute("aria-hidden", "true");
iconEl.textContent = "🏠";
slotEl.appendChild(iconEl);
}
}
/**
* @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
* @param {import("./types.js").WorldZoo} zoo
* @param {Array<{ animalId: string, isBaby: boolean, price: number }>} zooListingsForPlayer
* @param {Array<import("./conveyor.js").ConveyorOffer>} offers
*/
function buildZooNode(ctx, zoo, zooListingsForPlayer, offers) {
const { state, worldMapEl } = ctx;
const isPlayer = zoo.id === "player";
const zooOffers = offers.filter((o) => (o.zooId ?? "player") === zoo.id);
const oneOffer = !isPlayer && zooOffers.length > 0 ? zooOffers[0] : null;
const playerBabyOffer = isPlayer ? zooOffers.find((o) => o.type === "baby") : null;
const playerAnimalOffer = isPlayer ? zooOffers.find((o) => o.type === "animal") : null;
const node = document.createElement("div");
node.className = "world-map-zoo" + (isPlayer ? " world-map-zoo-player" : "");
node.style.left = `${zoo.x}%`;
node.style.top = `${zoo.y}%`;
node.dataset.zooId = zoo.id;
const nameEl = document.createElement("div");
nameEl.className = "world-map-zoo-name";
nameEl.textContent = zoo.name;
node.appendChild(nameEl);
if (isPlayer) {
const scoreEl = document.createElement("div");
scoreEl.className = "world-map-zoo-reproduction-score";
scoreEl.textContent = `Score repro: ${(state.reproductionScore ?? 0).toFixed(1)}`;
node.appendChild(scoreEl);
const attrEl = document.createElement("div");
attrEl.className = "world-map-zoo-attractivity-score";
attrEl.textContent = `Score attractivité: ${(state.attractivityScore ?? 0).toFixed(1)}`;
node.appendChild(attrEl);
}
if (!isPlayer && zoo.botState) {
const indEl = document.createElement("div");
indEl.className = "world-map-zoo-indicators";
indEl.textContent = `${Math.floor(zoo.botState.coins)} · Parcelle ${zoo.botState.plotLevel}`;
node.appendChild(indEl);
}
const slotEl = document.createElement("div");
slotEl.className = "world-map-zoo-slot";
const zooListings = isPlayer ? zooListingsForPlayer : [];
fillZooSlotContent({ slotEl, zoo, isPlayer, zooListings, oneOffer, playerBabyOffer: playerBabyOffer ?? null, playerAnimalOffer: playerAnimalOffer ?? null, ctx });
node.appendChild(slotEl);
worldMapEl.appendChild(node);
}
/**
* @param {{ worldMapEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, animalEmoji: Record<string, string> }} ctx
* @param {Array<import("./types.js").WorldZoo>} zoos
* @param {Array<import("./conveyor.js").ConveyorOffer>} offers
* @param {Array<{ animalId: string, isBaby: boolean, price: number }>} zooListingsForPlayer
*/
function renderZoos(ctx, zoos, offers, zooListingsForPlayer) {
for (const zoo of zoos) {
buildZooNode(ctx, zoo, zooListingsForPlayer, offers);
}
}
/**
* @param {{ worldMapEl: HTMLElement, worldMapTruckEl: HTMLElement, worldMapNpcTrucksEl: HTMLElement, state: import("./types.js").GameState, setState: () => void, setError: (s: string) => void, playSound: (s: string) => void, animalEmoji: Record<string, string>, pendingTokenByEggType: Record<string, number> }} ctx
*/
export function renderWorldMap(ctx) {
ctx.worldMapEl.innerHTML = "";
const playerZooId = ctx.state.myZooId ?? "player";
const api = ctx.state.salesFromApi;
const myListingsFromApi = api?.asSeller ? api.asSeller.map(mapServerListingToClient) : null;
const zooListingsForPlayer = myListingsFromApi ?? (ctx.state.saleListings ?? []).filter((s) => s.zooId === playerZooId);
renderSalesPanel(ctx, api, playerZooId);
renderCellsLayer(ctx);
const zoos = ctx.state.worldZoos ?? [{ id: "player", name: "Mon zoo", x: 25, y: 50, animalWeights: defaultAnimalWeights() }];
const offers = ctx.state.conveyorOffers || [];
renderZoos(ctx, zoos, offers, zooListingsForPlayer);
renderCities(ctx);
renderLab(ctx);
renderTruckAndNpcTrucks(ctx, zoos);
}
export { renderSalesPanel, renderCellsLayer, renderZoos, WORLD_MAP_GRID_COLS, WORLD_MAP_GRID_ROWS, EGG_EMOJI };
export { renderCities } from "./ui-world-map-cities.js";

File diff suppressed because it is too large Load Diff

View File

@@ -11,24 +11,92 @@ export const INCIDENT_TYPES = ["thirst", "bin", "bench", "animalFar", "photo"];
/** Emoji per incident type for bubble display. */
export const INCIDENT_EMOJI = { thirst: "💧", bin: "🗑️", bench: "🪑", animalFar: "🦌", photo: "📷" };
/**
* True when truck (egg or sale) is in progress.
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
function hasTruckWait(state) {
if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true;
if (state.truckSale && state.truckSale.startAt) return true;
return false;
}
/**
* True when there are API sales as buyer undelivered with pending validation.
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
function hasApiUndeliveredWait(state) {
const api = state.salesFromApi;
if (!api || !api.asBuyerUndelivered || api.asBuyerUndelivered.length === 0) return false;
const nowMs = Date.now();
for (const s of api.asBuyerUndelivered) {
const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0;
const pending = (s.status === "sold" || s.status === "validated") && validatedAtMs > nowMs;
if (pending) return true;
}
return false;
}
/**
* True when player is in a wait phase (truck moving, sale pending validation, etc.).
* @param {import("./types.js").GameState} state
* @returns {boolean}
*/
export function isInWaitPhase(state) {
if (state.eggPurchaseTruck && state.eggPurchaseTruck.startAt) return true;
if (state.truckSale && state.truckSale.startAt) return true;
const api = state.salesFromApi;
if (api && api.asBuyerUndelivered && api.asBuyerUndelivered.length > 0) {
const nowMs = Date.now();
for (const s of api.asBuyerUndelivered) {
const validatedAtMs = s.validated_at ? new Date(s.validated_at).getTime() : 0;
const pending = (s.status === "sold" || s.status === "validated") && validatedAtMs > nowMs;
if (pending) return true;
return hasTruckWait(state) || hasApiUndeliveredWait(state);
}
/**
* Expire timed-out incidents and apply penalty. Returns indices to remove from arrivals.
* @param {import("./types.js").VisitorArrival[]} arrivals
* @param {{ nowUnix: number, timeoutSec: number, penalty: number, stateRef: { attractivityBonusFromIncidents: number } }} opts
* @returns {number[]}
*/
function expireIncidents(arrivals, opts) {
const { nowUnix, timeoutSec, penalty, stateRef } = opts;
const toRemove = [];
for (let i = 0; i < arrivals.length; i++) {
const v = arrivals[i];
if (v.incidentType !== null && v.incidentType !== undefined) {
if (nowUnix - (v.incidentSince ?? nowUnix) >= timeoutSec) {
stateRef.attractivityBonusFromIncidents = (stateRef.attractivityBonusFromIncidents ?? 0) - penalty;
toRemove.push(i);
}
}
}
return false;
return toRemove;
}
/**
* Spawn new incidents on visitors without one (by chance).
* @param {import("./types.js").VisitorArrival[]} arrivals
* @param {number} chance
* @param {number} nowUnix
*/
function spawnIncidents(arrivals, chance, nowUnix) {
for (let i = 0; i < arrivals.length; i++) {
const v = arrivals[i];
const hasNoIncident = v.incidentType === null || v.incidentType === undefined;
if (hasNoIncident && Math.random() < chance) {
v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)];
v.incidentSince = nowUnix;
}
}
}
/**
* @returns {{ baseChance: number, waitMult: number, timeoutSec: number, penalty: number }}
*/
function getIncidentConfig() {
const cfg = GameConfig.Visitor;
return {
baseChance: cfg?.IncidentChanceBase ?? 0.002,
waitMult: cfg?.IncidentChanceWaitMultiplier ?? 4,
timeoutSec: cfg?.IncidentTimeoutSeconds ?? 45,
penalty: cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2,
};
}
/**
@@ -38,30 +106,30 @@ export function isInWaitPhase(state) {
*/
export function tickVisitorIncidents(state, nowUnix) {
const arrivals = state.visitorArrivals ?? [];
const cfg = GameConfig.Visitor;
const baseChance = cfg?.IncidentChanceBase ?? 0.002;
const waitMult = cfg?.IncidentChanceWaitMultiplier ?? 4;
const timeoutSec = cfg?.IncidentTimeoutSeconds ?? 45;
const penalty = cfg?.IncidentUnresolvedAttractivityPenalty ?? 0.2;
const { baseChance, waitMult, timeoutSec, penalty } = getIncidentConfig();
const inWait = isInWaitPhase(state);
const chance = inWait ? baseChance * waitMult : baseChance;
const toRemove = [];
for (let i = 0; i < arrivals.length; i++) {
const v = arrivals[i];
if (v.incidentType !== null && v.incidentType !== undefined) {
if (nowUnix - (v.incidentSince ?? nowUnix) >= timeoutSec) {
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) - penalty;
toRemove.push(i);
}
} else if (Math.random() < chance) {
v.incidentType = INCIDENT_TYPES[Math.floor(Math.random() * INCIDENT_TYPES.length)];
v.incidentSince = nowUnix;
}
}
const toRemove = expireIncidents(arrivals, { nowUnix, timeoutSec, penalty, stateRef: state });
for (let r = toRemove.length - 1; r >= 0; r--) {
arrivals.splice(toRemove[r], 1);
}
spawnIncidents(arrivals, chance, nowUnix);
}
/**
* Apply resolve bonus (coins + attractivity) to state. Mutates state and v.
* @param {import("./types.js").GameState} state
* @param {import("./types.js").VisitorArrival} v
*/
function applyResolveBonus(state, v) {
const cfg = GameConfig.Visitor;
const coinBonus = cfg?.IncidentResolveCoinBonus ?? 8;
const attractivityBonus = cfg?.IncidentResolveAttractivityBonus ?? 0.15;
state.coins += coinBonus;
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) + attractivityBonus;
if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + coinBonus;
delete v.incidentType;
delete v.incidentSince;
}
/**
@@ -74,13 +142,6 @@ export function resolveIncident(state, visitorIndex) {
const arrivals = state.visitorArrivals ?? [];
const v = arrivals[visitorIndex];
if (!v || (v.incidentType === null || v.incidentType === undefined)) return false;
const cfg = GameConfig.Visitor;
const coinBonus = cfg?.IncidentResolveCoinBonus ?? 8;
const attractivityBonus = cfg?.IncidentResolveAttractivityBonus ?? 0.15;
state.coins += coinBonus;
state.attractivityBonusFromIncidents = (state.attractivityBonusFromIncidents ?? 0) + attractivityBonus;
if (state.stats) state.stats.coinsEarned = (state.stats.coinsEarned ?? 0) + coinBonus;
delete v.incidentType;
delete v.incidentSince;
applyResolveBonus(state, v);
return true;
}

90
web/js/zoo-placement.js Normal file
View File

@@ -0,0 +1,90 @@
import { LootTables } from "./loot-tables.js";
import { canPlaceMultiCell } from "./placement.js";
/**
* @param {import("./types.js").GameState} state
* @param {string} nurseryCellKey
* @param {number} nowUnix
* @returns {[false, string] | [true, import("./types.js").PendingBaby]}
*/
function getMatureBabyReady(state, nurseryCellKey, nowUnix) {
const baby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === nurseryCellKey);
if (baby === null || baby === undefined) return [false, "NoBaby"];
if (nowUnix < baby.readyAt) return [false, "BabyNotReady"];
return [true, baby];
}
/**
* @param {import("./types.js").GameState} state
* @param {{ nurseryCellKey: string, toX: number, toY: number, nowUnix: number }} opts
* @returns {[false, string] | [true, import("./types.js").AnimalCell]}
*/
export function getMatureBabyPlacementData(state, opts) {
const { nurseryCellKey, toX, toY, nowUnix } = opts;
const babyResult = getMatureBabyReady(state, nurseryCellKey, nowUnix);
if (babyResult[0] === false) return [false, babyResult[1]];
const baby = babyResult[1];
const def = LootTables.Animals[baby.animalId];
if (def === null || def === undefined) return [false, "UnknownAnimal"];
const w = def.cellsWide ?? 1;
const h = def.cellsHigh ?? 1;
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
if (!ok) return [false, reason ?? "NoPlace"];
const animalData = {
kind: "animal",
id: baby.animalId,
mutation: "none",
level: 1,
placedAt: nowUnix,
lastVisitedAt: nowUnix,
lastFedAt: nowUnix,
cellsWide: w,
cellsHigh: h,
fromOtherZoo: baby.fromOtherZoo === true,
};
return [true, animalData];
}
/**
* @param {import("./types.js").GameState} state
* @param {string} receptionCellKey
* @param {number} nowUnix
* @returns {[false, string] | [true, import("./types.js").ReceptionAnimal]}
*/
function getReceptionAnimalReady(state, receptionCellKey, nowUnix) {
const rec = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === receptionCellKey);
if (rec === null || rec === undefined) return [false, "NoReceptionAnimal"];
if (nowUnix < rec.readyAt) return [false, "AnimalNotReady"];
return [true, rec];
}
/**
* @param {import("./types.js").GameState} state
* @param {{ receptionCellKey: string, toX: number, toY: number, nowUnix: number }} opts
* @returns {[false, string] | [true, import("./types.js").AnimalCell]}
*/
export function getReceptionAnimalPlacementData(state, opts) {
const { receptionCellKey, toX, toY, nowUnix } = opts;
const recResult = getReceptionAnimalReady(state, receptionCellKey, nowUnix);
if (recResult[0] === false) return [false, recResult[1]];
const rec = recResult[1];
const def = LootTables.Animals[rec.animalId];
if (def === null || def === undefined) return [false, "UnknownAnimal"];
const w = def.cellsWide ?? 1;
const h = def.cellsHigh ?? 1;
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
if (!ok) return [false, reason ?? "NoPlace"];
const animalData = {
kind: "animal",
id: rec.animalId,
mutation: "none",
level: 1,
placedAt: nowUnix,
lastVisitedAt: nowUnix,
lastFedAt: nowUnix,
cellsWide: w,
cellsHigh: h,
fromOtherZoo: true,
};
return [true, animalData];
}

View File

@@ -3,7 +3,8 @@ import { LootTables, getRarityHatchMultiplierForEggType } from "./loot-tables.js
import { plotSizeFromLevel } from "./grid-utils.js";
import { getPlotUpgradeCost, getWorldMapUpgradeResearchCost } from "./economy.js";
import { findOffer } from "./conveyor.js";
import { placeEgg, fillAnimalBlock, canPlaceMultiCell } from "./placement.js";
import { placeEgg, fillAnimalBlock } from "./placement.js";
import { getMatureBabyPlacementData, getReceptionAnimalPlacementData } from "./zoo-placement.js";
import { getNurseryCellKeysOrdered, getFreeNurseryCellKey } from "./zoo-nursery.js";
export { getNurseryCellKeysOrdered, getFreeNurseryCellKey };
@@ -125,28 +126,9 @@ export function tryBuyAnimal(state, animalId, price) {
*/
export function placeMatureBabyOnCell(state, opts) {
const { nurseryCellKey, toX, toY, nowUnix } = opts;
const baby = (state.pendingBabies ?? []).find((p) => p.nurseryCellKey === nurseryCellKey);
if (baby === null || baby === undefined) return [false, "NoBaby"];
if (nowUnix < baby.readyAt) return [false, "BabyNotReady"];
const def = LootTables.Animals[baby.animalId];
if (def === null || def === undefined) return [false, "UnknownAnimal"];
const w = def.cellsWide ?? 1;
const h = def.cellsHigh ?? 1;
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
if (!ok) return [false, reason];
const animalData = {
kind: "animal",
id: baby.animalId,
mutation: "none",
level: 1,
placedAt: nowUnix,
lastVisitedAt: nowUnix,
lastFedAt: nowUnix,
cellsWide: w,
cellsHigh: h,
fromOtherZoo: baby.fromOtherZoo === true,
};
fillAnimalBlock(state, toX, toY, animalData);
const [ok, result] = getMatureBabyPlacementData(state, opts);
if (!ok) return [false, result];
fillAnimalBlock(state, toX, toY, result);
state.pendingBabies = (state.pendingBabies ?? []).filter((p) => p.nurseryCellKey !== nurseryCellKey);
state.lastEvolutionAt = nowUnix;
return [true, undefined];
@@ -160,28 +142,9 @@ export function placeMatureBabyOnCell(state, opts) {
*/
export function placeReceptionAnimalOnCell(state, opts) {
const { receptionCellKey, toX, toY, nowUnix } = opts;
const rec = (state.receptionAnimals ?? []).find((r) => r.receptionCellKey === receptionCellKey);
if (rec === null || rec === undefined) return [false, "NoReceptionAnimal"];
if (nowUnix < rec.readyAt) return [false, "AnimalNotReady"];
const def = LootTables.Animals[rec.animalId];
if (def === null || def === undefined) return [false, "UnknownAnimal"];
const w = def.cellsWide ?? 1;
const h = def.cellsHigh ?? 1;
const [ok, reason] = canPlaceMultiCell(state, { originX: toX, originY: toY, w, h });
if (!ok) return [false, reason];
const animalData = {
kind: "animal",
id: rec.animalId,
mutation: "none",
level: 1,
placedAt: nowUnix,
lastVisitedAt: nowUnix,
lastFedAt: nowUnix,
cellsWide: w,
cellsHigh: h,
fromOtherZoo: true,
};
fillAnimalBlock(state, toX, toY, animalData);
const [ok, result] = getReceptionAnimalPlacementData(state, opts);
if (!ok) return [false, result];
fillAnimalBlock(state, toX, toY, result);
state.receptionAnimals = (state.receptionAnimals ?? []).filter((r) => r.receptionCellKey !== receptionCellKey);
state.lastEvolutionAt = nowUnix;
return [true, undefined];