Compare commits
8 Commits
8cc9fdaa62
...
ed8cf87d4e
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8cf87d4e | |||
| f80f071c24 | |||
| 2c924c1e4a | |||
| 2a242e97cc | |||
| ae50908bc9 | |||
| 3ba10dad5f | |||
| 90761b3e13 | |||
| b58d39e707 |
4
Backend/database/migrations/003_save_version.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 003: Add save_version column for Sprint 3 migration system
|
||||||
|
-- Safe to run on existing data — defaults to 1 (Sprint 2 saves)
|
||||||
|
|
||||||
|
ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1 AFTER game_state;
|
||||||
@@ -16,6 +16,7 @@ CREATE TABLE game_saves (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT NOT NULL UNIQUE,
|
user_id INT NOT NULL UNIQUE,
|
||||||
game_state JSON NOT NULL,
|
game_state JSON NOT NULL,
|
||||||
|
save_version INT DEFAULT 1,
|
||||||
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
lifetime_tadpoles BIGINT DEFAULT 0,
|
lifetime_tadpoles BIGINT DEFAULT 0,
|
||||||
prestige_count INT DEFAULT 0,
|
prestige_count INT DEFAULT 0,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const tables = require("../tables");
|
const tables = require("../tables");
|
||||||
|
const { migrateSave } = require("../services/migrateSave");
|
||||||
|
|
||||||
// --- Anti-cheat validation ---
|
// --- Anti-cheat validation ---
|
||||||
|
|
||||||
@@ -75,11 +76,14 @@ const load = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// game_state est stocké en JSON — MySQL le retourne comme objet si type JSON
|
// game_state est stocké en JSON — MySQL le retourne comme objet si type JSON
|
||||||
const gameState =
|
const rawState =
|
||||||
typeof save.game_state === "string"
|
typeof save.game_state === "string"
|
||||||
? JSON.parse(save.game_state)
|
? JSON.parse(save.game_state)
|
||||||
: save.game_state;
|
: save.game_state;
|
||||||
|
|
||||||
|
// Migrate on load — lazy migration, never touch DB rows directly
|
||||||
|
const gameState = migrateSave(rawState);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
gameState,
|
gameState,
|
||||||
lastSave: save.last_save,
|
lastSave: save.last_save,
|
||||||
|
|||||||
@@ -15,18 +15,20 @@ class GameSaveManager extends AbstractManager {
|
|||||||
|
|
||||||
async upsert(userId, gameState, metadata) {
|
async upsert(userId, gameState, metadata) {
|
||||||
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
|
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
|
||||||
|
const saveVersion = gameState.saveVersion ?? 1;
|
||||||
const gameStateJson = JSON.stringify(gameState);
|
const gameStateJson = JSON.stringify(gameState);
|
||||||
|
|
||||||
const [result] = await this.database.query(
|
const [result] = await this.database.query(
|
||||||
`INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds)
|
`INSERT INTO ${this.table} (user_id, game_state, save_version, lifetime_tadpoles, prestige_count, play_time_seconds)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
game_state = VALUES(game_state),
|
game_state = VALUES(game_state),
|
||||||
|
save_version = VALUES(save_version),
|
||||||
lifetime_tadpoles = VALUES(lifetime_tadpoles),
|
lifetime_tadpoles = VALUES(lifetime_tadpoles),
|
||||||
prestige_count = VALUES(prestige_count),
|
prestige_count = VALUES(prestige_count),
|
||||||
play_time_seconds = VALUES(play_time_seconds),
|
play_time_seconds = VALUES(play_time_seconds),
|
||||||
last_save = CURRENT_TIMESTAMP`,
|
last_save = CURRENT_TIMESTAMP`,
|
||||||
[userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds]
|
[userId, gameStateJson, saveVersion, lifetimeTadpoles, prestigeCount, playTimeSeconds]
|
||||||
);
|
);
|
||||||
|
|
||||||
return result.affectedRows;
|
return result.affectedRows;
|
||||||
|
|||||||
65
Backend/src/services/migrateSave.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// migrateSave.js — Backend save migration (mirrors Frontend/src/core/migrateSave.ts)
|
||||||
|
// Applied on load — lazy migration, never touch DB directly.
|
||||||
|
|
||||||
|
const CURRENT_SAVE_VERSION = 2;
|
||||||
|
|
||||||
|
// Default evolution tree (Sprint 2 — 18 nodes)
|
||||||
|
// Used to merge new nodes into old saves
|
||||||
|
const DEFAULT_TREE_IDS = [
|
||||||
|
"ponte_amelioree", "double_ponte", "ponte_frenetique", "auto_ponte",
|
||||||
|
"ponte_critique", "maitre_pondeur",
|
||||||
|
"instinct_gregaire", "symbiose_algale", "courant_profond", "maree_haute",
|
||||||
|
"ecosysteme_mature", "marais_eternel",
|
||||||
|
"memoire_genetique", "adn_renforce", "eveil_rapide", "resilience",
|
||||||
|
"heritage", "transcendance",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a raw game state to the current version.
|
||||||
|
* Backend only needs structural migration for anti-cheat validation —
|
||||||
|
* the full tree/generator merge happens on the frontend.
|
||||||
|
*/
|
||||||
|
function migrateSave(raw) {
|
||||||
|
if (!raw || typeof raw !== "object") return raw;
|
||||||
|
|
||||||
|
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
|
||||||
|
let state = { ...raw };
|
||||||
|
|
||||||
|
if (version < 2) {
|
||||||
|
state = migrateV1toV2(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateV1toV2(state) {
|
||||||
|
state.saveVersion = 2;
|
||||||
|
|
||||||
|
// RunStats
|
||||||
|
if (!state.runStats) {
|
||||||
|
state.runStats = {
|
||||||
|
startedAt: state.lastTick || Date.now(),
|
||||||
|
tadpolesProduced: 0,
|
||||||
|
bestRun: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree reset fields
|
||||||
|
if (typeof state.freeResetAvailable !== "boolean") {
|
||||||
|
state.freeResetAvailable = true;
|
||||||
|
}
|
||||||
|
if (typeof state.extraResetsUsed !== "number") {
|
||||||
|
state.extraResetsUsed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill cosmetics
|
||||||
|
if (!state.lastOnline) state.lastOnline = state.lastTick;
|
||||||
|
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
|
||||||
|
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
|
||||||
|
state.cosmeticEquipped = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { migrateSave, CURRENT_SAVE_VERSION };
|
||||||
49
Frontend/package-lock.json
generated
@@ -14,7 +14,6 @@
|
|||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-lottie-player": "^1.5.5",
|
"react-lottie-player": "^1.5.5",
|
||||||
"react-router-dom": "^6.19.0",
|
"react-router-dom": "^6.19.0",
|
||||||
"sass": "^1.69.5",
|
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
@@ -1033,9 +1032,11 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||||
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3",
|
"detect-libc": "^2.0.3",
|
||||||
"is-glob": "^4.0.3",
|
"is-glob": "^4.0.3",
|
||||||
@@ -1072,11 +1073,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1092,11 +1095,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1112,11 +1117,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1132,11 +1139,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1152,11 +1161,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1172,11 +1183,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1192,11 +1205,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1212,11 +1227,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1232,11 +1249,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1252,11 +1271,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1272,11 +1293,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1292,11 +1315,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -1312,11 +1337,13 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
},
|
},
|
||||||
@@ -2579,6 +2606,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -3600,7 +3629,9 @@
|
|||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
@@ -3757,7 +3788,7 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3793,7 +3824,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
},
|
},
|
||||||
@@ -4442,8 +4473,10 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.13",
|
"version": "2.0.13",
|
||||||
@@ -4685,7 +4718,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -4885,6 +4918,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.18.0"
|
"node": ">= 14.18.0"
|
||||||
},
|
},
|
||||||
@@ -5114,6 +5149,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
|
||||||
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
|
"integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.1.5",
|
"immutable": "^5.1.5",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"react-helmet": "^6.1.0",
|
"react-helmet": "^6.1.0",
|
||||||
"react-lottie-player": "^1.5.5",
|
"react-lottie-player": "^1.5.5",
|
||||||
"react-router-dom": "^6.19.0",
|
"react-router-dom": "^6.19.0",
|
||||||
"sass": "^1.69.5",
|
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
|
|||||||
1
Frontend/public/svg/cosmetics/armor-scales.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g fill="#6b7280" opacity="0.5"><ellipse cx="13" cy="16" rx="3" ry="2"/><ellipse cx="19" cy="16" rx="3" ry="2"/><ellipse cx="16" cy="20" rx="3" ry="2"/><ellipse cx="13" cy="24" rx="3" ry="2"/><ellipse cx="19" cy="24" rx="3" ry="2"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
1
Frontend/public/svg/cosmetics/cap-swamp.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><ellipse cx="16" cy="8" rx="10" ry="4" fill="#34d399"/><rect x="6" y="7" width="20" height="3" rx="1" fill="#059669"/></svg>
|
||||||
|
After Width: | Height: | Size: 185 B |
1
Frontend/public/svg/cosmetics/cape-algae.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M10,14 Q6,20 8,28 L16,26 L24,28 Q26,20 22,14Z" fill="#059669" opacity="0.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 153 B |
1
Frontend/public/svg/cosmetics/crown.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><polygon points="8,10 11,4 16,8 21,4 24,10" fill="#fbbf24"/><rect x="8" y="10" width="16" height="3" rx="1" fill="#f59e0b"/></svg>
|
||||||
|
After Width: | Height: | Size: 191 B |
1
Frontend/public/svg/cosmetics/flame-tail.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M14,22 Q12,18 16,16 Q20,18 18,22 Q20,20 18,26 Q16,28 14,26 Q12,24 14,22Z" fill="#f59e0b" opacity="0.8"/></svg>
|
||||||
|
After Width: | Height: | Size: 180 B |
1
Frontend/public/svg/cosmetics/glasses-savant.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="11" cy="13" r="3" fill="none" stroke="#a78bfa" stroke-width="1.5"/><circle cx="21" cy="13" r="3" fill="none" stroke="#a78bfa" stroke-width="1.5"/><line x1="14" y1="13" x2="18" y2="13" stroke="#a78bfa" stroke-width="1"/></svg>
|
||||||
|
After Width: | Height: | Size: 298 B |
1
Frontend/public/svg/cosmetics/mask-frog.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8,12 Q16,18 24,12 Q24,16 16,17 Q8,16 8,12Z" fill="#34d399" opacity="0.7"/></svg>
|
||||||
|
After Width: | Height: | Size: 151 B |
1
Frontend/public/svg/cosmetics/particles-gold.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g fill="#fbbf24" opacity="0.6"><circle cx="6" cy="8" r="1.2"/><circle cx="26" cy="6" r="1"/><circle cx="4" cy="20" r="0.8"/><circle cx="28" cy="22" r="1.2"/><circle cx="10" cy="28" r="1"/><circle cx="22" cy="30" r="0.8"/><circle cx="16" cy="2" r="1"/><circle cx="2" cy="14" r="0.8"/><circle cx="30" cy="14" r="1"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 386 B |
1
Frontend/public/svg/cosmetics/ribbon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M12,22 L16,20 L20,22 L18,26 L16,24 L14,26Z" fill="#ec4899"/><circle cx="16" cy="21" r="2" fill="#f472b6"/></svg>
|
||||||
|
After Width: | Height: | Size: 182 B |
@@ -5,10 +5,7 @@ import Navbar from "./components/navbar";
|
|||||||
import Footer from "./components/footer";
|
import Footer from "./components/footer";
|
||||||
import { GameTick } from "./components/GameTick";
|
import { GameTick } from "./components/GameTick";
|
||||||
import { GameSync } from "./components/GameSync";
|
import { GameSync } from "./components/GameSync";
|
||||||
|
import { OfflineReport } from "./components/OfflineReport";
|
||||||
import "./scss/root.scss";
|
|
||||||
import "./scss/zones.scss";
|
|
||||||
import "./scss/components/footer.scss";
|
|
||||||
|
|
||||||
import navData from "./data/NavBarData.json";
|
import navData from "./data/NavBarData.json";
|
||||||
|
|
||||||
@@ -19,6 +16,7 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<GameTick />
|
<GameTick />
|
||||||
<GameSync />
|
<GameSync />
|
||||||
|
<OfflineReport />
|
||||||
<Navbar
|
<Navbar
|
||||||
navData={navData}
|
navData={navData}
|
||||||
toggleRain={toggleRain}
|
toggleRain={toggleRain}
|
||||||
|
|||||||
53
Frontend/src/__tests__/balance.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { postCapstoneCost, treeResetCost } from "../core/balance";
|
||||||
|
|
||||||
|
describe("postCapstoneCost", () => {
|
||||||
|
it("first purchase = base cost (no multiplier)", () => {
|
||||||
|
expect(postCapstoneCost(500, 0)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies ×1.5 for purchases 1-5", () => {
|
||||||
|
expect(postCapstoneCost(500, 1)).toBe(750);
|
||||||
|
expect(postCapstoneCost(500, 2)).toBe(Math.floor(500 * 1.5 * 1.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses ×1.8 tier for purchases 5-9", () => {
|
||||||
|
const at5 = postCapstoneCost(500, 5);
|
||||||
|
const at6 = postCapstoneCost(500, 6);
|
||||||
|
// Ratio should be ~1.8 (floor rounding tolerance ±1)
|
||||||
|
expect(at6 / at5).toBeCloseTo(1.8, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses ×2.0 tier for purchases 10+", () => {
|
||||||
|
const at10 = postCapstoneCost(500, 10);
|
||||||
|
const at11 = postCapstoneCost(500, 11);
|
||||||
|
expect(at11 / at10).toBeCloseTo(2.0, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cost always increases", () => {
|
||||||
|
let prev = 0;
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const cost = postCapstoneCost(500, i);
|
||||||
|
expect(cost).toBeGreaterThan(prev);
|
||||||
|
prev = cost;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("treeResetCost", () => {
|
||||||
|
it("free reset costs 0", () => {
|
||||||
|
expect(treeResetCost(true, 0)).toBe(0);
|
||||||
|
expect(treeResetCost(true, 5)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first paid reset costs 5 ADN", () => {
|
||||||
|
expect(treeResetCost(false, 0)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scales linearly", () => {
|
||||||
|
expect(treeResetCost(false, 0)).toBe(5);
|
||||||
|
expect(treeResetCost(false, 1)).toBe(10);
|
||||||
|
expect(treeResetCost(false, 2)).toBe(15);
|
||||||
|
expect(treeResetCost(false, 3)).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
Frontend/src/__tests__/cosmetics.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
COSMETICS,
|
||||||
|
shouldUnlockCosmetic,
|
||||||
|
computeNewUnlocks,
|
||||||
|
equipCosmetic,
|
||||||
|
unequipSlot,
|
||||||
|
addToInventory,
|
||||||
|
DEFAULT_COSMETIC_STATE,
|
||||||
|
} from "../core/cosmetics";
|
||||||
|
import { DEFAULT_STATE } from "../core/economy";
|
||||||
|
|
||||||
|
describe("Cosmetics system", () => {
|
||||||
|
describe("COSMETICS catalog", () => {
|
||||||
|
it("a 10 cosmétiques", () => {
|
||||||
|
expect(COSMETICS.length).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("2 cosmétiques par slot", () => {
|
||||||
|
const slots = ["hat", "eyes", "body", "tail", "accessory"];
|
||||||
|
for (const slot of slots) {
|
||||||
|
expect(COSMETICS.filter((c) => c.slot === slot).length).toBe(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ids uniques", () => {
|
||||||
|
const ids = COSMETICS.map((c) => c.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldUnlockCosmetic", () => {
|
||||||
|
it("unlock prestige_3 si prestigeCount >= 3", () => {
|
||||||
|
const cos = COSMETICS.find((c) => c.sourceId === "prestige_3")!;
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 3 };
|
||||||
|
expect(shouldUnlockCosmetic(cos, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pas d'unlock prestige_10 si prestigeCount < 10", () => {
|
||||||
|
const cos = COSMETICS.find((c) => c.sourceId === "prestige_10")!;
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||||
|
expect(shouldUnlockCosmetic(cos, state)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlock achievement 'first_prestige' si prestigeCount >= 1", () => {
|
||||||
|
const cos = COSMETICS.find((c) => c.sourceId === "first_prestige")!;
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
|
||||||
|
expect(shouldUnlockCosmetic(cos, state)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pas d'unlock achievement si condition non remplie", () => {
|
||||||
|
const cos = COSMETICS.find((c) => c.sourceId === "empire")!;
|
||||||
|
expect(shouldUnlockCosmetic(cos, DEFAULT_STATE)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeNewUnlocks", () => {
|
||||||
|
it("retourne les cosmétiques nouvellement débloqués", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||||
|
const newUnlocks = computeNewUnlocks(state, DEFAULT_COSMETIC_STATE);
|
||||||
|
// prestige_3 (particles_gold) + prestige_5 (glasses_savant) + first_prestige (ribbon) + veteran (aura_swamp)
|
||||||
|
expect(newUnlocks).toContain("particles_gold");
|
||||||
|
expect(newUnlocks).toContain("glasses_savant");
|
||||||
|
expect(newUnlocks).toContain("ribbon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne retourne pas les cosmétiques déjà dans l'inventaire", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||||
|
const cosState = { ...DEFAULT_COSMETIC_STATE, inventory: ["particles_gold"] };
|
||||||
|
const newUnlocks = computeNewUnlocks(state, cosState);
|
||||||
|
expect(newUnlocks).not.toContain("particles_gold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne vide si rien à débloquer", () => {
|
||||||
|
expect(computeNewUnlocks(DEFAULT_STATE, DEFAULT_COSMETIC_STATE)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipCosmetic", () => {
|
||||||
|
it("équipe un cosmétique dans le bon slot", () => {
|
||||||
|
const cosState = { inventory: ["ribbon"], equipped: {} };
|
||||||
|
const result = equipCosmetic(cosState, "ribbon");
|
||||||
|
expect(result.equipped.tail).toBe("ribbon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne fait rien si cosmétique pas dans l'inventaire", () => {
|
||||||
|
const result = equipCosmetic(DEFAULT_COSMETIC_STATE, "ribbon");
|
||||||
|
expect(result.equipped).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remplace le cosmétique déjà équipé dans le même slot", () => {
|
||||||
|
const cosState = { inventory: ["ribbon", "flame_tail"], equipped: { tail: "ribbon" } };
|
||||||
|
const result = equipCosmetic(cosState, "flame_tail");
|
||||||
|
expect(result.equipped.tail).toBe("flame_tail");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unequipSlot", () => {
|
||||||
|
it("retire le cosmétique du slot", () => {
|
||||||
|
const cosState = { inventory: ["ribbon"], equipped: { tail: "ribbon" } };
|
||||||
|
const result = unequipSlot(cosState, "tail");
|
||||||
|
expect(result.equipped.tail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne touche pas les autres slots", () => {
|
||||||
|
const cosState = {
|
||||||
|
inventory: ["ribbon", "crown"],
|
||||||
|
equipped: { tail: "ribbon", hat: "crown" },
|
||||||
|
};
|
||||||
|
const result = unequipSlot(cosState, "tail");
|
||||||
|
expect(result.equipped.hat).toBe("crown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addToInventory", () => {
|
||||||
|
it("ajoute des ids", () => {
|
||||||
|
const result = addToInventory(DEFAULT_COSMETIC_STATE, ["ribbon", "crown"]);
|
||||||
|
expect(result.inventory).toEqual(["ribbon", "crown"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pas de doublons", () => {
|
||||||
|
const cosState = { ...DEFAULT_COSMETIC_STATE, inventory: ["ribbon"] };
|
||||||
|
const result = addToInventory(cosState, ["ribbon", "crown"]);
|
||||||
|
expect(result.inventory).toEqual(["ribbon", "crown"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,19 @@ import {
|
|||||||
buyGenerator,
|
buyGenerator,
|
||||||
applyPrestige,
|
applyPrestige,
|
||||||
canPrestige,
|
canPrestige,
|
||||||
|
getPrestigeThreshold,
|
||||||
computePrestigeDna,
|
computePrestigeDna,
|
||||||
canBuyEvolutionNode,
|
canBuyEvolutionNode,
|
||||||
buyEvolutionNode,
|
buyEvolutionNode,
|
||||||
|
resetEvolutionTree,
|
||||||
getClickMultiplierFromTree,
|
getClickMultiplierFromTree,
|
||||||
getProductionMultiplierFromTree,
|
getProductionMultiplierFromTree,
|
||||||
getStartBonusFromTree,
|
getStartBonusFromTree,
|
||||||
|
getPrestigeDnaBonus,
|
||||||
|
getCostReduction,
|
||||||
|
getAutoClicksPerSecond,
|
||||||
|
offlineEfficiency,
|
||||||
|
computeOfflineGains,
|
||||||
DEFAULT_STATE,
|
DEFAULT_STATE,
|
||||||
DEFAULT_GENERATORS,
|
DEFAULT_GENERATORS,
|
||||||
DEFAULT_EVOLUTION_TREE,
|
DEFAULT_EVOLUTION_TREE,
|
||||||
@@ -167,13 +174,15 @@ describe("computeIdleGains (lazy calculation)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Click ---
|
// --- Click (avec double + crit) ---
|
||||||
|
|
||||||
describe("applyClick", () => {
|
describe("applyClick", () => {
|
||||||
it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => {
|
it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => {
|
||||||
const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 };
|
const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 };
|
||||||
const result = applyClick(state);
|
const result = applyClick(state, 0.99); // rng high → no double, no crit
|
||||||
expect(result.resources).toBe(6);
|
expect(result.state.resources).toBe(6);
|
||||||
|
expect(result.isDouble).toBe(false);
|
||||||
|
expect(result.isCrit).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applique le multiplicateur click de l'arbre", () => {
|
it("applique le multiplicateur click de l'arbre", () => {
|
||||||
@@ -185,14 +194,58 @@ describe("applyClick", () => {
|
|||||||
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const result = applyClick(state);
|
const result = applyClick(state, 0.99);
|
||||||
expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée
|
expect(result.state.resources).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("incrémente lifetimeTadpoles", () => {
|
it("incrémente lifetimeTadpoles", () => {
|
||||||
const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 };
|
const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 };
|
||||||
const result = applyClick(state);
|
const result = applyClick(state, 0.99);
|
||||||
expect(result.lifetimeTadpoles).toBe(5);
|
expect(result.state.lifetimeTadpoles).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("double ponte x2 quand rng < doubleClickChance", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
clickMultiplier: 1,
|
||||||
|
prestigeMultiplier: 1,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "double_ponte" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// double_ponte = 10% chance, rng=0.05 < 0.10 → double
|
||||||
|
const result = applyClick(state, 0.05);
|
||||||
|
expect(result.isDouble).toBe(true);
|
||||||
|
expect(result.gain).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pas de double ponte quand rng > doubleClickChance", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
clickMultiplier: 1,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "double_ponte" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const result = applyClick(state, 0.50);
|
||||||
|
expect(result.isDouble).toBe(false);
|
||||||
|
expect(result.gain).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("crit x10 quand critRng < critClickChance", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
clickMultiplier: 1,
|
||||||
|
prestigeMultiplier: 1,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "ponte_critique" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// ponte_critique = 5% chance, need critRng = (rng * 7.13) % 1 < 0.05
|
||||||
|
// rng = 0.007 → critRng = 0.04991 < 0.05 → crit!
|
||||||
|
const result = applyClick(state, 0.007);
|
||||||
|
expect(result.isCrit).toBe(true);
|
||||||
|
expect(result.gain).toBe(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,49 +299,68 @@ describe("computePrestigeDna", () => {
|
|||||||
expect(computePrestigeDna(0)).toBe(0);
|
expect(computePrestigeDna(0)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => {
|
it("retourne 0 sous le seuil de 1M", () => {
|
||||||
expect(computePrestigeDna(1e9)).toBe(150);
|
expect(computePrestigeDna(999_999)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => {
|
it("retourne 1 (clamp) à exactement 1M têtards", () => {
|
||||||
expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2)));
|
expect(computePrestigeDna(1e6)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => {
|
it("retourne 50 pour 10M têtards (log10(10) = 1)", () => {
|
||||||
const dna1 = computePrestigeDna(1e9);
|
expect(computePrestigeDna(10e6)).toBe(50);
|
||||||
const dna10 = computePrestigeDna(10e9);
|
});
|
||||||
expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1);
|
|
||||||
|
it("scaling log — 10M→100M donne 2× ADN (log10(100) = 2)", () => {
|
||||||
|
const dna10m = computePrestigeDna(10e6);
|
||||||
|
const dna100m = computePrestigeDna(100e6);
|
||||||
|
expect(dna100m / dna10m).toBeCloseTo(2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prestige bonus augmente le gain (+5% par prestige)", () => {
|
||||||
|
const base = computePrestigeDna(10e6, 0);
|
||||||
|
const with10 = computePrestigeDna(10e6, 10);
|
||||||
|
expect(with10).toBe(Math.max(1, Math.floor(50 * 1 * 1.5))); // 75
|
||||||
|
expect(with10).toBeGreaterThan(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prestige bonus cappé à ×4 (80+ prestiges)", () => {
|
||||||
|
const at80 = computePrestigeDna(10e6, 80);
|
||||||
|
const at100 = computePrestigeDna(10e6, 100);
|
||||||
|
expect(at80).toBe(at100); // cap atteint
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Arbre d'Évolution ---
|
// --- Arbre d'Évolution 3 voies ---
|
||||||
|
|
||||||
|
describe("Evolution Tree (3 branches)", () => {
|
||||||
|
it("arbre V2 : 3 branches + cross (~30 nœuds)", () => {
|
||||||
|
const ponte = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "ponte");
|
||||||
|
const marais = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "marais");
|
||||||
|
const adaptation = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "adaptation");
|
||||||
|
const cross = DEFAULT_EVOLUTION_TREE.filter((n) => n.branch === "cross");
|
||||||
|
expect(ponte.length).toBe(8);
|
||||||
|
expect(marais.length).toBe(8);
|
||||||
|
expect(adaptation.length).toBe(8);
|
||||||
|
expect(cross.length).toBe(1);
|
||||||
|
expect(DEFAULT_EVOLUTION_TREE.length).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
describe("Evolution Tree", () => {
|
|
||||||
describe("canBuyEvolutionNode", () => {
|
describe("canBuyEvolutionNode", () => {
|
||||||
it("peut acheter le premier nœud (pas de prérequis) avec assez d'ADN", () => {
|
it("peut acheter un nœud racine avec assez d'ADN", () => {
|
||||||
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
|
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
|
||||||
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true);
|
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(true);
|
||||||
|
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true);
|
||||||
|
expect(canBuyEvolutionNode(state, "memoire_genetique")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ne peut pas acheter sans assez d'ADN", () => {
|
it("ne peut pas acheter sans assez d'ADN", () => {
|
||||||
const state = { ...DEFAULT_STATE, ancestralDna: 0 };
|
expect(canBuyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBe(false);
|
||||||
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ne peut pas acheter un nœud déjà débloqué", () => {
|
|
||||||
const state = {
|
|
||||||
...DEFAULT_STATE,
|
|
||||||
ancestralDna: 100,
|
|
||||||
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
|
||||||
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
|
||||||
),
|
|
||||||
};
|
|
||||||
expect(canBuyEvolutionNode(state, "ponte_amelioree")).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
|
it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
|
||||||
const state = { ...DEFAULT_STATE, ancestralDna: 100 };
|
const state = { ...DEFAULT_STATE, ancestralDna: 100 };
|
||||||
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(false);
|
expect(canBuyEvolutionNode(state, "double_ponte")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("peut acheter un nœud si le prérequis est débloqué", () => {
|
it("peut acheter un nœud si le prérequis est débloqué", () => {
|
||||||
@@ -299,7 +371,32 @@ describe("Evolution Tree", () => {
|
|||||||
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
expect(canBuyEvolutionNode(state, "instinct_gregaire")).toBe(true);
|
expect(canBuyEvolutionNode(state, "double_ponte")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne peut pas acheter un nœud exclusif si l'alternative est débloquée", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
ancestralDna: 100,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } :
|
||||||
|
n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// concentration exclusive_with ponte_frenetique → locked
|
||||||
|
expect(canBuyEvolutionNode(state, "concentration")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("peut acheter un nœud exclusif si l'alternative n'est pas débloquée", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
ancestralDna: 100,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "double_ponte" || n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
expect(canBuyEvolutionNode(state, "concentration")).toBe(true);
|
||||||
|
expect(canBuyEvolutionNode(state, "ponte_frenetique")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,15 +410,32 @@ describe("Evolution Tree", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("retourne null si impossible", () => {
|
it("retourne null si impossible", () => {
|
||||||
const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree");
|
expect(buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBeNull();
|
||||||
expect(result).toBeNull();
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ne modifie pas les autres nœuds", () => {
|
describe("resetEvolutionTree", () => {
|
||||||
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
|
it("rembourse tout l'ADN dépensé et relock tous les nœuds", () => {
|
||||||
const result = buyEvolutionNode(state, "ponte_amelioree")!;
|
const state = {
|
||||||
const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree");
|
...DEFAULT_STATE,
|
||||||
expect(otherNodes.every((n) => n.unlocked === false)).toBe(true);
|
ancestralDna: 50,
|
||||||
|
prestigeCount: 1,
|
||||||
|
freeResetAvailable: true,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "ponte_amelioree" || n.id === "instinct_gregaire"
|
||||||
|
? { ...n, unlocked: true }
|
||||||
|
: n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// ponte_amelioree (1) + instinct_gregaire (3) = 4 ADN spent, free reset
|
||||||
|
const result = resetEvolutionTree(state);
|
||||||
|
expect(result.ancestralDna).toBe(54);
|
||||||
|
expect(result.evolutionTree.every((n) => !n.unlocked)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne change rien si aucun nœud débloqué", () => {
|
||||||
|
const result = resetEvolutionTree({ ...DEFAULT_STATE, ancestralDna: 10 });
|
||||||
|
expect(result.ancestralDna).toBe(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,6 +450,13 @@ describe("Evolution Tree", () => {
|
|||||||
);
|
);
|
||||||
expect(getClickMultiplierFromTree(tree)).toBe(2);
|
expect(getClickMultiplierFromTree(tree)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("multiplie si plusieurs nœuds click débloqués (2 × 3 = 6)", () => {
|
||||||
|
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
|
||||||
|
n.id === "ponte_amelioree" || n.id === "ponte_frenetique" ? { ...n, unlocked: true } : n
|
||||||
|
);
|
||||||
|
expect(getClickMultiplierFromTree(tree)).toBe(6);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getProductionMultiplierFromTree", () => {
|
describe("getProductionMultiplierFromTree", () => {
|
||||||
@@ -363,4 +484,168 @@ describe("Evolution Tree", () => {
|
|||||||
expect(getStartBonusFromTree(tree)).toBe(100);
|
expect(getStartBonusFromTree(tree)).toBe(100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("prestige_dna_bonus", () => {
|
||||||
|
it("ADN Renforcé + Héritage = +75% ADN", () => {
|
||||||
|
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
|
||||||
|
n.id === "adn_renforce" || n.id === "heritage" ? { ...n, unlocked: true } : n
|
||||||
|
);
|
||||||
|
expect(getPrestigeDnaBonus(tree)).toBeCloseTo(0.75);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cost_reduction", () => {
|
||||||
|
it("Marée Haute = -20% coût générateurs", () => {
|
||||||
|
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
|
||||||
|
n.id === "maree_haute" ? { ...n, unlocked: true } : n
|
||||||
|
);
|
||||||
|
expect(getCostReduction(tree)).toBeCloseTo(0.20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coût réduit appliqué via generatorCost", () => {
|
||||||
|
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
|
||||||
|
n.id === "maree_haute" ? { ...n, unlocked: true } : n
|
||||||
|
);
|
||||||
|
const gen = { ...DEFAULT_GENERATORS[0], owned: 0 };
|
||||||
|
const baseCost = generatorCost(gen);
|
||||||
|
const reducedCost = generatorCost(gen, tree);
|
||||||
|
expect(reducedCost).toBe(Math.floor(baseCost * 0.8));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prestige reset generators", () => {
|
||||||
|
it("prestige remet les générateurs à 0", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
resources: 2_000_000,
|
||||||
|
lifetimeTadpoles: 2_000_000,
|
||||||
|
generators: DEFAULT_STATE.generators.map((g) => ({ ...g, owned: 5 })),
|
||||||
|
};
|
||||||
|
const result = applyPrestige(state);
|
||||||
|
expect(result.generators.every((g) => g.owned === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auto_click (getAutoClicksPerSecond)", () => {
|
||||||
|
it("retourne 0 si capstone ponte non débloqué", () => {
|
||||||
|
expect(getAutoClicksPerSecond(DEFAULT_EVOLUTION_TREE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 1 si capstone Ponte Automatique débloqué", () => {
|
||||||
|
const tree = DEFAULT_EVOLUTION_TREE.map((n) =>
|
||||||
|
n.id === "ponte_auto" ? { ...n, unlocked: true } : n
|
||||||
|
);
|
||||||
|
expect(getAutoClicksPerSecond(tree)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prestige threshold reduction", () => {
|
||||||
|
it("Transcendance réduit le seuil de 50%", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "transcendance" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
expect(getPrestigeThreshold(state)).toBe(500_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canPrestige utilise le seuil réduit", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
resources: 600_000,
|
||||||
|
evolutionTree: DEFAULT_STATE.evolutionTree.map((n) =>
|
||||||
|
n.id === "transcendance" ? { ...n, unlocked: true } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
expect(canPrestige(state)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Offline gains (courbe inversée) ---
|
||||||
|
|
||||||
|
describe("offlineEfficiency", () => {
|
||||||
|
it("retourne 1.0 pour absence < 60s (pas offline)", () => {
|
||||||
|
expect(offlineEfficiency(30_000)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 1.0 pour absence de 10min (phase 100%)", () => {
|
||||||
|
expect(offlineEfficiency(10 * 60_000)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 1.0 à exactement 15min", () => {
|
||||||
|
expect(offlineEfficiency(15 * 60_000)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne ~0.625 à 30min (milieu decay 1.0→0.25)", () => {
|
||||||
|
const eff = offlineEfficiency(30 * 60_000);
|
||||||
|
// 30min = 15min dans la phase decay (15min-1h = 45min total)
|
||||||
|
// t = 15/45 = 0.333 → eff = 1 - 0.333 * 0.75 = 0.75
|
||||||
|
expect(eff).toBeCloseTo(0.75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 0.25 à exactement 1h (fin du decay)", () => {
|
||||||
|
expect(offlineEfficiency(60 * 60_000)).toBeCloseTo(0.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne ~0.125 à 1h30 (milieu 0.25→0)", () => {
|
||||||
|
const eff = offlineEfficiency(90 * 60_000);
|
||||||
|
expect(eff).toBeCloseTo(0.125, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 0 à exactement 2h", () => {
|
||||||
|
expect(offlineEfficiency(2 * 60 * 60_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 0 après 2h (cap)", () => {
|
||||||
|
expect(offlineEfficiency(5 * 60 * 60_000)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("courbe monotone décroissante", () => {
|
||||||
|
const points = [0, 10, 15, 30, 45, 60, 90, 120, 180].map(
|
||||||
|
(min) => offlineEfficiency(min * 60_000)
|
||||||
|
);
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
expect(points[i]).toBeLessThanOrEqual(points[i - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeOfflineGains", () => {
|
||||||
|
const stateWithProd = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
generators: DEFAULT_STATE.generators.map((g, i) =>
|
||||||
|
i === 0 ? { ...g, owned: 10 } : g
|
||||||
|
),
|
||||||
|
lastTick: 0,
|
||||||
|
lastOnline: 0,
|
||||||
|
};
|
||||||
|
const pps = DEFAULT_GENERATORS[0].baseProduction * 10; // 1/s
|
||||||
|
|
||||||
|
it("gains normaux si absence < 60s", () => {
|
||||||
|
const gains = computeOfflineGains(stateWithProd, 30_000);
|
||||||
|
// < threshold → computeIdleGains classique
|
||||||
|
expect(gains).toBeCloseTo(pps * 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gains < idle pur pour absence de 1h", () => {
|
||||||
|
const gains = computeOfflineGains(stateWithProd, 60 * 60_000);
|
||||||
|
const fullIdleGains = pps * 3600;
|
||||||
|
expect(gains).toBeLessThan(fullIdleGains);
|
||||||
|
expect(gains).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gains = 0 pour absence > 2h si prod constante", () => {
|
||||||
|
// > 2h : tout tombe à 0%, mais les premières 2h produisent encore
|
||||||
|
const gains = computeOfflineGains(stateWithProd, 3 * 60 * 60_000);
|
||||||
|
const gainsAt2h = computeOfflineGains(stateWithProd, 2 * 60 * 60_000);
|
||||||
|
// gains at 3h should equal gains at 2h (nothing added after 2h)
|
||||||
|
expect(gains).toBeCloseTo(gainsAt2h, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 0 si aucune production", () => {
|
||||||
|
const gains = computeOfflineGains({ ...DEFAULT_STATE, lastTick: 0 }, 60 * 60_000);
|
||||||
|
expect(gains).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
123
Frontend/src/__tests__/migrateSave.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { migrateSave } from "../core/migrateSave";
|
||||||
|
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../core/economy";
|
||||||
|
import { CURRENT_SAVE_VERSION } from "../core/balance";
|
||||||
|
|
||||||
|
// Minimal Sprint 2 save (v1 — no saveVersion field)
|
||||||
|
function makeV1Save(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
resources: 1234,
|
||||||
|
clickMultiplier: 1,
|
||||||
|
generators: DEFAULT_GENERATORS.map((g) => ({ ...g, owned: 5 })),
|
||||||
|
lastTick: Date.now() - 60_000,
|
||||||
|
lastOnline: Date.now() - 60_000,
|
||||||
|
prestigeCount: 3,
|
||||||
|
prestigeMultiplier: 1.3,
|
||||||
|
ancestralDna: 42,
|
||||||
|
evolutionTree: DEFAULT_EVOLUTION_TREE.slice(0, 18).map((n, i) => ({
|
||||||
|
...n,
|
||||||
|
unlocked: i < 2, // first 2 nodes unlocked
|
||||||
|
})),
|
||||||
|
lifetimeTadpoles: 5_000_000,
|
||||||
|
cosmeticInventory: ["hat_lily"],
|
||||||
|
cosmeticEquipped: { hat: "hat_lily" },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("migrateSave", () => {
|
||||||
|
describe("v1 → v2", () => {
|
||||||
|
it("sets saveVersion to current", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
expect(result.saveVersion).toBe(CURRENT_SAVE_VERSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds runStats with defaults", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
expect(result.runStats).toBeDefined();
|
||||||
|
expect(result.runStats.tadpolesProduced).toBe(0);
|
||||||
|
expect(result.runStats.bestRun).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds freeResetAvailable and extraResetsUsed", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
expect(result.freeResetAvailable).toBe(true);
|
||||||
|
expect(result.extraResetsUsed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves unlocked state of existing tree nodes", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
const node0 = result.evolutionTree.find((n) => n.id === "ponte_amelioree");
|
||||||
|
const node2 = result.evolutionTree.find((n) => n.id === "ponte_frenetique");
|
||||||
|
expect(node0?.unlocked).toBe(true);
|
||||||
|
expect(node2?.unlocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves generator owned counts", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
expect(result.generators[0].owned).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves resources, ancestralDna, prestigeCount", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
expect(result.resources).toBe(1234);
|
||||||
|
expect(result.ancestralDna).toBe(42);
|
||||||
|
expect(result.prestigeCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves cosmetics", () => {
|
||||||
|
const result = migrateSave(makeV1Save());
|
||||||
|
expect(result.cosmeticInventory).toContain("hat_lily");
|
||||||
|
expect(result.cosmeticEquipped.hat).toBe("hat_lily");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backfill missing fields", () => {
|
||||||
|
it("backfills lastOnline from lastTick", () => {
|
||||||
|
const save = makeV1Save();
|
||||||
|
delete (save as Record<string, unknown>).lastOnline;
|
||||||
|
const result = migrateSave(save);
|
||||||
|
expect(result.lastOnline).toBe(save.lastTick);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfills empty cosmeticInventory", () => {
|
||||||
|
const save = makeV1Save();
|
||||||
|
delete (save as Record<string, unknown>).cosmeticInventory;
|
||||||
|
const result = migrateSave(save);
|
||||||
|
expect(result.cosmeticInventory).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backfills empty cosmeticEquipped", () => {
|
||||||
|
const save = makeV1Save();
|
||||||
|
delete (save as Record<string, unknown>).cosmeticEquipped;
|
||||||
|
const result = migrateSave(save);
|
||||||
|
expect(result.cosmeticEquipped).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("v2 passthrough", () => {
|
||||||
|
it("does not re-migrate a v2 save", () => {
|
||||||
|
const v2 = migrateSave(makeV1Save());
|
||||||
|
const result = migrateSave(v2 as unknown as Record<string, unknown>);
|
||||||
|
expect(result.saveVersion).toBe(CURRENT_SAVE_VERSION);
|
||||||
|
expect(result).toEqual(v2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("handles save with no evolutionTree (corrupted)", () => {
|
||||||
|
const save = makeV1Save();
|
||||||
|
delete (save as Record<string, unknown>).evolutionTree;
|
||||||
|
const result = migrateSave(save);
|
||||||
|
expect(result.evolutionTree.length).toBe(DEFAULT_EVOLUTION_TREE.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles save with no generators (corrupted)", () => {
|
||||||
|
const save = makeV1Save();
|
||||||
|
delete (save as Record<string, unknown>).generators;
|
||||||
|
const result = migrateSave(save);
|
||||||
|
expect(result.generators.length).toBe(DEFAULT_GENERATORS.length);
|
||||||
|
expect(result.generators[0].owned).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
101
Frontend/src/__tests__/milestones.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_STATE,
|
||||||
|
getClaimableMilestones,
|
||||||
|
getNextMilestone,
|
||||||
|
claimMilestone,
|
||||||
|
getMilestoneStartNid,
|
||||||
|
getMilestoneOfflineBonus,
|
||||||
|
} from "../core/economy";
|
||||||
|
|
||||||
|
describe("Prestige Milestones", () => {
|
||||||
|
it("no claimable milestones at 0 prestiges", () => {
|
||||||
|
expect(getClaimableMilestones(DEFAULT_STATE)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("milestone_1 claimable at 1 prestige", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
|
||||||
|
const claimable = getClaimableMilestones(state);
|
||||||
|
expect(claimable.length).toBe(1);
|
||||||
|
expect(claimable[0].id).toBe("milestone_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("multiple milestones claimable at 5 prestiges", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 5 };
|
||||||
|
const claimable = getClaimableMilestones(state);
|
||||||
|
expect(claimable.length).toBe(3); // 1, 3, 5
|
||||||
|
});
|
||||||
|
|
||||||
|
it("already claimed milestones not returned", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
prestigeCount: 5,
|
||||||
|
claimedMilestones: ["milestone_1", "milestone_3"],
|
||||||
|
};
|
||||||
|
const claimable = getClaimableMilestones(state);
|
||||||
|
expect(claimable.length).toBe(1);
|
||||||
|
expect(claimable[0].id).toBe("milestone_5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getNextMilestone returns first unachieved", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 2 };
|
||||||
|
const next = getNextMilestone(state);
|
||||||
|
expect(next?.id).toBe("milestone_3");
|
||||||
|
expect(next?.threshold).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getNextMilestone returns null when all achieved", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 200 };
|
||||||
|
expect(getNextMilestone(state)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("claimMilestone", () => {
|
||||||
|
it("claims successfully and adds to claimedMilestones", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
|
||||||
|
const result = claimMilestone(state, "milestone_1");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.claimedMilestones).toContain("milestone_1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cosmetic reward adds to inventory", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, prestigeCount: 1 };
|
||||||
|
const result = claimMilestone(state, "milestone_1");
|
||||||
|
expect(result!.cosmeticInventory).toContain("ribbon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot claim milestone not yet reached", () => {
|
||||||
|
const result = claimMilestone(DEFAULT_STATE, "milestone_1");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot claim already claimed milestone", () => {
|
||||||
|
const state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
prestigeCount: 1,
|
||||||
|
claimedMilestones: ["milestone_1"],
|
||||||
|
};
|
||||||
|
const result = claimMilestone(state, "milestone_1");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("milestone bonuses", () => {
|
||||||
|
it("getMilestoneStartNid returns 0 without milestone_5", () => {
|
||||||
|
expect(getMilestoneStartNid(DEFAULT_STATE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMilestoneStartNid returns 1 with milestone_5 claimed", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, claimedMilestones: ["milestone_5"] };
|
||||||
|
expect(getMilestoneStartNid(state)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMilestoneOfflineBonus returns 0 without milestone_15", () => {
|
||||||
|
expect(getMilestoneOfflineBonus(DEFAULT_STATE)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getMilestoneOfflineBonus returns 0.05 with milestone_15 claimed", () => {
|
||||||
|
const state = { ...DEFAULT_STATE, claimedMilestones: ["milestone_15"] };
|
||||||
|
expect(getMilestoneOfflineBonus(state)).toBe(0.05);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
Frontend/src/components/CosmeticsPanel.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// CosmeticsPanel.tsx — Inventaire cosmétique dans la sidebar
|
||||||
|
|
||||||
|
import { useGameStore } from "../store/useGameStore";
|
||||||
|
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
|
||||||
|
|
||||||
|
const SLOT_LABELS: Record<CosmeticSlot, string> = {
|
||||||
|
hat: "Tête",
|
||||||
|
eyes: "Yeux",
|
||||||
|
body: "Corps",
|
||||||
|
tail: "Queue",
|
||||||
|
accessory: "Aura",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SLOT_ORDER: CosmeticSlot[] = ["hat", "eyes", "body", "tail", "accessory"];
|
||||||
|
|
||||||
|
export function CosmeticsPanel() {
|
||||||
|
const inventory = useGameStore((s) => s.state.cosmeticInventory);
|
||||||
|
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
|
||||||
|
const equip = useGameStore((s) => s.equipCosmetic);
|
||||||
|
const unequip = useGameStore((s) => s.unequipCosmetic);
|
||||||
|
|
||||||
|
if (inventory.length === 0) return null;
|
||||||
|
|
||||||
|
const ownedCosmetics = COSMETICS.filter((c) => inventory.includes(c.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gp">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="gp-title">Cosmétiques</span>
|
||||||
|
<span className="gp-label">{inventory.length}/{COSMETICS.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{SLOT_ORDER.map((slot) => {
|
||||||
|
const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot);
|
||||||
|
if (slotCosmetics.length === 0) return null;
|
||||||
|
|
||||||
|
const equippedId = equipped[slot];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={slot} className="flex flex-col gap-0.5">
|
||||||
|
<span className="gp-zone-label">{SLOT_LABELS[slot]}</span>
|
||||||
|
{slotCosmetics.map((cos) => {
|
||||||
|
const isEquipped = equippedId === cos.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cos.id}
|
||||||
|
className={`gp-row ${isEquipped ? "gp-row--unlocked" : "gp-row--active"}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="gp-value text-[0.7rem]!">{cos.name}</span>
|
||||||
|
<span className="gp-label">{cos.description}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => isEquipped ? unequip(slot) : equip(cos.id)}
|
||||||
|
className={`gp-btn ${isEquipped ? "gp-btn--disabled" : "gp-btn--buy"}`}
|
||||||
|
>
|
||||||
|
{isEquipped ? "Retirer" : "Équiper"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,75 +1,265 @@
|
|||||||
// EvolutionTree.tsx — Arbre d'Évolution permanent (jamais reset)
|
// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
|
||||||
|
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
|
||||||
|
|
||||||
import { useGameStore } from "../store/useGameStore";
|
import { useGameStore } from "../store/useGameStore";
|
||||||
import { canBuyEvolutionNode } from "../core/economy";
|
import {
|
||||||
import type { EvolutionNode } from "../core/economy";
|
canBuyEvolutionNode,
|
||||||
|
getSpentDna,
|
||||||
|
getTreeResetCost,
|
||||||
|
canResetTree,
|
||||||
|
getRepeatableCost,
|
||||||
|
canUpgradeConvergence,
|
||||||
|
} from "../core/economy";
|
||||||
|
import type { EvolutionNode, Branch } from "../core/economy";
|
||||||
|
import { formatNumber } from "../utils/formatNumber";
|
||||||
|
|
||||||
const EFFECT_LABELS: Record<string, (v: number) => string> = {
|
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||||
click_multiplier: (v) => `x${v} ponte`,
|
click_multiplier: (v) => `x${v} ponte`,
|
||||||
production_multiplier: (v) => `x${v} production`,
|
production_multiplier: (v) => `x${v} production`,
|
||||||
start_bonus: (v) => `+${v} têtards au départ`,
|
start_bonus: (v) => `+${v} tetards au depart`,
|
||||||
unlock_generator: () => `Lac Mystique dès le début`,
|
unlock_generator: () => `Lac Mystique des le debut`,
|
||||||
achievement_scaling: (v) => `+${(v * 100).toFixed(0)}% prod/succès`,
|
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
|
||||||
|
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
|
||||||
|
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
|
||||||
|
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
|
||||||
|
generator_boost: (v) => `x${v} Nid`,
|
||||||
|
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
|
||||||
|
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
|
||||||
|
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
|
||||||
|
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
|
||||||
|
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
|
||||||
|
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||||
|
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
|
||||||
|
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BRANCH_CONFIG: Record<Branch | "cross", { label: string; color: string; accent: string }> = {
|
||||||
|
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
|
||||||
|
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
|
||||||
|
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
|
||||||
|
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function NodeRow({
|
function NodeRow({
|
||||||
node,
|
node,
|
||||||
canBuy,
|
canBuy,
|
||||||
|
isExcluded,
|
||||||
onBuy,
|
onBuy,
|
||||||
}: {
|
}: {
|
||||||
node: EvolutionNode;
|
node: EvolutionNode;
|
||||||
canBuy: boolean;
|
canBuy: boolean;
|
||||||
|
isExcluded: boolean;
|
||||||
onBuy: () => void;
|
onBuy: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const isCapstone = node.capstone;
|
||||||
|
const isRepeatable = node.repeatable;
|
||||||
|
const purchased = node.purchased ?? 0;
|
||||||
|
|
||||||
const rowClass = node.unlocked
|
const rowClass = node.unlocked
|
||||||
? "gp-row gp-row--unlocked"
|
? isCapstone
|
||||||
|
? "gp-row gp-row--unlocked border-amber-400/40!"
|
||||||
|
: "gp-row gp-row--unlocked"
|
||||||
|
: isExcluded
|
||||||
|
? "gp-row gp-row--locked opacity-30!"
|
||||||
: canBuy
|
: canBuy
|
||||||
? "gp-row gp-row--evolution"
|
? isCapstone
|
||||||
|
? "gp-row gp-row--evolution border-amber-400/30!"
|
||||||
|
: "gp-row gp-row--evolution"
|
||||||
: "gp-row gp-row--locked";
|
: "gp-row gp-row--locked";
|
||||||
|
|
||||||
|
const cost = isRepeatable && node.unlocked
|
||||||
|
? getRepeatableCost(node)
|
||||||
|
: isRepeatable
|
||||||
|
? node.cost
|
||||||
|
: node.cost;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={rowClass}>
|
<div className={rowClass}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="gp-value">{node.name}</span>
|
<div className="flex items-center gap-1">
|
||||||
<span className="gp-label">{EFFECT_LABELS[node.effect](node.value)}</span>
|
{isCapstone && <span className="text-amber-400 text-[0.6rem]">★</span>}
|
||||||
|
<span className="gp-value text-[0.7rem]!">{node.name}</span>
|
||||||
|
{isRepeatable && node.unlocked && (
|
||||||
|
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
|
||||||
|
)}
|
||||||
|
{node.exclusive_with && !node.unlocked && !isExcluded && (
|
||||||
|
<span className="gp-label text-[0.55rem]!">OU</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.unlocked ? (
|
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||||
|
</div>
|
||||||
|
{node.unlocked && !isRepeatable ? (
|
||||||
<span className="gp-label gp-accent-green">OK</span>
|
<span className="gp-label gp-accent-green">OK</span>
|
||||||
|
) : node.unlocked && isRepeatable ? (
|
||||||
|
<button
|
||||||
|
disabled={!canBuy}
|
||||||
|
onClick={onBuy}
|
||||||
|
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||||
|
>
|
||||||
|
{formatNumber(cost)}
|
||||||
|
</button>
|
||||||
|
) : isExcluded ? (
|
||||||
|
<span className="gp-label text-[0.55rem]!">verrouille</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
disabled={!canBuy}
|
disabled={!canBuy}
|
||||||
onClick={onBuy}
|
onClick={onBuy}
|
||||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||||
style={canBuy ? { background: "#d97706" } : {}}
|
|
||||||
>
|
>
|
||||||
{node.cost} ADN
|
{formatNumber(cost)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BranchColumn({ branch }: { branch: Branch }) {
|
||||||
|
const state = useGameStore((s) => s.state);
|
||||||
|
const buyNode = useGameStore((s) => s.buyNode);
|
||||||
|
const nodes = state.evolutionTree.filter((n) => n.branch === branch);
|
||||||
|
const config = BRANCH_CONFIG[branch];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
|
||||||
|
<span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const isExcluded = node.exclusive_with
|
||||||
|
? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<NodeRow
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
canBuy={canBuyEvolutionNode(state, node.id)}
|
||||||
|
isExcluded={isExcluded}
|
||||||
|
onBuy={() => buyNode(node.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvergenceSection() {
|
||||||
|
const state = useGameStore((s) => s.state);
|
||||||
|
const buyNode = useGameStore((s) => s.buyNode);
|
||||||
|
const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode);
|
||||||
|
const conv = state.evolutionTree.find((n) => n.id === "convergence");
|
||||||
|
|
||||||
|
if (!conv) return null;
|
||||||
|
|
||||||
|
const canBuy = canBuyEvolutionNode(state, "convergence");
|
||||||
|
const canUpgrade = canUpgradeConvergence(state);
|
||||||
|
const tier = conv.tier ?? 1;
|
||||||
|
const maxTier = conv.maxTier ?? 2;
|
||||||
|
const tierName = tier >= 2 ? "Omega" : "Alpha";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gp border-t-2 border-purple-500/30">
|
||||||
|
<span className="gp-title text-center gp-accent-purple">
|
||||||
|
Convergence {conv.unlocked ? tierName : ""}
|
||||||
|
</span>
|
||||||
|
{conv.unlocked ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="gp-row gp-row--unlocked border-purple-400/30!">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="gp-value text-[0.7rem]!">
|
||||||
|
{tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier})
|
||||||
|
</span>
|
||||||
|
<span className="gp-label">
|
||||||
|
{tier >= 2
|
||||||
|
? "+10% tous effets + -20% cout post-capstones"
|
||||||
|
: "+10% a tous les effets de l'arbre"
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="gp-label gp-accent-green">OK</span>
|
||||||
|
</div>
|
||||||
|
{tier < maxTier && (
|
||||||
|
<button
|
||||||
|
disabled={!canUpgrade}
|
||||||
|
onClick={upgradeConv}
|
||||||
|
className={`gp-btn ${canUpgrade ? "gp-btn--buy" : "gp-btn--disabled"} w-full`}
|
||||||
|
>
|
||||||
|
{canUpgrade
|
||||||
|
? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)`
|
||||||
|
: `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="gp-row gp-row--locked">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="gp-value text-[0.7rem]!">Convergence Alpha</span>
|
||||||
|
<span className="gp-label">+10% a tous les effets de l'arbre</span>
|
||||||
|
<span className="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
disabled={!canBuy}
|
||||||
|
onClick={() => buyNode("convergence")}
|
||||||
|
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||||
|
>
|
||||||
|
{conv.cost}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EvolutionTree() {
|
export function EvolutionTree() {
|
||||||
const state = useGameStore((s) => s.state);
|
const state = useGameStore((s) => s.state);
|
||||||
const buyNode = useGameStore((s) => s.buyNode);
|
const resetTree = useGameStore((s) => s.resetTree);
|
||||||
const { evolutionTree, prestigeCount } = state;
|
const { prestigeCount, ancestralDna, evolutionTree } = state;
|
||||||
|
|
||||||
if (prestigeCount < 1) return null;
|
if (prestigeCount < 1) return null;
|
||||||
|
|
||||||
|
const spentDna = getSpentDna(evolutionTree);
|
||||||
|
const hasUnlocked = spentDna > 0;
|
||||||
|
const resetCost = getTreeResetCost(state);
|
||||||
|
const canReset = canResetTree(state);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (!canReset) return;
|
||||||
|
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
|
||||||
|
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
|
||||||
|
`Tous les noeuds seront verrouilles.\n\n` +
|
||||||
|
`Confirmer ?`
|
||||||
|
);
|
||||||
|
if (confirmed) resetTree();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gp">
|
<div className="flex flex-col gap-2">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<div className="flex justify-between items-center px-1">
|
||||||
<span className="gp-title">Évolution</span>
|
<span className="gp-title">Evolution</span>
|
||||||
<span className="gp-value gp-accent-amber">{state.ancestralDna} ADN</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
|
||||||
|
{hasUnlocked && (
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!canReset}
|
||||||
|
className={`gp-btn text-[0.55rem]! ${
|
||||||
|
canReset
|
||||||
|
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
|
||||||
|
: "gp-btn--disabled"
|
||||||
|
}`}
|
||||||
|
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
|
||||||
|
>
|
||||||
|
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{evolutionTree.map((node) => (
|
</div>
|
||||||
<NodeRow
|
<div className="flex gap-1.5">
|
||||||
key={node.id}
|
<BranchColumn branch="ponte" />
|
||||||
node={node}
|
<BranchColumn branch="marais" />
|
||||||
canBuy={canBuyEvolutionNode(state, node.id)}
|
<BranchColumn branch="adaptation" />
|
||||||
onBuy={() => buyNode(node.id)}
|
</div>
|
||||||
/>
|
<ConvergenceSection />
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ export function GeneratorShop() {
|
|||||||
const resources = useGameStore((s) => s.state.resources);
|
const resources = useGameStore((s) => s.state.resources);
|
||||||
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
||||||
const buy = useGameStore((s) => s.buy);
|
const buy = useGameStore((s) => s.buy);
|
||||||
const generatorCost = useGameStore((s) => s.generatorCost);
|
const generatorCost = useGameStore((s) => s.generatorCostWithTree);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gp">
|
<div className="gp">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<div className="flex justify-between items-center">
|
||||||
<span className="gp-title" title="Achète des générateurs pour produire des têtards automatiquement">Générateurs</span>
|
<span className="gp-title" title="Achète des générateurs pour produire des têtards automatiquement">Générateurs</span>
|
||||||
<span className="gp-value gp-accent-green" title="Production totale par seconde">{formatNumber(productionPerSecond)}/s</span>
|
<span className="gp-value gp-accent-green" title="Production totale par seconde">{formatNumber(productionPerSecond)}/s</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,8 +26,8 @@ export function GeneratorShop() {
|
|||||||
key={gen.id}
|
key={gen.id}
|
||||||
className={`gp-row ${canAfford ? "gp-row--active" : "gp-row--locked"}`}
|
className={`gp-row ${canAfford ? "gp-row--active" : "gp-row--locked"}`}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div className="flex flex-col min-w-0">
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.3rem" }}>
|
<div className="flex items-center gap-1">
|
||||||
<span className="gp-value">{gen.name}</span>
|
<span className="gp-value">{gen.name}</span>
|
||||||
{gen.owned > 0 && (
|
{gen.owned > 0 && (
|
||||||
<span className="gp-label gp-accent-green">x{gen.owned}</span>
|
<span className="gp-label gp-accent-green">x{gen.owned}</span>
|
||||||
|
|||||||
@@ -1,35 +1,33 @@
|
|||||||
// MilestoneBar.tsx — Progression vers le prochain prestige
|
// MilestoneBar.tsx — Progression vers le prochain prestige
|
||||||
|
|
||||||
import { useGameStore } from "../store/useGameStore";
|
import { useGameStore } from "../store/useGameStore";
|
||||||
import { formatNumber } from "../utils/formatNumber";
|
import { formatNumber, } from "../utils/formatNumber";
|
||||||
|
import { getPrestigeThreshold } from "../core/economy";
|
||||||
const PRESTIGE_THRESHOLD = 1_000_000;
|
|
||||||
|
|
||||||
export function MilestoneBar() {
|
export function MilestoneBar() {
|
||||||
const resources = useGameStore((s) => s.state.resources);
|
const state = useGameStore((s) => s.state);
|
||||||
|
const resources = state.resources;
|
||||||
|
const threshold = getPrestigeThreshold(state);
|
||||||
|
|
||||||
const progress = Math.min(resources / PRESTIGE_THRESHOLD, 1);
|
const progress = Math.min(resources / threshold, 1);
|
||||||
const progressPercent = (progress * 100).toFixed(1);
|
const progressPercent = (progress * 100).toFixed(1);
|
||||||
const remaining = Math.max(PRESTIGE_THRESHOLD - resources, 0);
|
const remaining = Math.max(threshold - resources, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gp" style={{ gap: "0.25rem" }}>
|
<div className="gp gap-1">
|
||||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
<div className="flex justify-between">
|
||||||
<span className="gp-label">Prochaine Génération</span>
|
<span className="gp-label">Prochaine Génération</span>
|
||||||
<span className="gp-label">
|
<span className="gp-label">
|
||||||
{formatNumber(resources)} / {formatNumber(PRESTIGE_THRESHOLD)}
|
{formatNumber(resources)} / {formatNumber(threshold)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="gp-progress">
|
<div className="gp-progress">
|
||||||
<div
|
<div
|
||||||
className="gp-progress-fill"
|
className="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400"
|
||||||
style={{
|
style={{ width: `${progressPercent}%` }}
|
||||||
width: `${progressPercent}%`,
|
|
||||||
background: "linear-gradient(90deg, #7c3aed, #a78bfa)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="gp-label" style={{ textAlign: "right" }}>
|
<span className="gp-label text-right">
|
||||||
{remaining > 0
|
{remaining > 0
|
||||||
? `${formatNumber(remaining)} restants`
|
? `${formatNumber(remaining)} restants`
|
||||||
: "Nouvelle Génération disponible !"}
|
: "Nouvelle Génération disponible !"}
|
||||||
|
|||||||
89
Frontend/src/components/MilestonesPanel.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// MilestonesPanel.tsx — Paliers de prestige (Sprint 3)
|
||||||
|
// Progress bar vers le prochain milestone, claim button, preview reward
|
||||||
|
|
||||||
|
import { useGameStore } from "../store/useGameStore";
|
||||||
|
import { getClaimableMilestones, getNextMilestone } from "../core/economy";
|
||||||
|
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
|
||||||
|
|
||||||
|
export function MilestonesPanel() {
|
||||||
|
const state = useGameStore((s) => s.state);
|
||||||
|
const claim = useGameStore((s) => s.claimMilestone);
|
||||||
|
|
||||||
|
if (state.prestigeCount < 1) return null;
|
||||||
|
|
||||||
|
const claimable = getClaimableMilestones(state);
|
||||||
|
const nextMilestone = getNextMilestone(state);
|
||||||
|
const totalClaimed = state.claimedMilestones.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gp">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="gp-title">Milestones</span>
|
||||||
|
<span className="gp-label">{totalClaimed}/{PRESTIGE_MILESTONES.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Claimable milestones */}
|
||||||
|
{claimable.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{claimable.map((m) => (
|
||||||
|
<div key={m.id} className="gp-row gp-row--evolution border-purple-400/30!">
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="gp-value text-[0.7rem]!">{m.name}</span>
|
||||||
|
<span className="gp-label">{m.reward.label}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => claim(m.id)}
|
||||||
|
className="gp-btn gp-btn--buy"
|
||||||
|
>
|
||||||
|
Claim
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress vers le prochain milestone */}
|
||||||
|
{nextMilestone && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Prochain : {nextMilestone.name}</span>
|
||||||
|
<span className="gp-label">
|
||||||
|
{state.prestigeCount}/{nextMilestone.threshold}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="gp-progress">
|
||||||
|
<div
|
||||||
|
className="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="gp-label">{nextMilestone.reward.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tous les milestones réclamés */}
|
||||||
|
{!nextMilestone && claimable.length === 0 && (
|
||||||
|
<span className="gp-label text-center gp-accent-purple">
|
||||||
|
Tous les milestones reclames !
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste compacte des milestones passés */}
|
||||||
|
{totalClaimed > 0 && claimable.length === 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{PRESTIGE_MILESTONES.filter((m) => state.claimedMilestones.includes(m.id)).map((m) => (
|
||||||
|
<span
|
||||||
|
key={m.id}
|
||||||
|
className="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
|
||||||
|
title={`${m.name} — ${m.description}`}
|
||||||
|
>
|
||||||
|
{m.threshold}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
Frontend/src/components/OfflineReport.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// OfflineReport.tsx — Écran "Pendant ton absence..." affiché au retour offline
|
||||||
|
|
||||||
|
import { useGameStore } from "../store/useGameStore";
|
||||||
|
import { formatNumber } from "../utils/formatNumber";
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const minutes = Math.floor(ms / 60_000);
|
||||||
|
if (minutes < 60) return `${minutes}min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainMinutes = minutes % 60;
|
||||||
|
return remainMinutes > 0 ? `${hours}h${remainMinutes}min` : `${hours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfflineReport() {
|
||||||
|
const report = useGameStore((s) => s.offlineReport);
|
||||||
|
const dismiss = useGameStore((s) => s.dismissOfflineReport);
|
||||||
|
|
||||||
|
if (!report || !report.wasOffline) return null;
|
||||||
|
|
||||||
|
const effPercent = Math.round(report.efficiency * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||||
|
<div className="gp max-w-sm w-full mx-4 text-center">
|
||||||
|
<span className="gp-title text-lg!">Pendant ton absence...</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 mt-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Durée</span>
|
||||||
|
<span className="gp-value">{formatDuration(report.duration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Efficacité marais</span>
|
||||||
|
<span className={`gp-value ${effPercent > 50 ? "gp-accent-green" : "gp-accent-amber"}`}>
|
||||||
|
{effPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="gp-sep" />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="gp-label">Têtards récoltés</span>
|
||||||
|
<span className="gp-value gp-accent-green text-lg!">
|
||||||
|
+{formatNumber(report.gains)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.efficiency < 0.5 && (
|
||||||
|
<p className="gp-label text-center">
|
||||||
|
Le marais s'endort sans toi... Joue activement pour maximiser ta production !
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
className="gp-btn gp-btn--buy w-full mt-3 py-2!"
|
||||||
|
>
|
||||||
|
Retour au marais
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,41 +1,33 @@
|
|||||||
// PrestigePanel.tsx — Nouvelle Génération (prestige)
|
// PrestigePanel.tsx — Nouvelle Génération (prestige)
|
||||||
|
|
||||||
import { useGameStore } from "../store/useGameStore";
|
import { useGameStore } from "../store/useGameStore";
|
||||||
import { computePrestigeDna } from "../core/economy";
|
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from "../core/economy";
|
||||||
|
import { formatNumber } from "../utils/formatNumber";
|
||||||
|
|
||||||
export function PrestigePanel() {
|
export function PrestigePanel() {
|
||||||
const { lifetimeTadpoles } = useGameStore((s) => s.state);
|
const state = useGameStore((s) => s.state);
|
||||||
const canPrestige = useGameStore((s) => s.canPrestige);
|
const canPrestige = useGameStore((s) => s.canPrestige);
|
||||||
const prestige = useGameStore((s) => s.prestige);
|
const openPrestigeScreen = useGameStore((s) => s.openPrestigeScreen);
|
||||||
|
|
||||||
const dnaPreview = computePrestigeDna(lifetimeTadpoles);
|
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
|
||||||
|
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
|
||||||
const handlePrestige = () => {
|
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
|
||||||
const confirmed = window.confirm(
|
const threshold = getPrestigeThreshold(state);
|
||||||
`Nouvelle Génération\n\n` +
|
|
||||||
`Reset : têtards et générateurs à zéro.\n` +
|
|
||||||
`Récompense : +${dnaPreview} ADN Ancestral\n` +
|
|
||||||
` +0.1x multiplicateur permanent\n\n` +
|
|
||||||
`L'Arbre d'Évolution persiste.\n\n` +
|
|
||||||
`Confirmer ?`
|
|
||||||
);
|
|
||||||
if (confirmed) prestige();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gp">
|
<div className="gp">
|
||||||
<span className="gp-title" title="Recommence à zéro en échange d'un bonus permanent — tes têtards et générateurs sont réinitialisés mais tu gagnes de l'ADN et un multiplicateur">Prestige</span>
|
<span className="gp-title" title="Recommence à zéro en échange d'un bonus permanent — tes têtards et générateurs sont réinitialisés mais tu gagnes de l'ADN et un multiplicateur">Prestige</span>
|
||||||
{canPrestige ? (
|
{canPrestige ? (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="gp-value gp-accent-purple">
|
<span className="gp-value gp-accent-purple">
|
||||||
+{dnaPreview} ADN · +0.1x mult
|
+{dnaPreview} ADN · +0.1x mult
|
||||||
</span>
|
</span>
|
||||||
<button onClick={handlePrestige} className="gp-btn gp-btn--prestige">
|
<button onClick={openPrestigeScreen} className="gp-btn gp-btn--prestige">
|
||||||
Nouvelle Génération
|
Nouvelle Generation
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="gp-label">Atteins 1M têtards pour prestige</span>
|
<span className="gp-label">Atteins {formatNumber(threshold)} tetards pour prestige</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
182
Frontend/src/components/PrestigeScreen.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// PrestigeScreen.tsx — Écran de prestige fullscreen (Sprint 3)
|
||||||
|
// Preview ADN, stats de run, comparaison meilleure run, confirmation
|
||||||
|
|
||||||
|
import { useGameStore } from "../store/useGameStore";
|
||||||
|
import {
|
||||||
|
computePrestigeDna,
|
||||||
|
getPrestigeDnaBonus,
|
||||||
|
getPrestigeThreshold,
|
||||||
|
} from "../core/economy";
|
||||||
|
import { formatNumber } from "../utils/formatNumber";
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrestigeScreen() {
|
||||||
|
const show = useGameStore((s) => s.showPrestigeScreen);
|
||||||
|
const close = useGameStore((s) => s.closePrestigeScreen);
|
||||||
|
const prestige = useGameStore((s) => s.prestige);
|
||||||
|
const state = useGameStore((s) => s.state);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
|
||||||
|
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
|
||||||
|
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
|
||||||
|
const threshold = getPrestigeThreshold(state);
|
||||||
|
const canPrestige = state.lifetimeTadpoles >= threshold;
|
||||||
|
|
||||||
|
// Run stats
|
||||||
|
const now = Date.now();
|
||||||
|
const runDuration = now - state.runStats.startedAt;
|
||||||
|
const bestRun = state.runStats.bestRun;
|
||||||
|
|
||||||
|
// Comparison with best run
|
||||||
|
const isBestAdn = !bestRun || dnaPreview > bestRun.adn;
|
||||||
|
const isBestTadpoles = !bestRun || state.lifetimeTadpoles > bestRun.tadpoles;
|
||||||
|
|
||||||
|
const handlePrestige = () => {
|
||||||
|
if (canPrestige) prestige();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm">
|
||||||
|
<div className="gp max-w-md w-full mx-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="gp-title text-lg!">Nouvelle Generation</span>
|
||||||
|
<p className="gp-label mt-1">
|
||||||
|
Generation #{state.prestigeCount + 1}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="gp-sep" />
|
||||||
|
|
||||||
|
{/* ADN Preview */}
|
||||||
|
<div className="flex flex-col items-center gap-1 py-2">
|
||||||
|
<span className="gp-label">ADN Ancestral</span>
|
||||||
|
<span className="text-3xl font-extrabold" style={{ color: "#a78bfa", fontFamily: "var(--font)" }}>
|
||||||
|
+{formatNumber(dnaPreview)}
|
||||||
|
</span>
|
||||||
|
{dnaBonus > 0 && (
|
||||||
|
<span className="gp-label">
|
||||||
|
(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="gp-label mt-1">
|
||||||
|
Total apres : {formatNumber(state.ancestralDna + dnaPreview)} ADN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="gp-sep" />
|
||||||
|
|
||||||
|
{/* Run Stats */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="gp-zone-label">Stats de la run</span>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Duree</span>
|
||||||
|
<span className="gp-value">{formatDuration(runDuration)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Tetards produits</span>
|
||||||
|
<span className={`gp-value ${isBestTadpoles ? "gp-accent-green" : ""}`}>
|
||||||
|
{formatNumber(state.lifetimeTadpoles)}
|
||||||
|
{isBestTadpoles && bestRun && " ★"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">ADN cette run</span>
|
||||||
|
<span className={`gp-value ${isBestAdn ? "gp-accent-green" : ""}`}>
|
||||||
|
{formatNumber(dnaPreview)}
|
||||||
|
{isBestAdn && bestRun && " ★"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bestRun && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Vitesse vs meilleure</span>
|
||||||
|
<span className={`gp-value ${
|
||||||
|
runDuration < bestRun.duration ? "gp-accent-green" : "gp-accent-amber"
|
||||||
|
}`}>
|
||||||
|
{runDuration < bestRun.duration
|
||||||
|
? `${Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide`
|
||||||
|
: runDuration > bestRun.duration
|
||||||
|
? `${Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent`
|
||||||
|
: "identique"
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bestRun && (
|
||||||
|
<>
|
||||||
|
<div className="gp-sep" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="gp-zone-label">Meilleure run</span>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">Duree</span>
|
||||||
|
<span className="gp-value">{formatDuration(bestRun.duration)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="gp-label">ADN</span>
|
||||||
|
<span className="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="gp-sep" />
|
||||||
|
|
||||||
|
{/* Reset info */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="gp-label">
|
||||||
|
Tetards et generateurs remis a zero.
|
||||||
|
</p>
|
||||||
|
<p className="gp-label">
|
||||||
|
Arbre d'Evolution et cosmetiques conserves.
|
||||||
|
</p>
|
||||||
|
<p className="gp-label mt-1">
|
||||||
|
+1 reset d'arbre gratuit offert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={close}
|
||||||
|
className="gp-btn flex-1 py-2!"
|
||||||
|
style={{ background: "rgba(255,255,255,0.08)", color: "rgba(255,255,255,0.7)" }}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
{canPrestige ? (
|
||||||
|
<button
|
||||||
|
onClick={handlePrestige}
|
||||||
|
className="gp-btn gp-btn--prestige flex-1 py-2!"
|
||||||
|
>
|
||||||
|
Nouvelle Generation
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="gp-btn gp-btn--disabled flex-1 py-2!"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{formatNumber(threshold - state.lifetimeTadpoles)} tetards manquants
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
Frontend/src/components/TadpoleSprite.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// TadpoleSprite.tsx — Sprite têtard avec overlays cosmétiques équipés
|
||||||
|
|
||||||
|
import { useGameStore } from "../store/useGameStore";
|
||||||
|
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
|
||||||
|
|
||||||
|
const SLOT_ORDER: CosmeticSlot[] = ["body", "tail", "eyes", "hat", "accessory"];
|
||||||
|
|
||||||
|
export function TadpoleSprite() {
|
||||||
|
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
|
||||||
|
|
||||||
|
const overlays = SLOT_ORDER
|
||||||
|
.map((slot) => {
|
||||||
|
const cosId = equipped[slot];
|
||||||
|
if (!cosId) return null;
|
||||||
|
return COSMETICS.find((c) => c.id === cosId);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px]">
|
||||||
|
{/* Base sprite */}
|
||||||
|
<img
|
||||||
|
src="/svg/tadpole.svg"
|
||||||
|
alt="Têtard"
|
||||||
|
className="w-full h-full object-contain transition-transform duration-100"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{/* Cosmetic overlays */}
|
||||||
|
{overlays.map((cos) => (
|
||||||
|
<img
|
||||||
|
key={cos!.id}
|
||||||
|
src={cos!.svg}
|
||||||
|
alt={cos!.name}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { NavLink as Link } from "react-router-dom";
|
import { NavLink as Link } from "react-router-dom";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import "../scss/components/navbar.scss";
|
|
||||||
|
|
||||||
import "../scss/root.scss";
|
|
||||||
import PrimaryButton from "./buttons/PrimaryButton";
|
import PrimaryButton from "./buttons/PrimaryButton";
|
||||||
|
|
||||||
export default function Burger({ navData }) {
|
export default function Burger({ navData }) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import "../../scss/components/buttons.scss";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export default function PrimaryButton({ btnText, btnLink }) {
|
export default function PrimaryButton({ btnText, btnLink }) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import "../../scss/components/buttons.scss";
|
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import "../scss/components/footer.scss";
|
|
||||||
import { NavLink as Link } from "react-router-dom";
|
import { NavLink as Link } from "react-router-dom";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { NavLink as Link } from "react-router-dom";
|
import { NavLink as Link } from "react-router-dom";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import "../scss/components/navbar.scss";
|
|
||||||
import "../scss/root.scss";
|
|
||||||
|
|
||||||
import PrimaryButton from "./buttons/PrimaryButton";
|
import PrimaryButton from "./buttons/PrimaryButton";
|
||||||
import Burger from "./burger";
|
import Burger from "./burger";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|||||||
68
Frontend/src/core/balance.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// balance.ts — Constantes d'équilibrage centralisées
|
||||||
|
// Toutes les valeurs de tuning en un seul fichier pour faciliter le playtest.
|
||||||
|
// Sprint 3 — session brainstorm 2026-03-28
|
||||||
|
|
||||||
|
// --- Formule ADN prestige ---
|
||||||
|
|
||||||
|
export const PRESTIGE_ADN_BASE = 50;
|
||||||
|
export const PRESTIGE_ADN_THRESHOLD = 1e6; // 1M têtards minimum pour prestige
|
||||||
|
export const PRESTIGE_BONUS_PER_PRESTIGE = 0.05; // +5% par prestige
|
||||||
|
export const PRESTIGE_BONUS_CAP = 3.0; // cap à ×4 total (80 prestiges)
|
||||||
|
export const PRESTIGE_ADN_MIN = 1; // clamp : jamais 0 ADN si seuil atteint
|
||||||
|
|
||||||
|
// --- Seuil prestige ---
|
||||||
|
|
||||||
|
export const BASE_PRESTIGE_THRESHOLD = 1_000_000; // 1M têtards
|
||||||
|
|
||||||
|
// --- Post-capstone scaling par tranche ---
|
||||||
|
|
||||||
|
export const POST_CAPSTONE_TIERS = [
|
||||||
|
{ maxPurchases: 5, multiplier: 1.5 }, // achats 1-5
|
||||||
|
{ maxPurchases: 10, multiplier: 1.8 }, // achats 6-10
|
||||||
|
{ maxPurchases: Infinity, multiplier: 2.0 }, // achats 11+
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le coût du N-ième achat post-capstone repeatable (0-indexed).
|
||||||
|
* Scaling par tranche : ×1.5 (achats 0-4), ×1.8 (5-9), ×2.0 (10+)
|
||||||
|
*/
|
||||||
|
export function postCapstoneCost(baseCost: number, purchased: number): number {
|
||||||
|
let cost = baseCost;
|
||||||
|
for (let i = 0; i < purchased; i++) {
|
||||||
|
if (i < 5) cost *= 1.5;
|
||||||
|
else if (i < 10) cost *= 1.8;
|
||||||
|
else cost *= 2.0;
|
||||||
|
}
|
||||||
|
return Math.floor(cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reset arbre ---
|
||||||
|
|
||||||
|
export const TREE_RESET_FREE_PER_PRESTIGE = 1; // 1 gratuit par prestige
|
||||||
|
export const TREE_RESET_EXTRA_COST = 5; // 5 ADN × n pour les resets supplémentaires
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coût du prochain reset arbre.
|
||||||
|
* 1 gratuit par prestige, puis linéaire (5 × n) au-delà.
|
||||||
|
*/
|
||||||
|
export function treeResetCost(freeResetAvailable: boolean, extraResetsUsed: number): number {
|
||||||
|
if (freeResetAvailable) return 0;
|
||||||
|
return TREE_RESET_EXTRA_COST * (extraResetsUsed + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Offline ---
|
||||||
|
|
||||||
|
export const OFFLINE_THRESHOLD_MS = 60_000; // 60s
|
||||||
|
export const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100%
|
||||||
|
export const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25%
|
||||||
|
export const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0%
|
||||||
|
export const OFFLINE_FLOOR = 0.25; // plancher decay
|
||||||
|
|
||||||
|
// --- Anti-cheat ---
|
||||||
|
|
||||||
|
export const MAX_PRODUCTION_PER_SECOND = 750_000;
|
||||||
|
export const CHEAT_MARGIN = 1.1;
|
||||||
|
|
||||||
|
// --- Save version ---
|
||||||
|
|
||||||
|
export const CURRENT_SAVE_VERSION = 2;
|
||||||
97
Frontend/src/core/cosmetics.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// cosmetics.ts — Système cosmétique (récompenses achievements + prestige)
|
||||||
|
|
||||||
|
import type { GameState } from "./economy";
|
||||||
|
import { ACHIEVEMENTS } from "../data/achievements";
|
||||||
|
|
||||||
|
export type CosmeticSlot = "hat" | "eyes" | "body" | "tail" | "accessory";
|
||||||
|
|
||||||
|
export interface Cosmetic {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slot: CosmeticSlot;
|
||||||
|
svg: string; // chemin vers le SVG overlay (/svg/cosmetics/...)
|
||||||
|
source: "achievement" | "prestige";
|
||||||
|
sourceId: string; // achievement id ou "prestige_N"
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CosmeticState {
|
||||||
|
inventory: string[]; // ids des cosmétiques débloqués
|
||||||
|
equipped: Partial<Record<CosmeticSlot, string>>; // slot → cosmetic id
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_COSMETIC_STATE: CosmeticState = {
|
||||||
|
inventory: [],
|
||||||
|
equipped: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Catalogue des cosmétiques ---
|
||||||
|
|
||||||
|
export const COSMETICS: Cosmetic[] = [
|
||||||
|
// Hat
|
||||||
|
{ id: "crown", name: "Couronne Ancestrale", slot: "hat", svg: "/svg/cosmetics/crown.svg", source: "prestige", sourceId: "prestige_10", description: "10 prestiges — la royauté du marais" },
|
||||||
|
{ id: "cap_swamp", name: "Casquette du Marais", slot: "hat", svg: "/svg/cosmetics/cap-swamp.svg", source: "achievement", sourceId: "industriel", description: "10 générateurs au total" },
|
||||||
|
|
||||||
|
// Eyes
|
||||||
|
{ id: "glasses_savant", name: "Lunettes du Savant", slot: "eyes", svg: "/svg/cosmetics/glasses-savant.svg", source: "prestige", sourceId: "prestige_5", description: "5 prestiges — la sagesse" },
|
||||||
|
{ id: "mask_frog", name: "Masque Grenouille", slot: "eyes", svg: "/svg/cosmetics/mask-frog.svg", source: "achievement", sourceId: "empire", description: "1M têtards — le regard de l'empire" },
|
||||||
|
|
||||||
|
// Body
|
||||||
|
{ id: "cape_algae", name: "Cape d'Algues", slot: "body", svg: "/svg/cosmetics/cape-algae.svg", source: "prestige", sourceId: "prestige_25", description: "25 prestiges — tissée par le marais" },
|
||||||
|
{ id: "armor_scales", name: "Armure d'Écailles", slot: "body", svg: "/svg/cosmetics/armor-scales.svg", source: "achievement", sourceId: "tycoon", description: "100 générateurs — blindage total" },
|
||||||
|
|
||||||
|
// Tail
|
||||||
|
{ id: "flame_tail", name: "Queue Enflammée", slot: "tail", svg: "/svg/cosmetics/flame-tail.svg", source: "prestige", sourceId: "prestige_50", description: "50 prestiges — la traîne de feu" },
|
||||||
|
{ id: "ribbon", name: "Ruban du Nouveau-Né", slot: "tail", svg: "/svg/cosmetics/ribbon.svg", source: "achievement", sourceId: "first_prestige", description: "Premier prestige — le début de tout" },
|
||||||
|
|
||||||
|
// Accessory
|
||||||
|
{ id: "aura_swamp", name: "Aura du Marais", slot: "accessory", svg: "/svg/aura-swamp.svg", source: "achievement", sourceId: "veteran", description: "5 prestiges — l'aura des anciens" },
|
||||||
|
{ id: "particles_gold", name: "Particules Dorées", slot: "accessory", svg: "/svg/cosmetics/particles-gold.svg", source: "prestige", sourceId: "prestige_3", description: "3 prestiges — poussière d'étoiles" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Fonctions cosmétiques ---
|
||||||
|
|
||||||
|
// Vérifie si un cosmétique devrait être débloqué
|
||||||
|
export function shouldUnlockCosmetic(cosmetic: Cosmetic, state: GameState): boolean {
|
||||||
|
if (cosmetic.source === "prestige") {
|
||||||
|
const tier = parseInt(cosmetic.sourceId.replace("prestige_", ""), 10);
|
||||||
|
return state.prestigeCount >= tier;
|
||||||
|
}
|
||||||
|
if (cosmetic.source === "achievement") {
|
||||||
|
const achievement = ACHIEVEMENTS.find((a) => a.id === cosmetic.sourceId);
|
||||||
|
return achievement ? achievement.check(state) : false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcule les cosmétiques nouvellement débloqués (pas encore dans l'inventaire)
|
||||||
|
export function computeNewUnlocks(state: GameState, cosmeticState: CosmeticState): string[] {
|
||||||
|
return COSMETICS
|
||||||
|
.filter((c) => !cosmeticState.inventory.includes(c.id) && shouldUnlockCosmetic(c, state))
|
||||||
|
.map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équiper un cosmétique (retourne le nouvel état)
|
||||||
|
export function equipCosmetic(cosmeticState: CosmeticState, cosmeticId: string): CosmeticState {
|
||||||
|
const cosmetic = COSMETICS.find((c) => c.id === cosmeticId);
|
||||||
|
if (!cosmetic) return cosmeticState;
|
||||||
|
if (!cosmeticState.inventory.includes(cosmeticId)) return cosmeticState;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cosmeticState,
|
||||||
|
equipped: { ...cosmeticState.equipped, [cosmetic.slot]: cosmeticId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déséquiper un slot
|
||||||
|
export function unequipSlot(cosmeticState: CosmeticState, slot: CosmeticSlot): CosmeticState {
|
||||||
|
const { [slot]: _, ...rest } = cosmeticState.equipped;
|
||||||
|
return { ...cosmeticState, equipped: rest };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter des cosmétiques à l'inventaire
|
||||||
|
export function addToInventory(cosmeticState: CosmeticState, ids: string[]): CosmeticState {
|
||||||
|
const newIds = ids.filter((id) => !cosmeticState.inventory.includes(id));
|
||||||
|
if (newIds.length === 0) return cosmeticState;
|
||||||
|
return { ...cosmeticState, inventory: [...cosmeticState.inventory, ...newIds] };
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
// economy.ts — Core clicker logic (lazy calculation pattern)
|
// economy.ts — Core clicker logic (lazy calculation pattern)
|
||||||
// Jamais de timer actif : tout est calculé au read depuis lastTick
|
// Jamais de timer actif : tout est calculé au read depuis lastTick
|
||||||
|
|
||||||
|
import {
|
||||||
|
PRESTIGE_ADN_BASE,
|
||||||
|
PRESTIGE_ADN_THRESHOLD,
|
||||||
|
PRESTIGE_BONUS_PER_PRESTIGE,
|
||||||
|
PRESTIGE_BONUS_CAP,
|
||||||
|
PRESTIGE_ADN_MIN,
|
||||||
|
BASE_PRESTIGE_THRESHOLD,
|
||||||
|
OFFLINE_THRESHOLD_MS as OFFLINE_THRESHOLD,
|
||||||
|
OFFLINE_FULL_MS,
|
||||||
|
OFFLINE_DECAY_END_MS,
|
||||||
|
OFFLINE_ZERO_MS,
|
||||||
|
OFFLINE_FLOOR,
|
||||||
|
CURRENT_SAVE_VERSION,
|
||||||
|
treeResetCost,
|
||||||
|
postCapstoneCost,
|
||||||
|
} from "./balance";
|
||||||
|
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
|
||||||
|
import type { PrestigeMilestone } from "../data/prestigeMilestones";
|
||||||
|
|
||||||
export interface Generator {
|
export interface Generator {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,54 +28,295 @@ export interface Generator {
|
|||||||
owned: number;
|
owned: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EffectType = "click_multiplier" | "production_multiplier" | "start_bonus" | "unlock_generator" | "achievement_scaling";
|
export type EffectType =
|
||||||
|
| "click_multiplier"
|
||||||
|
| "production_multiplier"
|
||||||
|
| "start_bonus"
|
||||||
|
| "unlock_generator"
|
||||||
|
| "double_click_chance"
|
||||||
|
| "auto_click"
|
||||||
|
| "crit_click_chance"
|
||||||
|
| "generator_boost"
|
||||||
|
| "cost_reduction"
|
||||||
|
| "prestige_dna_bonus"
|
||||||
|
| "offline_boost"
|
||||||
|
| "prestige_threshold_reduction"
|
||||||
|
// Sprint 3 — capstones
|
||||||
|
| "auto_click_scaling" // Ponte Auto — auto-click scale avec upgrades
|
||||||
|
| "generator_synergy" // Symbiose Totale — +X% par type possédé
|
||||||
|
| "offline_cap_boost" // Mémoire du Marais — offline cap + durée
|
||||||
|
// Sprint 3 — Convergence
|
||||||
|
| "all_effects_boost" // +X% à tous les effets
|
||||||
|
| "post_capstone_discount"; // -X% coût post-capstones
|
||||||
|
|
||||||
|
export type Branch = "ponte" | "marais" | "adaptation" | "cross";
|
||||||
|
|
||||||
export interface EvolutionNode {
|
export interface EvolutionNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
cost: number; // en ADN Ancestral
|
cost: number; // en ADN Ancestral (base cost for repeatables)
|
||||||
effect: EffectType;
|
effect: EffectType;
|
||||||
value: number;
|
value: number;
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
requires: string | null; // id du nœud prérequis (null = racine)
|
requires: string | null; // id du nœud prérequis (null = racine)
|
||||||
|
branch: Branch;
|
||||||
|
exclusive_with?: string; // id du nœud alternatif (pick one)
|
||||||
|
// Sprint 3 — capstone & repeatable
|
||||||
|
capstone?: boolean; // nœud capstone (bordure dorée, game-changer)
|
||||||
|
repeatable?: boolean; // post-capstone achetable en boucle
|
||||||
|
purchased?: number; // nombre d'achats pour les repeatables
|
||||||
|
// Sprint 3 — Convergence (nœud évolutif)
|
||||||
|
tier?: number; // tier actuel (1 = Alpha, 2 = Omega)
|
||||||
|
maxTier?: number; // tier max
|
||||||
|
tierUpgradeCost?: number; // coût upgrade au tier suivant
|
||||||
|
tierUpgradeRequires?: string; // condition pour upgrade ("2_capstones")
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CosmeticSlotMap {
|
||||||
|
[slot: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunStats {
|
||||||
|
startedAt: number; // timestamp ms début de la run
|
||||||
|
tadpolesProduced: number; // têtards produits cette run (tracking granulaire)
|
||||||
|
bestRun: {
|
||||||
|
duration: number; // ms
|
||||||
|
tadpoles: number;
|
||||||
|
adn: number;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
|
saveVersion: number;
|
||||||
resources: number;
|
resources: number;
|
||||||
clickMultiplier: number;
|
clickMultiplier: number;
|
||||||
generators: Generator[];
|
generators: Generator[];
|
||||||
lastTick: number; // timestamp ms — lazy calc reference
|
lastTick: number; // timestamp ms — lazy calc reference
|
||||||
|
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
|
||||||
prestigeCount: number;
|
prestigeCount: number;
|
||||||
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
|
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
|
||||||
ancestralDna: number;
|
ancestralDna: number;
|
||||||
evolutionTree: EvolutionNode[];
|
evolutionTree: EvolutionNode[];
|
||||||
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
|
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
|
||||||
|
cosmeticInventory: string[]; // ids des cosmétiques débloqués
|
||||||
|
cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id
|
||||||
|
// Sprint 3
|
||||||
|
runStats: RunStats;
|
||||||
|
freeResetAvailable: boolean; // 1 gratuit par prestige
|
||||||
|
extraResetsUsed: number; // resets payants dans la génération courante
|
||||||
|
claimedMilestones: string[]; // IDs des milestones réclamés
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Arbre d'Évolution ---
|
// --- Arbre d'Évolution ---
|
||||||
|
|
||||||
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
|
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
|
||||||
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null },
|
// ═══ PONTE (click) — 10 nœuds ═══
|
||||||
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: "ponte_amelioree" },
|
|
||||||
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 10, effect: "start_bonus", value: 100, unlocked: false, requires: "instinct_gregaire" },
|
// Tier 1
|
||||||
{ id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" },
|
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" },
|
||||||
{ id: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" },
|
// Tier 2
|
||||||
|
{ id: "double_ponte", name: "Double Ponte", cost: 5, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
|
||||||
|
// Tier 3 (exclusif)
|
||||||
|
{ id: "ponte_frenetique", name: "Frénésie", cost: 15, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "concentration" },
|
||||||
|
{ id: "concentration", name: "Concentration", cost: 15, effect: "click_multiplier", value: 4, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" },
|
||||||
|
// Tier 3 (parallèle)
|
||||||
|
{ id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" },
|
||||||
|
// Tier 4
|
||||||
|
{ id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" },
|
||||||
|
// Capstone
|
||||||
|
{ id: "ponte_auto", name: "Ponte Automatique", cost: 200, effect: "auto_click_scaling", value: 1, unlocked: false, requires: "maitre_pondeur", branch: "ponte", capstone: true },
|
||||||
|
// Post-capstone (repeatable)
|
||||||
|
{ id: "ponte_post", name: "+5% auto-ponte", cost: 500, effect: "auto_click", value: 0.05, unlocked: false, requires: "ponte_auto", branch: "ponte", repeatable: true, purchased: 0 },
|
||||||
|
|
||||||
|
// ═══ MARAIS (production) — 10 nœuds ═══
|
||||||
|
|
||||||
|
// Tier 1
|
||||||
|
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" },
|
||||||
|
// Tier 2
|
||||||
|
{ id: "symbiose_algale", name: "Symbiose Algale", cost: 8, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" },
|
||||||
|
// Tier 3 (exclusif)
|
||||||
|
{ id: "courant_profond", name: "Courant Profond", cost: 25, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" },
|
||||||
|
{ id: "maree_haute", name: "Marée Haute", cost: 25, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" },
|
||||||
|
// Tier 3 (parallèle)
|
||||||
|
{ id: "ecosysteme_mature", name: "Écosystème Mature", cost: 25, effect: "production_multiplier", value: 3, unlocked: false, requires: "symbiose_algale", branch: "marais" },
|
||||||
|
// Tier 4
|
||||||
|
{ id: "marais_eternel", name: "Marais Éternel", cost: 60, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" },
|
||||||
|
// Capstone
|
||||||
|
{ id: "symbiose_totale", name: "Symbiose Totale", cost: 300, effect: "generator_synergy", value: 0.02, unlocked: false, requires: "marais_eternel", branch: "marais", capstone: true },
|
||||||
|
// Post-capstone (repeatable)
|
||||||
|
{ id: "marais_post", name: "+1% synergie", cost: 600, effect: "generator_synergy", value: 0.01, unlocked: false, requires: "symbiose_totale", branch: "marais", repeatable: true, purchased: 0 },
|
||||||
|
|
||||||
|
// ═══ ADAPTATION (utility) — 10 nœuds ═══
|
||||||
|
|
||||||
|
// Tier 1
|
||||||
|
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 2, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" },
|
||||||
|
// Tier 2
|
||||||
|
{ id: "adn_renforce", name: "ADN Renforcé", cost: 10, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" },
|
||||||
|
// Tier 3 (exclusif)
|
||||||
|
{ id: "eveil_rapide", name: "Éveil Rapide", cost: 30, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "mutation_adn" },
|
||||||
|
{ id: "mutation_adn", name: "Mutation ADN", cost: 30, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" },
|
||||||
|
// Tier 3 (parallèle)
|
||||||
|
{ id: "heritage", name: "Héritage", cost: 30, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation" },
|
||||||
|
// Tier 4
|
||||||
|
{ id: "transcendance", name: "Transcendance", cost: 60, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" },
|
||||||
|
// Capstone
|
||||||
|
{ id: "memoire_marais", name: "Mémoire du Marais", cost: 250, effect: "offline_cap_boost", value: 0.75, unlocked: false, requires: "transcendance", branch: "adaptation", capstone: true },
|
||||||
|
// Post-capstone (repeatable)
|
||||||
|
{ id: "adapt_post", name: "+2% offline cap", cost: 500, effect: "offline_boost", value: 0.02, unlocked: false, requires: "memoire_marais", branch: "adaptation", repeatable: true, purchased: 0 },
|
||||||
|
|
||||||
|
// ═══ CROSS-BRANCHE — Convergence (nœud évolutif) ═══
|
||||||
|
|
||||||
|
{ id: "convergence", name: "Convergence", cost: 500, effect: "all_effects_boost", value: 0.10, unlocked: false, requires: null, branch: "cross",
|
||||||
|
tier: 1, maxTier: 2, tierUpgradeCost: 500, tierUpgradeRequires: "2_capstones" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9))
|
// Formule ADN Sprint 3 : max(1, floor(base × log10(t / threshold) × (1 + bonus)))
|
||||||
export function computePrestigeDna(lifetimeTadpoles: number): number {
|
// Clamp min 1 si seuil atteint, cap bonus ×4 à 80 prestiges
|
||||||
return Math.floor(150 * Math.sqrt(lifetimeTadpoles / 1e9));
|
|
||||||
|
export function computePrestigeDna(lifetimeTadpoles: number, prestigeCount: number = 0): number {
|
||||||
|
if (lifetimeTadpoles < PRESTIGE_ADN_THRESHOLD) return 0;
|
||||||
|
const ratio = lifetimeTadpoles / PRESTIGE_ADN_THRESHOLD;
|
||||||
|
if (ratio <= 1) return PRESTIGE_ADN_MIN;
|
||||||
|
const bonus = Math.min(PRESTIGE_BONUS_PER_PRESTIGE * prestigeCount, PRESTIGE_BONUS_CAP);
|
||||||
|
const raw = PRESTIGE_ADN_BASE * Math.log10(ratio) * (1 + bonus);
|
||||||
|
return Math.max(PRESTIGE_ADN_MIN, Math.floor(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Milestones prestige ---
|
||||||
|
|
||||||
|
// Milestones disponibles mais pas encore réclamés
|
||||||
|
export function getClaimableMilestones(state: GameState): PrestigeMilestone[] {
|
||||||
|
return PRESTIGE_MILESTONES.filter(
|
||||||
|
(m) => state.prestigeCount >= m.threshold && !state.claimedMilestones.includes(m.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prochain milestone non atteint
|
||||||
|
export function getNextMilestone(state: GameState): PrestigeMilestone | null {
|
||||||
|
return PRESTIGE_MILESTONES.find((m) => state.prestigeCount < m.threshold) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réclamer un milestone
|
||||||
|
export function claimMilestone(state: GameState, milestoneId: string): GameState | null {
|
||||||
|
const milestone = PRESTIGE_MILESTONES.find((m) => m.id === milestoneId);
|
||||||
|
if (!milestone) return null;
|
||||||
|
if (state.prestigeCount < milestone.threshold) return null;
|
||||||
|
if (state.claimedMilestones.includes(milestoneId)) return null;
|
||||||
|
|
||||||
|
let newState = {
|
||||||
|
...state,
|
||||||
|
claimedMilestones: [...state.claimedMilestones, milestoneId],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appliquer la récompense
|
||||||
|
if (milestone.reward.type === "cosmetic") {
|
||||||
|
if (!newState.cosmeticInventory.includes(milestone.reward.cosmeticId)) {
|
||||||
|
newState = {
|
||||||
|
...newState,
|
||||||
|
cosmeticInventory: [...newState.cosmeticInventory, milestone.reward.cosmeticId],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Les bonus gameplay sont appliqués passivement via getMilestoneBonus()
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus gameplay cumulés depuis les milestones réclamés
|
||||||
|
export function getMilestoneStartNid(state: GameState): number {
|
||||||
|
const claimed = state.claimedMilestones;
|
||||||
|
if (claimed.includes("milestone_5")) return 1; // 1 Nid gratuit
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMilestoneOfflineBonus(state: GameState): number {
|
||||||
|
const claimed = state.claimedMilestones;
|
||||||
|
if (claimed.includes("milestone_15")) return 0.05; // +5% offline cap
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compte les capstones débloqués
|
||||||
|
export function getUnlockedCapstoneCount(tree: EvolutionNode[]): number {
|
||||||
|
return tree.filter((n) => n.capstone && n.unlocked).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coût actuel d'un nœud repeatable (scaling par tranche via balance.ts)
|
||||||
|
export function getRepeatableCost(node: EvolutionNode): number {
|
||||||
|
if (!node.repeatable) return node.cost;
|
||||||
|
return postCapstoneCost(node.cost, node.purchased ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie si le joueur peut acheter Convergence (condition spéciale)
|
||||||
|
function canBuyConvergence(state: GameState, node: EvolutionNode): boolean {
|
||||||
|
// Tier 1 : 1 capstone + au moins 1 nœud tier 3 d'une 2e branche
|
||||||
|
if (!node.unlocked && (node.tier ?? 1) === 1) {
|
||||||
|
const capstones = getUnlockedCapstoneCount(state.evolutionTree);
|
||||||
|
if (capstones < 1) return false;
|
||||||
|
// Check: au moins 1 nœud dans une branche différente de la capstone
|
||||||
|
const capstoneBranches = new Set(
|
||||||
|
state.evolutionTree.filter((n) => n.capstone && n.unlocked).map((n) => n.branch)
|
||||||
|
);
|
||||||
|
const otherBranchNodes = state.evolutionTree.filter(
|
||||||
|
(n) => n.unlocked && !capstoneBranches.has(n.branch) && n.branch !== "cross" && n.cost >= 15
|
||||||
|
);
|
||||||
|
return otherBranchNodes.length > 0 && state.ancestralDna >= node.cost;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie si Convergence peut être upgradé au tier suivant
|
||||||
|
export function canUpgradeConvergence(state: GameState): boolean {
|
||||||
|
const conv = state.evolutionTree.find((n) => n.id === "convergence");
|
||||||
|
if (!conv || !conv.unlocked) return false;
|
||||||
|
if ((conv.tier ?? 1) >= (conv.maxTier ?? 2)) return false;
|
||||||
|
if (conv.tierUpgradeRequires === "2_capstones" && getUnlockedCapstoneCount(state.evolutionTree) < 2) return false;
|
||||||
|
return state.ancestralDna >= (conv.tierUpgradeCost ?? 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade Convergence au tier suivant
|
||||||
|
export function upgradeConvergence(state: GameState): GameState | null {
|
||||||
|
if (!canUpgradeConvergence(state)) return null;
|
||||||
|
const conv = state.evolutionTree.find((n) => n.id === "convergence")!;
|
||||||
|
const cost = conv.tierUpgradeCost ?? 500;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ancestralDna: state.ancestralDna - cost,
|
||||||
|
evolutionTree: state.evolutionTree.map((n) =>
|
||||||
|
n.id === "convergence"
|
||||||
|
? { ...n, tier: (n.tier ?? 1) + 1, effect: "post_capstone_discount" as EffectType, value: 0.20 }
|
||||||
|
: n
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifie si un nœud peut être acheté
|
// Vérifie si un nœud peut être acheté
|
||||||
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||||
const node = state.evolutionTree.find((n) => n.id === nodeId);
|
const node = state.evolutionTree.find((n) => n.id === nodeId);
|
||||||
if (!node || node.unlocked) return false;
|
if (!node) return false;
|
||||||
if (state.ancestralDna < node.cost) return false;
|
|
||||||
|
// Convergence a sa propre logique
|
||||||
|
if (node.id === "convergence") return canBuyConvergence(state, node);
|
||||||
|
|
||||||
|
// Repeatable : toujours achetable si unlocked + prérequis + assez d'ADN
|
||||||
|
if (node.repeatable && node.unlocked) {
|
||||||
|
const cost = getRepeatableCost(node);
|
||||||
|
return state.ancestralDna >= cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.unlocked) return false;
|
||||||
|
|
||||||
|
const cost = node.repeatable ? getRepeatableCost(node) : node.cost;
|
||||||
|
if (state.ancestralDna < cost) return false;
|
||||||
|
|
||||||
if (node.requires) {
|
if (node.requires) {
|
||||||
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
||||||
if (!prereq || !prereq.unlocked) return false;
|
if (!prereq || !prereq.unlocked) return false;
|
||||||
}
|
}
|
||||||
|
// Exclusive node: can't buy if the alternative is already unlocked
|
||||||
|
if (node.exclusive_with) {
|
||||||
|
const alt = state.evolutionTree.find((n) => n.id === node.exclusive_with);
|
||||||
|
if (alt && alt.unlocked) return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +325,82 @@ export function buyEvolutionNode(state: GameState, nodeId: string): GameState |
|
|||||||
if (!canBuyEvolutionNode(state, nodeId)) return null;
|
if (!canBuyEvolutionNode(state, nodeId)) return null;
|
||||||
|
|
||||||
const node = state.evolutionTree.find((n) => n.id === nodeId)!;
|
const node = state.evolutionTree.find((n) => n.id === nodeId)!;
|
||||||
|
|
||||||
|
// Repeatable node — already unlocked, increment purchased
|
||||||
|
if (node.repeatable && node.unlocked) {
|
||||||
|
const cost = getRepeatableCost(node);
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
ancestralDna: state.ancestralDna - node.cost,
|
ancestralDna: state.ancestralDna - cost,
|
||||||
|
evolutionTree: state.evolutionTree.map((n) =>
|
||||||
|
n.id === nodeId ? { ...n, purchased: (n.purchased ?? 0) + 1 } : n
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cost = node.repeatable ? getRepeatableCost(node) : node.cost;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ancestralDna: state.ancestralDna - cost,
|
||||||
evolutionTree: state.evolutionTree.map((n) =>
|
evolutionTree: state.evolutionTree.map((n) =>
|
||||||
n.id === nodeId ? { ...n, unlocked: true } : n
|
n.id === nodeId ? { ...n, unlocked: true } : n
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coût du prochain reset arbre (pour affichage UI)
|
||||||
|
export function getTreeResetCost(state: GameState): number {
|
||||||
|
return treeResetCost(state.freeResetAvailable, state.extraResetsUsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie si le joueur peut reset l'arbre
|
||||||
|
export function canResetTree(state: GameState): boolean {
|
||||||
|
if (state.prestigeCount < 1) return false;
|
||||||
|
const cost = getTreeResetCost(state);
|
||||||
|
return state.ancestralDna >= cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset l'arbre — rembourse l'ADN dépensé (y compris repeatables), déduit le coût du reset
|
||||||
|
export function resetEvolutionTree(state: GameState): GameState {
|
||||||
|
const cost = getTreeResetCost(state);
|
||||||
|
if (state.ancestralDna < cost) return state;
|
||||||
|
|
||||||
|
const spentDna = getSpentDna(state.evolutionTree);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ancestralDna: state.ancestralDna + spentDna - cost,
|
||||||
|
evolutionTree: state.evolutionTree.map((n) => ({
|
||||||
|
...n,
|
||||||
|
unlocked: false,
|
||||||
|
purchased: n.repeatable ? 0 : n.purchased,
|
||||||
|
tier: n.maxTier ? 1 : n.tier,
|
||||||
|
})),
|
||||||
|
freeResetAvailable: state.freeResetAvailable ? false : state.freeResetAvailable,
|
||||||
|
extraResetsUsed: state.freeResetAvailable ? state.extraResetsUsed : state.extraResetsUsed + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compte l'ADN total investi dans l'arbre (standard + repeatables + convergence upgrades)
|
||||||
|
export function getSpentDna(tree: EvolutionNode[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const n of tree) {
|
||||||
|
if (!n.unlocked) continue;
|
||||||
|
total += n.cost; // coût initial
|
||||||
|
// Repeatables : somme des coûts de chaque achat
|
||||||
|
if (n.repeatable && (n.purchased ?? 0) > 0) {
|
||||||
|
for (let i = 0; i < n.purchased!; i++) {
|
||||||
|
total += postCapstoneCost(n.cost, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Convergence tier upgrades
|
||||||
|
if (n.maxTier && (n.tier ?? 1) > 1) {
|
||||||
|
total += (n.tierUpgradeCost ?? 0) * ((n.tier ?? 1) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
// Calcule le multiplicateur click total depuis l'arbre
|
// Calcule le multiplicateur click total depuis l'arbre
|
||||||
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||||
return tree
|
return tree
|
||||||
@@ -95,21 +422,171 @@ export function getStartBonusFromTree(tree: EvolutionNode[]): number {
|
|||||||
.reduce((sum, n) => sum + n.value, 0);
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chance de double click (0-1)
|
||||||
|
export function getDoubleClickChance(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "double_click_chance")
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-clicks par seconde depuis l'arbre (standard + capstone scaling)
|
||||||
|
export function getAutoClicksPerSecond(tree: EvolutionNode[]): number {
|
||||||
|
const standard = tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "auto_click" && !n.repeatable)
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
const scaling = getAutoClickScaling(tree);
|
||||||
|
return standard + scaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chance de crit click (0-1), crit = x10
|
||||||
|
export function getCritClickChance(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "crit_click_chance")
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiplicateur boost sur Nid (generator_boost)
|
||||||
|
export function getGeneratorBoostFromTree(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "generator_boost")
|
||||||
|
.reduce((mult, n) => mult * n.value, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réduction de coût générateurs (0-1)
|
||||||
|
export function getCostReduction(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "cost_reduction")
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus ADN prestige (additif, ex: 0.25 = +25%)
|
||||||
|
export function getPrestigeDnaBonus(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "prestige_dna_bonus")
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boost offline (additif, ex: 0.50 = +50% efficacité offline)
|
||||||
|
export function getOfflineBoost(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "offline_boost")
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réduction seuil prestige (multiplicatif, ex: 0.50 = seuil divisé par 2)
|
||||||
|
export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number {
|
||||||
|
return tree
|
||||||
|
.filter((n) => n.unlocked && n.effect === "prestige_threshold_reduction")
|
||||||
|
.reduce((sum, n) => sum + n.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sprint 3 — Nouveaux effets ---
|
||||||
|
|
||||||
|
// Ponte Automatique (capstone) : 1 auto-click/s de base, scale avec les repeatables
|
||||||
|
export function getAutoClickScaling(tree: EvolutionNode[]): number {
|
||||||
|
const capstone = tree.find((n) => n.id === "ponte_auto" && n.unlocked);
|
||||||
|
if (!capstone) return 0;
|
||||||
|
const baseAutoClick = capstone.value; // 1/s
|
||||||
|
// Post-capstone adds flat auto-click value per purchase
|
||||||
|
const postNode = tree.find((n) => n.id === "ponte_post" && n.unlocked);
|
||||||
|
const postBonus = postNode ? postNode.value * (postNode.purchased ?? 0) : 0;
|
||||||
|
return baseAutoClick + postBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbiose Totale (capstone) : +X% par type de générateur possédé
|
||||||
|
// Retourne le multiplicateur (ex: 5 types × 0.02 = 0.10 → ×1.10)
|
||||||
|
export function getGeneratorSynergyMultiplier(tree: EvolutionNode[], generators: Generator[]): number {
|
||||||
|
const synergyNodes = tree.filter((n) => n.unlocked && n.effect === "generator_synergy");
|
||||||
|
if (synergyNodes.length === 0) return 1;
|
||||||
|
const totalSynergyRate = synergyNodes.reduce((sum, n) => {
|
||||||
|
// For repeatables, each purchase adds to the rate
|
||||||
|
const extra = n.repeatable ? n.value * (n.purchased ?? 0) : 0;
|
||||||
|
return sum + n.value + extra;
|
||||||
|
}, 0);
|
||||||
|
const typesOwned = generators.filter((g) => g.owned > 0).length;
|
||||||
|
return 1 + totalSynergyRate * typesOwned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convergence : all_effects_boost — multiplicateur global sur tous les effets de l'arbre
|
||||||
|
export function getAllEffectsBoost(tree: EvolutionNode[]): number {
|
||||||
|
const conv = tree.find((n) => n.id === "convergence" && n.unlocked);
|
||||||
|
if (!conv) return 1;
|
||||||
|
return 1 + conv.value; // 0.10 = ×1.10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convergence Omega : post_capstone_discount
|
||||||
|
export function getPostCapstoneDiscount(tree: EvolutionNode[]): number {
|
||||||
|
const conv = tree.find((n) => n.id === "convergence" && n.unlocked && n.effect === "post_capstone_discount");
|
||||||
|
if (!conv) return 0;
|
||||||
|
return conv.value; // 0.20 = -20%
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Offline gains (courbe inversée) ---
|
||||||
|
|
||||||
|
// Retourne le multiplicateur d'efficacité offline (1.0 → 0.0)
|
||||||
|
// basé sur le temps d'absence en ms
|
||||||
|
export function offlineEfficiency(elapsedMs: number): number {
|
||||||
|
if (elapsedMs <= OFFLINE_THRESHOLD) return 1; // pas offline
|
||||||
|
if (elapsedMs <= OFFLINE_FULL_MS) return 1; // 0-15min : 100%
|
||||||
|
if (elapsedMs <= OFFLINE_DECAY_END_MS) {
|
||||||
|
// 15min-1h : linéaire 1.0 → 0.25
|
||||||
|
const t = (elapsedMs - OFFLINE_FULL_MS) / (OFFLINE_DECAY_END_MS - OFFLINE_FULL_MS);
|
||||||
|
return 1 - t * (1 - OFFLINE_FLOOR);
|
||||||
|
}
|
||||||
|
if (elapsedMs <= OFFLINE_ZERO_MS) {
|
||||||
|
// 1h-2h : linéaire 0.25 → 0.0
|
||||||
|
const t = (elapsedMs - OFFLINE_DECAY_END_MS) / (OFFLINE_ZERO_MS - OFFLINE_DECAY_END_MS);
|
||||||
|
return OFFLINE_FLOOR * (1 - t);
|
||||||
|
}
|
||||||
|
return 0; // >2h : rien
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcule les gains offline avec la courbe dégressive
|
||||||
|
// Intègre la courbe par tranches de 1 minute pour plus de précision
|
||||||
|
export function computeOfflineGains(state: GameState, now: number): number {
|
||||||
|
const elapsed = now - state.lastTick;
|
||||||
|
if (elapsed <= OFFLINE_THRESHOLD) return computeIdleGains(state, now);
|
||||||
|
|
||||||
|
const pps = totalProductionPerSecond(state);
|
||||||
|
if (pps <= 0) return 0;
|
||||||
|
|
||||||
|
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree) + getMilestoneOfflineBonus(state);
|
||||||
|
|
||||||
|
// Intégration par tranches de 60s
|
||||||
|
const STEP = 60_000;
|
||||||
|
let total = 0;
|
||||||
|
for (let t = 0; t < elapsed; t += STEP) {
|
||||||
|
const chunk = Math.min(STEP, elapsed - t);
|
||||||
|
const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche
|
||||||
|
total += pps * (chunk / 1000) * eff;
|
||||||
|
}
|
||||||
|
return total * offlineBoost;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Core economy (mis à jour pour intégrer l'arbre) ---
|
// --- Core economy (mis à jour pour intégrer l'arbre) ---
|
||||||
|
|
||||||
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned
|
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned × (1 - costReduction)
|
||||||
export function generatorCost(gen: Generator): number {
|
export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
|
||||||
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
|
const base = Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
|
||||||
|
if (!tree) return base;
|
||||||
|
const reduction = getCostReduction(tree);
|
||||||
|
return Math.max(1, Math.floor(base * (1 - reduction)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production totale par seconde de tous les générateurs
|
// Production totale par seconde de tous les générateurs
|
||||||
export function totalProductionPerSecond(state: GameState): number {
|
export function totalProductionPerSecond(state: GameState): number {
|
||||||
|
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
|
||||||
|
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||||||
const base = state.generators.reduce(
|
const base = state.generators.reduce(
|
||||||
(sum, gen) => sum + gen.baseProduction * gen.owned,
|
(sum, gen) => {
|
||||||
|
const boost = gen.id === "nid" ? nidBoost : 1;
|
||||||
|
return sum + gen.baseProduction * gen.owned * boost;
|
||||||
|
},
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||||
return base * state.prestigeMultiplier * treeMultiplier;
|
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
|
||||||
|
return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazy calculation : ressources accumulées depuis lastTick
|
// Lazy calculation : ressources accumulées depuis lastTick
|
||||||
@@ -126,22 +603,59 @@ export function applyIdleGains(state: GameState, now: number): GameState {
|
|||||||
resources: state.resources + gains,
|
resources: state.resources + gains,
|
||||||
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||||||
lastTick: now,
|
lastTick: now,
|
||||||
|
runStats: {
|
||||||
|
...state.runStats,
|
||||||
|
tadpolesProduced: state.runStats.tadpolesProduced + gains,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gain réel par clic (pour affichage particule)
|
// Gain de base par clic (sans RNG — pour affichage tooltip)
|
||||||
export function getClickGain(state: GameState): number {
|
export function getClickGain(state: GameState): number {
|
||||||
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||||
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
|
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clic manuel
|
export interface ClickResult {
|
||||||
export function applyClick(state: GameState): GameState {
|
state: GameState;
|
||||||
const gain = getClickGain(state);
|
gain: number;
|
||||||
|
isDouble: boolean;
|
||||||
|
isCrit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clic manuel avec double ponte + crit
|
||||||
|
export function applyClick(state: GameState, rng: number = Math.random()): ClickResult {
|
||||||
|
let gain = getClickGain(state);
|
||||||
|
let isDouble = false;
|
||||||
|
let isCrit = false;
|
||||||
|
|
||||||
|
const doubleChance = getDoubleClickChance(state.evolutionTree);
|
||||||
|
if (doubleChance > 0 && rng < doubleChance) {
|
||||||
|
gain *= 2;
|
||||||
|
isDouble = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const critChance = getCritClickChance(state.evolutionTree);
|
||||||
|
// Use a second "roll" derived from rng to avoid double+crit being correlated
|
||||||
|
const critRng = (rng * 7.13) % 1;
|
||||||
|
if (critChance > 0 && critRng < critChance) {
|
||||||
|
gain *= 10;
|
||||||
|
isCrit = true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
state: {
|
||||||
...state,
|
...state,
|
||||||
resources: state.resources + gain,
|
resources: state.resources + gain,
|
||||||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||||||
|
runStats: {
|
||||||
|
...state.runStats,
|
||||||
|
tadpolesProduced: state.runStats.tadpolesProduced + gain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gain,
|
||||||
|
isDouble,
|
||||||
|
isCrit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +665,7 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
|
|||||||
if (genIndex === -1) return null;
|
if (genIndex === -1) return null;
|
||||||
|
|
||||||
const gen = state.generators[genIndex];
|
const gen = state.generators[genIndex];
|
||||||
const cost = generatorCost(gen);
|
const cost = generatorCost(gen, state.evolutionTree);
|
||||||
if (state.resources < cost) return null;
|
if (state.resources < cost) return null;
|
||||||
|
|
||||||
const updatedGenerators = [...state.generators];
|
const updatedGenerators = [...state.generators];
|
||||||
@@ -165,24 +679,63 @@ export function buyGenerator(state: GameState, genId: string): GameState | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prestige : reset run, gain ADN, arbre persiste
|
// Prestige : reset run, gain ADN, arbre persiste
|
||||||
|
|
||||||
|
export function getPrestigeThreshold(state: GameState): number {
|
||||||
|
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
|
||||||
|
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
|
||||||
|
}
|
||||||
|
|
||||||
export function canPrestige(state: GameState): boolean {
|
export function canPrestige(state: GameState): boolean {
|
||||||
return state.resources >= 1_000_000;
|
return state.resources >= getPrestigeThreshold(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyPrestige(state: GameState): GameState {
|
export function applyPrestige(state: GameState): GameState {
|
||||||
const newPrestigeCount = state.prestigeCount + 1;
|
const newPrestigeCount = state.prestigeCount + 1;
|
||||||
const dnaGained = computePrestigeDna(state.lifetimeTadpoles);
|
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
|
||||||
|
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
|
||||||
|
const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
|
||||||
const startBonus = getStartBonusFromTree(state.evolutionTree);
|
const startBonus = getStartBonusFromTree(state.evolutionTree);
|
||||||
|
|
||||||
|
// Résilience : commencer avec 1 Lac Mystique
|
||||||
|
const hasUnlockGen = state.evolutionTree.some(
|
||||||
|
(n) => n.unlocked && n.effect === "unlock_generator"
|
||||||
|
);
|
||||||
|
// Milestone bonus : Nid gratuit au départ
|
||||||
|
const milestoneNid = getMilestoneStartNid(state);
|
||||||
|
|
||||||
|
// RunStats : snapshot de la run qui se termine
|
||||||
|
const now = Date.now();
|
||||||
|
const runDuration = now - state.runStats.startedAt;
|
||||||
|
const bestRun = state.runStats.bestRun;
|
||||||
|
const newBestRun =
|
||||||
|
!bestRun || dnaGained > bestRun.adn
|
||||||
|
? { duration: runDuration, tadpoles: state.lifetimeTadpoles, adn: dnaGained }
|
||||||
|
: bestRun;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
resources: startBonus,
|
resources: startBonus,
|
||||||
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
|
generators: state.generators.map((g) => ({
|
||||||
|
...g,
|
||||||
|
owned:
|
||||||
|
(hasUnlockGen && g.id === "lac") ? 1 :
|
||||||
|
(milestoneNid > 0 && g.id === "nid") ? milestoneNid :
|
||||||
|
0,
|
||||||
|
})),
|
||||||
prestigeCount: newPrestigeCount,
|
prestigeCount: newPrestigeCount,
|
||||||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||||||
ancestralDna: state.ancestralDna + dnaGained,
|
ancestralDna: state.ancestralDna + dnaGained,
|
||||||
lifetimeTadpoles: 0,
|
lifetimeTadpoles: 0,
|
||||||
lastTick: Date.now(),
|
lastTick: now,
|
||||||
|
lastOnline: now,
|
||||||
|
// Sprint 3 — nouvelle run
|
||||||
|
runStats: {
|
||||||
|
startedAt: now,
|
||||||
|
tadpolesProduced: 0,
|
||||||
|
bestRun: newBestRun,
|
||||||
|
},
|
||||||
|
freeResetAvailable: true, // 1 reset gratuit offert par prestige
|
||||||
|
extraResetsUsed: 0,
|
||||||
// evolutionTree persiste — jamais reset
|
// evolutionTree persiste — jamais reset
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -197,13 +750,25 @@ export const DEFAULT_GENERATORS: Generator[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_STATE: GameState = {
|
export const DEFAULT_STATE: GameState = {
|
||||||
|
saveVersion: CURRENT_SAVE_VERSION,
|
||||||
resources: 0,
|
resources: 0,
|
||||||
clickMultiplier: 1,
|
clickMultiplier: 1,
|
||||||
generators: DEFAULT_GENERATORS,
|
generators: DEFAULT_GENERATORS,
|
||||||
lastTick: Date.now(),
|
lastTick: Date.now(),
|
||||||
|
lastOnline: Date.now(),
|
||||||
prestigeCount: 0,
|
prestigeCount: 0,
|
||||||
prestigeMultiplier: 1,
|
prestigeMultiplier: 1,
|
||||||
ancestralDna: 0,
|
ancestralDna: 0,
|
||||||
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||||||
lifetimeTadpoles: 0,
|
lifetimeTadpoles: 0,
|
||||||
|
cosmeticInventory: [],
|
||||||
|
cosmeticEquipped: {},
|
||||||
|
runStats: {
|
||||||
|
startedAt: Date.now(),
|
||||||
|
tadpolesProduced: 0,
|
||||||
|
bestRun: null,
|
||||||
|
},
|
||||||
|
freeResetAvailable: true,
|
||||||
|
extraResetsUsed: 0,
|
||||||
|
claimedMilestones: [],
|
||||||
};
|
};
|
||||||
|
|||||||
142
Frontend/src/core/migrateSave.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// migrateSave.ts — Migration lazy des saves entre versions
|
||||||
|
// Appliqué au chargement (frontend + backend). Jamais de migration en DB.
|
||||||
|
// Chaque sprint ajoute un step (v2→v3, etc.)
|
||||||
|
|
||||||
|
import { CURRENT_SAVE_VERSION } from "./balance";
|
||||||
|
import type { GameState } from "./economy";
|
||||||
|
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte la version d'une save et applique les migrations nécessaires.
|
||||||
|
* Entrée : objet brut depuis la DB/localStorage (potentiellement incomplet).
|
||||||
|
* Sortie : GameState conforme à la version courante.
|
||||||
|
*/
|
||||||
|
export function migrateSave(raw: Record<string, unknown>): GameState {
|
||||||
|
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
|
||||||
|
|
||||||
|
let state = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (version < 2) {
|
||||||
|
state = migrateV1toV2(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Futures migrations :
|
||||||
|
// if (version < 3) state = migrateV2toV3(state);
|
||||||
|
|
||||||
|
return state as unknown as GameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1 → v2 : Sprint 2 → Sprint 3
|
||||||
|
* - Ajoute saveVersion
|
||||||
|
* - Ajoute runStats (vide)
|
||||||
|
* - Ajoute freeResetAvailable + extraResetsUsed
|
||||||
|
* - Merge les nouveaux nœuds arbre (conserve l'état des 18 existants)
|
||||||
|
* - Backfill champs manquants (cosmeticInventory, cosmeticEquipped, lastOnline)
|
||||||
|
*/
|
||||||
|
function migrateV1toV2(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const state = { ...raw };
|
||||||
|
|
||||||
|
// saveVersion
|
||||||
|
state.saveVersion = 2;
|
||||||
|
|
||||||
|
// RunStats (nouveau Sprint 3)
|
||||||
|
if (!state.runStats) {
|
||||||
|
state.runStats = {
|
||||||
|
startedAt: typeof state.lastTick === "number" ? state.lastTick : Date.now(),
|
||||||
|
tadpolesProduced: 0,
|
||||||
|
bestRun: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset arbre : 1 gratuit par prestige
|
||||||
|
if (typeof state.freeResetAvailable !== "boolean") {
|
||||||
|
state.freeResetAvailable = true;
|
||||||
|
}
|
||||||
|
if (typeof state.extraResetsUsed !== "number") {
|
||||||
|
state.extraResetsUsed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Milestones (Sprint 3)
|
||||||
|
if (!Array.isArray(state.claimedMilestones)) {
|
||||||
|
state.claimedMilestones = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill champs Sprint 2 potentiellement manquants
|
||||||
|
if (!state.lastOnline) state.lastOnline = state.lastTick;
|
||||||
|
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
|
||||||
|
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
|
||||||
|
state.cosmeticEquipped = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge arbre : conserver les 18 nœuds existants + ajouter les nouveaux
|
||||||
|
state.evolutionTree = mergeEvolutionTree(
|
||||||
|
state.evolutionTree as Array<Record<string, unknown>> | undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge générateurs : conserver owned + ajouter les potentiels nouveaux
|
||||||
|
state.generators = mergeGenerators(
|
||||||
|
state.generators as Array<Record<string, unknown>> | undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge l'arbre sauvegardé avec DEFAULT_EVOLUTION_TREE.
|
||||||
|
* - Nœuds existants : conserve unlocked state
|
||||||
|
* - Nœuds nouveaux : ajoutés avec unlocked: false
|
||||||
|
* - Nœuds supprimés du default : retirés (forward compat)
|
||||||
|
*/
|
||||||
|
function mergeEvolutionTree(
|
||||||
|
savedTree: Array<Record<string, unknown>> | undefined
|
||||||
|
): typeof DEFAULT_EVOLUTION_TREE {
|
||||||
|
if (!savedTree || !Array.isArray(savedTree)) {
|
||||||
|
return DEFAULT_EVOLUTION_TREE.map((n) => ({ ...n }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedById = new Map(
|
||||||
|
savedTree.map((n) => [n.id as string, n])
|
||||||
|
);
|
||||||
|
|
||||||
|
return DEFAULT_EVOLUTION_TREE.map((defaultNode) => {
|
||||||
|
const saved = savedById.get(defaultNode.id);
|
||||||
|
if (saved) {
|
||||||
|
// Conserver l'état unlocked, tout le reste vient du default
|
||||||
|
// (permet de corriger des valeurs rebalancées sans casser les saves)
|
||||||
|
return {
|
||||||
|
...defaultNode,
|
||||||
|
unlocked: saved.unlocked === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Nouveau nœud — ajouté verrouillé
|
||||||
|
return { ...defaultNode };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge les générateurs sauvegardés avec DEFAULT_GENERATORS.
|
||||||
|
* Conserve le owned count, met à jour les stats de base.
|
||||||
|
*/
|
||||||
|
function mergeGenerators(
|
||||||
|
savedGens: Array<Record<string, unknown>> | undefined
|
||||||
|
): typeof DEFAULT_GENERATORS {
|
||||||
|
if (!savedGens || !Array.isArray(savedGens)) {
|
||||||
|
return DEFAULT_GENERATORS.map((g) => ({ ...g }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedById = new Map(
|
||||||
|
savedGens.map((g) => [g.id as string, g])
|
||||||
|
);
|
||||||
|
|
||||||
|
return DEFAULT_GENERATORS.map((defaultGen) => {
|
||||||
|
const saved = savedById.get(defaultGen.id);
|
||||||
|
if (saved) {
|
||||||
|
return {
|
||||||
|
...defaultGen,
|
||||||
|
owned: typeof saved.owned === "number" ? saved.owned : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...defaultGen };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -165,9 +165,12 @@ export const ACHIEVEMENTS: Achievement[] = [
|
|||||||
{
|
{
|
||||||
id: "full_tree",
|
id: "full_tree",
|
||||||
name: "Évolution Complète",
|
name: "Évolution Complète",
|
||||||
description: "Débloquer tous les noeuds de l'arbre.",
|
description: "Débloquer un nœud dans chaque branche de l'arbre.",
|
||||||
icon: "🌳",
|
icon: "🌳",
|
||||||
check: (s) => s.evolutionTree.every((n) => n.unlocked),
|
check: (s) => {
|
||||||
|
const branches = new Set(s.evolutionTree.filter((n) => n.unlocked).map((n) => n.branch));
|
||||||
|
return branches.size >= 3;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Easter eggs & humour ---
|
// --- Easter eggs & humour ---
|
||||||
@@ -211,6 +214,6 @@ export const ACHIEVEMENTS: Achievement[] = [
|
|||||||
name: "Le Cercle de la Vie",
|
name: "Le Cercle de la Vie",
|
||||||
description: "Symbiose activée. Même Mufasa serait fier.",
|
description: "Symbiose activée. Même Mufasa serait fier.",
|
||||||
icon: "🦁",
|
icon: "🦁",
|
||||||
check: (s) => hasEvolutionNode(s, "symbiose"),
|
check: (s) => hasEvolutionNode(s, "symbiose_algale"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
76
Frontend/src/data/prestigeMilestones.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// prestigeMilestones.ts — Paliers de prestige (Sprint 3)
|
||||||
|
// 8 paliers : cosmétiques exclusifs + bonus gameplay légers
|
||||||
|
|
||||||
|
export type MilestoneRewardType = "cosmetic" | "bonus" | "title";
|
||||||
|
|
||||||
|
export interface PrestigeMilestone {
|
||||||
|
id: string;
|
||||||
|
threshold: number; // nombre de prestiges requis
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
reward: MilestoneReward;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MilestoneReward =
|
||||||
|
| { type: "cosmetic"; cosmeticId: string; label: string }
|
||||||
|
| { type: "bonus"; effect: string; value: number; label: string }
|
||||||
|
| { type: "title"; title: string; label: string };
|
||||||
|
|
||||||
|
export const PRESTIGE_MILESTONES: PrestigeMilestone[] = [
|
||||||
|
{
|
||||||
|
id: "milestone_1",
|
||||||
|
threshold: 1,
|
||||||
|
name: "Premiere Generation",
|
||||||
|
description: "Premier prestige accompli",
|
||||||
|
reward: { type: "cosmetic", cosmeticId: "ribbon", label: "Ruban queue" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_3",
|
||||||
|
threshold: 3,
|
||||||
|
name: "Gardien Recurrent",
|
||||||
|
description: "3 prestiges — la perseverance paie",
|
||||||
|
reward: { type: "title", title: "Gardien Recurrent", label: "Titre exclusif" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_5",
|
||||||
|
threshold: 5,
|
||||||
|
name: "Nid Offert",
|
||||||
|
description: "5 prestiges — un coup de pouce au depart",
|
||||||
|
reward: { type: "bonus", effect: "start_nid", value: 1, label: "1 Nid gratuit au depart" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_10",
|
||||||
|
threshold: 10,
|
||||||
|
name: "Tetard Ancestral",
|
||||||
|
description: "10 prestiges — la lignee s'affirme",
|
||||||
|
reward: { type: "cosmetic", cosmeticId: "crown", label: "Couronne doree + skin Ancestral" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_15",
|
||||||
|
threshold: 15,
|
||||||
|
name: "Marais Fidele",
|
||||||
|
description: "15 prestiges — le marais te reconnait",
|
||||||
|
reward: { type: "bonus", effect: "offline_cap_perm", value: 0.05, label: "+5% offline cap permanent" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_25",
|
||||||
|
threshold: 25,
|
||||||
|
name: "Gardien Emerite",
|
||||||
|
description: "25 prestiges — tissu d'algues ancestrales",
|
||||||
|
reward: { type: "cosmetic", cosmeticId: "cape_algae", label: "Cape d'algues ancestrales" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_50",
|
||||||
|
threshold: 50,
|
||||||
|
name: "Legende du Marais",
|
||||||
|
description: "50 prestiges — la legende est toi",
|
||||||
|
reward: { type: "cosmetic", cosmeticId: "flame_tail", label: "Queue enflamee + particules dorees" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "milestone_100",
|
||||||
|
threshold: 100,
|
||||||
|
name: "Tetard Primordial",
|
||||||
|
description: "100 prestiges — retour aux origines",
|
||||||
|
reward: { type: "cosmetic", cosmeticId: "primordial_body", label: "Skin Tetard Primordial (full set)" },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useCallback, useState } from "react";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useGameStore } from "../store/useGameStore";
|
import { useGameStore } from "../store/useGameStore";
|
||||||
import type { GameState } from "../core/economy";
|
import type { GameState } from "../core/economy";
|
||||||
|
import { migrateSave } from "../core/migrateSave";
|
||||||
|
|
||||||
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
|
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310";
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310";
|
||||||
@@ -51,9 +52,10 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
|
|||||||
|
|
||||||
apiRequest("/save").then((data) => {
|
apiRequest("/save").then((data) => {
|
||||||
if (data?.gameState) {
|
if (data?.gameState) {
|
||||||
onLoad(data.gameState);
|
const migrated = migrateSave(data.gameState);
|
||||||
|
onLoad(migrated);
|
||||||
lastSaveRef.current = data.lastSave;
|
lastSaveRef.current = data.lastSave;
|
||||||
console.info("[SaveSync] Loaded save from server — server is authority");
|
console.info("[SaveSync] Loaded save from server — server is authority (v%d)", migrated.saveVersion);
|
||||||
} else {
|
} else {
|
||||||
console.info("[SaveSync] No server save found — starting fresh");
|
console.info("[SaveSync] No server save found — starting fresh");
|
||||||
}
|
}
|
||||||
@@ -99,7 +101,8 @@ export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncO
|
|||||||
setTimeout(() => apiRequest("/save").then((data) => {
|
setTimeout(() => apiRequest("/save").then((data) => {
|
||||||
if (data?.gameState && data.lastSave) {
|
if (data?.gameState && data.lastSave) {
|
||||||
if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) {
|
if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) {
|
||||||
onLoad(data.gameState);
|
const migrated = migrateSave(data.gameState);
|
||||||
|
onLoad(migrated);
|
||||||
lastSaveRef.current = data.lastSave;
|
lastSaveRef.current = data.lastSave;
|
||||||
console.info("[SaveSync] Reloaded from server on focus");
|
console.info("[SaveSync] Reloaded from server on focus");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,967 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* ── Tailwind v4 theme — tokens du jeu ── */
|
||||||
|
@theme {
|
||||||
|
/* Base colors */
|
||||||
|
--color-blue-light: #dcecf3;
|
||||||
|
--color-purple-light: #e4e3f3;
|
||||||
|
--color-red-light: #c33636;
|
||||||
|
--color-light: #eaeaea;
|
||||||
|
--color-grey: #202020;
|
||||||
|
--color-grey-hover: #606060;
|
||||||
|
|
||||||
|
/* Game panel tokens */
|
||||||
|
--color-gp-bg: rgba(17, 17, 17, 0.75);
|
||||||
|
--color-gp-bg-hover: rgba(17, 17, 17, 0.85);
|
||||||
|
--color-gp-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--color-gp-text: rgba(255, 255, 255, 0.9);
|
||||||
|
--color-gp-text-muted: rgba(255, 255, 255, 0.5);
|
||||||
|
--color-gp-accent-green: #34d399;
|
||||||
|
--color-gp-accent-purple: #a78bfa;
|
||||||
|
--color-gp-accent-amber: #fbbf24;
|
||||||
|
--color-gp-accent-green-bg: rgba(16, 185, 129, 0.12);
|
||||||
|
--color-gp-accent-purple-bg: rgba(139, 92, 246, 0.12);
|
||||||
|
--color-gp-accent-amber-bg: rgba(251, 191, 36, 0.12);
|
||||||
|
--color-gp-btn: #059669;
|
||||||
|
--color-gp-btn-hover: #10b981;
|
||||||
|
--color-gp-btn-disabled: rgba(255, 255, 255, 0.08);
|
||||||
|
--color-gp-btn-text-disabled: rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
/* Spacing / sizing tokens */
|
||||||
|
--radius-gp: 0.75rem;
|
||||||
|
--spacing-gp: 0.75rem;
|
||||||
|
--spacing-gp-gap: 0.5rem;
|
||||||
|
|
||||||
|
/* Font sizes */
|
||||||
|
--font-size-gp-title: 0.8rem;
|
||||||
|
--font-size-gp-text: 0.75rem;
|
||||||
|
--font-size-gp-sm: 0.65rem;
|
||||||
|
|
||||||
|
/* Animation */
|
||||||
|
--animate-gp-pulse: gp-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gp-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(124, 58, 237, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global reset & base ── */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font: "Hanken Grotesk", sans-serif;
|
||||||
|
--bg-color: var(--color-blue-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 92vh;
|
||||||
|
margin-top: 80px;
|
||||||
|
padding: 0 0 2rem;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
display: none;
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Zone system (biomes) ── */
|
||||||
|
@layer components {
|
||||||
|
.zone {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 92vh;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
transition: background-image 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-zone="swamp"] {
|
||||||
|
background-image: url("/webp/bg-cover.webp");
|
||||||
|
background-position: center 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-zone="landing"] {
|
||||||
|
background: var(--bg-color);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-zone="page"] {
|
||||||
|
background: var(--bg-color);
|
||||||
|
align-items: flex-start;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game panels design system ── */
|
||||||
|
|
||||||
|
.gp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-gp-gap);
|
||||||
|
padding: var(--spacing-gp);
|
||||||
|
background: var(--color-gp-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--color-gp-border);
|
||||||
|
border-radius: var(--radius-gp);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-title {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--font-size-gp-title);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-gp-text);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-label {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--font-size-gp-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-gp-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-value {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--font-size-gp-text);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-gp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-accent-green { color: var(--color-gp-accent-green); }
|
||||||
|
.gp-accent-purple { color: var(--color-gp-accent-purple); }
|
||||||
|
.gp-accent-amber { color: var(--color-gp-accent-amber); }
|
||||||
|
|
||||||
|
/* Row item (générateur, noeud évolution) */
|
||||||
|
.gp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: calc(var(--radius-gp) - 0.15rem);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-row--active {
|
||||||
|
border-color: rgba(16, 185, 129, 0.3);
|
||||||
|
background: var(--color-gp-accent-green-bg);
|
||||||
|
}
|
||||||
|
.gp-row--active:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-row--locked {
|
||||||
|
border-color: var(--color-gp-border);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-row--evolution {
|
||||||
|
border-color: rgba(251, 191, 36, 0.3);
|
||||||
|
background: var(--color-gp-accent-amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-row--unlocked {
|
||||||
|
border-color: rgba(16, 185, 129, 0.3);
|
||||||
|
background: var(--color-gp-accent-green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bouton achat */
|
||||||
|
.gp-btn {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--font-size-gp-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-btn--buy {
|
||||||
|
background: var(--color-gp-btn);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.gp-btn--buy:hover {
|
||||||
|
background: var(--color-gp-btn-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-btn--disabled {
|
||||||
|
background: var(--color-gp-btn-disabled);
|
||||||
|
color: var(--color-gp-btn-text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-btn--prestige {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: var(--font-size-gp-text);
|
||||||
|
animation: var(--animate-gp-pulse);
|
||||||
|
}
|
||||||
|
.gp-btn--prestige:hover {
|
||||||
|
background: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header cockpit (stats résumé) */
|
||||||
|
.gp-cockpit-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.gp-progress {
|
||||||
|
height: 0.35rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gp-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section separator */
|
||||||
|
.gp-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-gp-border);
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone titles in sidebar */
|
||||||
|
.gp-zone-label {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--font-size-gp-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-gp-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding-left: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Home / Game view ── */
|
||||||
|
|
||||||
|
.click-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 2vh;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.click-zone {
|
||||||
|
padding-right: 22rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-zone:active img {
|
||||||
|
transform: scale(0.95) rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-zone-counter {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 12px rgba(52, 211, 153, 0.5), 0 2px 6px rgba(0, 0, 0, 0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.click-zone-counter {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.achieve-badge {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: var(--radius-gp);
|
||||||
|
background: var(--color-gp-accent-green-bg);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: var(--font-size-gp-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-gp-accent-green);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.achieve-badge:hover {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-particle {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #34d399;
|
||||||
|
text-shadow: 0 0 8px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 100;
|
||||||
|
animation: float-up 1.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 5.5rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
width: 20rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.game-sidebar {
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 45vh;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-top-left-radius: 1rem;
|
||||||
|
border-top-right-radius: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-up {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1.2);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-80px) scale(1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Navbar ── */
|
||||||
|
@layer components {
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
padding: 0 2rem;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
background-blend-mode: darken;
|
||||||
|
background-size: cover;
|
||||||
|
z-index: 99;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
@media (max-width: 999px) {
|
||||||
|
.header-main {
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 5rem;
|
||||||
|
content: url(/svg/tadpole.svg);
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.6rem;
|
||||||
|
align-items: center;
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 999px) {
|
||||||
|
.nav-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list li {
|
||||||
|
list-style: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
float: left;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainLink {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 30px 0;
|
||||||
|
}
|
||||||
|
.mainLink:hover {
|
||||||
|
color: var(--color-red-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropLink {
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.dropLink:hover {
|
||||||
|
color: var(--color-red-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
background: var(--color-grey);
|
||||||
|
transform: translateY(30px);
|
||||||
|
min-width: 160px;
|
||||||
|
box-shadow: 0 8px 16px rgba(10, 10, 10, 0.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.dropdown-content a {
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.dropdown-content a:hover {
|
||||||
|
background-color: var(--color-grey-hover);
|
||||||
|
}
|
||||||
|
.dropdown:hover .dropdown-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.auth-nickname {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-grey);
|
||||||
|
}
|
||||||
|
.auth-btn {
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-grey);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.auth-btn:hover {
|
||||||
|
background: var(--color-grey);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Burger menu (mobile) ── */
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
.menuToggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 999px) {
|
||||||
|
.menuToggle {
|
||||||
|
float: left;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
top: 2px;
|
||||||
|
left: -10px;
|
||||||
|
z-index: 99;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.menuToggle a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-grey);
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
.menuToggle a:hover {
|
||||||
|
color: var(--color-red-light);
|
||||||
|
}
|
||||||
|
.menuToggle input {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 32px;
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: -5px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.menuToggle span {
|
||||||
|
display: block;
|
||||||
|
width: 33px;
|
||||||
|
height: 4px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-grey);
|
||||||
|
border-radius: 3px;
|
||||||
|
z-index: 1;
|
||||||
|
transform-origin: 4px 0;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||||
|
background 0.2s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease;
|
||||||
|
}
|
||||||
|
.menuToggle span:first-child {
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
}
|
||||||
|
.menuToggle span:nth-last-child(2) {
|
||||||
|
transform-origin: 0% 100%;
|
||||||
|
}
|
||||||
|
.menuToggle input:checked ~ span {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(45deg) translate(-2px, -1px);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.menuToggle input:checked ~ span:nth-last-child(3) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(0deg) scale(0.2, 0.2);
|
||||||
|
}
|
||||||
|
.menuToggle input:checked ~ span:nth-last-child(2) {
|
||||||
|
transform: rotate(-45deg) translate(0, -1px);
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 280px;
|
||||||
|
height: 110vh;
|
||||||
|
margin: -100px 0 0 -231px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
padding-top: 100px;
|
||||||
|
background: var(--color-grey);
|
||||||
|
list-style-type: none;
|
||||||
|
transform-origin: 0% 0%;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||||
|
}
|
||||||
|
.menu li {
|
||||||
|
padding: 10px 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.menuToggle input:checked ~ ul {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sousmenu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-left: 1.2rem;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-weight: 500;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
@layer components {
|
||||||
|
.primary-button {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
height: fit-content;
|
||||||
|
background-color: var(--color-red-light);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: white !important;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
transition: transform 0.1s ease-in-out;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.primary-button:hover {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey) !important;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: transform 0.1s ease-in-out;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.secondary-button:hover {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background-color: var(--color-grey-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
@layer components {
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-top: solid 1px var(--color-grey);
|
||||||
|
padding: 2rem 0;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.footer-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 90%;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.footer-logo {
|
||||||
|
background-image: url(/svg/tadpole.svg);
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
width: 250px;
|
||||||
|
height: 100px;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
.footer-logo:hover {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.footer .section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
text-decoration-line: underline;
|
||||||
|
text-underline-offset: 0.5rem;
|
||||||
|
}
|
||||||
|
.section-text {
|
||||||
|
max-width: 26ch;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
}
|
||||||
|
.section-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.section-list .section-item,
|
||||||
|
.section-list a {
|
||||||
|
width: fit-content;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
.section-list .section-item:hover,
|
||||||
|
.section-list a:hover {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.spacing {
|
||||||
|
min-width: 150px;
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
.footer-github {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-grey);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
.footer-github:hover {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.copyright {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--color-grey);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pages layout (error, legal, settings, login) ── */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 132ch;
|
||||||
|
width: 80%;
|
||||||
|
gap: 3rem;
|
||||||
|
margin: 150px auto 50px;
|
||||||
|
}
|
||||||
|
.container h1 {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.container h2 {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-grey);
|
||||||
|
}
|
||||||
|
.container .subtitle {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
.container .content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.container .paragraphe {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
list-style: inside;
|
||||||
|
}
|
||||||
|
.container .info {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 90vh;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.containererror {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
.containererror h1 {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
font-family: var(--font);
|
||||||
|
color: var(--color-grey);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn-return {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
margin: auto;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: var(--color-grey);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-return:hover {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Achievements ── */
|
||||||
|
|
||||||
|
.fullachieve {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-top: 6rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
background-color: var(--color-blue-light);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
.fullachieve h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.achieve-counter {
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.achievementscontainer {
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
.achievementscardcontainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 200px;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.achieve-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.achieve-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.achieve-unlocked {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
.achieve-locked {
|
||||||
|
background: rgba(107, 114, 128, 0.08);
|
||||||
|
border: 1px solid rgba(107, 114, 128, 0.15);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.achieve-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.achieve-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.achieve-name {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-grey);
|
||||||
|
}
|
||||||
|
.achieve-desc {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-grey);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legal / Cookie pages ── */
|
||||||
|
|
||||||
|
.mentionslegales {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1280px;
|
||||||
|
font-family: var(--font);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3rem;
|
||||||
|
padding: 15rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
.mentionslegales h2 {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-grey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import Landing from "./pages/Landing";
|
import Landing from "./pages/Landing";
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import "../scss/pages.scss";
|
|
||||||
import Lottie from "react-lottie-player";
|
import Lottie from "react-lottie-player";
|
||||||
import animation404 from "../data/404-animation.json";
|
import animation404 from "../data/404-animation.json";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useGameStore } from "../store/useGameStore";
|
import { useGameStore } from "../store/useGameStore";
|
||||||
import { ACHIEVEMENTS } from "../data/achievements";
|
import { ACHIEVEMENTS } from "../data/achievements";
|
||||||
import "../scss/achievements.scss";
|
|
||||||
|
|
||||||
function Achievements() {
|
function Achievements() {
|
||||||
const state = useGameStore((s) => s.state);
|
const state = useGameStore((s) => s.state);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useNavigate, Link } from "react-router-dom";
|
|||||||
import { exchangeCode, loadVerifier, clearVerifier } from "../lib/oauth";
|
import { exchangeCode, loadVerifier, clearVerifier } from "../lib/oauth";
|
||||||
import { apiFetch } from "../lib/api";
|
import { apiFetch } from "../lib/api";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import "../scss/pages.scss";
|
|
||||||
|
|
||||||
export default function AuthCallback() {
|
export default function AuthCallback() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import "../scss/Cookie.scss";
|
|
||||||
function Cookie() {
|
function Cookie() {
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { PrestigePanel } from "../components/PrestigePanel";
|
|||||||
import { EvolutionTree } from "../components/EvolutionTree";
|
import { EvolutionTree } from "../components/EvolutionTree";
|
||||||
import { MilestoneBar } from "../components/MilestoneBar";
|
import { MilestoneBar } from "../components/MilestoneBar";
|
||||||
import { CockpitHeader } from "../components/CockpitHeader";
|
import { CockpitHeader } from "../components/CockpitHeader";
|
||||||
|
import { TadpoleSprite } from "../components/TadpoleSprite";
|
||||||
|
import { CosmeticsPanel } from "../components/CosmeticsPanel";
|
||||||
|
import { PrestigeScreen } from "../components/PrestigeScreen";
|
||||||
|
import { MilestonesPanel } from "../components/MilestonesPanel";
|
||||||
import { ACHIEVEMENTS } from "../data/achievements";
|
import { ACHIEVEMENTS } from "../data/achievements";
|
||||||
import "../scss/home.scss";
|
|
||||||
import "../scss/components/game-panels.scss";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [toggleRain] = useOutletContext();
|
const [toggleRain] = useOutletContext();
|
||||||
@@ -22,21 +24,30 @@ export default function Home() {
|
|||||||
const state = useGameStore((s) => s.state);
|
const state = useGameStore((s) => s.state);
|
||||||
const clickGain = getClickGain(state);
|
const clickGain = getClickGain(state);
|
||||||
|
|
||||||
const createParticle = useCallback((clientX, clientY) => {
|
const lastClickGain = useGameStore((s) => s.lastClickGain);
|
||||||
|
const lastClickDouble = useGameStore((s) => s.lastClickDouble);
|
||||||
|
const lastClickCrit = useGameStore((s) => s.lastClickCrit);
|
||||||
|
|
||||||
|
const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => {
|
||||||
const particle = document.createElement("span");
|
const particle = document.createElement("span");
|
||||||
particle.className = "click-particle";
|
particle.className = "click-particle";
|
||||||
particle.textContent = `+${formatNumber(clickGain)}`;
|
const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : "";
|
||||||
|
particle.textContent = `${prefix}+${formatNumber(gain)}`;
|
||||||
|
if (isCrit) particle.style.color = "#f59e0b";
|
||||||
|
else if (isDouble) particle.style.color = "#a78bfa";
|
||||||
particle.style.left = `${clientX}px`;
|
particle.style.left = `${clientX}px`;
|
||||||
particle.style.top = `${clientY}px`;
|
particle.style.top = `${clientY}px`;
|
||||||
document.body.appendChild(particle);
|
document.body.appendChild(particle);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (particle.parentNode) particle.parentNode.removeChild(particle);
|
if (particle.parentNode) particle.parentNode.removeChild(particle);
|
||||||
}, 800);
|
}, 800);
|
||||||
}, [clickGain]);
|
}, []);
|
||||||
|
|
||||||
const handleIncrement = useCallback((e) => {
|
const handleIncrement = useCallback((e) => {
|
||||||
click();
|
click();
|
||||||
createParticle(e.clientX, e.clientY);
|
// Read latest click result from store after click
|
||||||
|
const s = useGameStore.getState();
|
||||||
|
createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit);
|
||||||
}, [click, createParticle]);
|
}, [click, createParticle]);
|
||||||
|
|
||||||
// Rain effect (ambiance)
|
// Rain effect (ambiance)
|
||||||
@@ -113,7 +124,7 @@ export default function Home() {
|
|||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<section className="game-container">
|
<section className="game-container">
|
||||||
<p style={{ textAlign: "center", color: "#6b7a99", marginTop: "20vh" }}>
|
<p className="text-center text-slate-400 mt-[20vh]">
|
||||||
Chargement de ta progression...
|
Chargement de ta progression...
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -127,9 +138,11 @@ export default function Home() {
|
|||||||
<title>Clickerz — Tetard Universe</title>
|
<title>Clickerz — Tetard Universe</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
|
<PrestigeScreen />
|
||||||
|
|
||||||
{/* Clicker area — centre */}
|
{/* Clicker area — centre */}
|
||||||
<div className="click-zone" onClick={handleIncrement}>
|
<div className="click-zone" onClick={handleIncrement}>
|
||||||
<div className="tadpole-sprite" />
|
<TadpoleSprite />
|
||||||
<div className="click-zone-counter">
|
<div className="click-zone-counter">
|
||||||
{formatNumber(resources)}
|
{formatNumber(resources)}
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +156,9 @@ export default function Home() {
|
|||||||
<GeneratorShop />
|
<GeneratorShop />
|
||||||
<div className="gp-sep" />
|
<div className="gp-sep" />
|
||||||
<PrestigePanel />
|
<PrestigePanel />
|
||||||
|
<MilestonesPanel />
|
||||||
<EvolutionTree />
|
<EvolutionTree />
|
||||||
|
<CosmeticsPanel />
|
||||||
<a href="/achievements" className="achieve-badge">
|
<a href="/achievements" className="achieve-badge">
|
||||||
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
|
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import "../scss/Legal.scss";
|
|
||||||
function Legal() {
|
function Legal() {
|
||||||
return (
|
return (
|
||||||
<div className="mentionslegales">
|
<div className="mentionslegales">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { buildAuthUrl, saveVerifier } from "../lib/oauth";
|
import { buildAuthUrl, saveVerifier } from "../lib/oauth";
|
||||||
import "../scss/pages.scss";
|
|
||||||
|
|
||||||
const PROVIDERS = [
|
const PROVIDERS = [
|
||||||
{ id: "discord", label: "Discord", emoji: "🎮" },
|
{ id: "discord", label: "Discord", emoji: "🎮" },
|
||||||
@@ -31,7 +30,7 @@ export default function Login() {
|
|||||||
<div className="containererror">
|
<div className="containererror">
|
||||||
<h1>Connexion</h1>
|
<h1>Connexion</h1>
|
||||||
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
|
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 16 }}>
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
{PROVIDERS.map((p) => (
|
{PROVIDERS.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import "../scss/pages.scss";
|
|
||||||
|
|
||||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || "";
|
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || "";
|
||||||
const PROVIDERS = ["discord", "github", "google", "twitch"];
|
const PROVIDERS = ["discord", "github", "google", "twitch"];
|
||||||
@@ -87,7 +86,6 @@ export default function Settings() {
|
|||||||
returnUrl: `${window.location.origin}/settings`,
|
returnUrl: `${window.location.origin}/settings`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
// Redirect to OAuth provider
|
|
||||||
window.location.href = data.data.authUrl;
|
window.location.href = data.data.authUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
@@ -134,30 +132,28 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<div className="containererror" style={{ maxWidth: 500 }}>
|
<div className="containererror max-w-[500px]">
|
||||||
<h1>Paramètres</h1>
|
<h1>Paramètres</h1>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: 16 }}>
|
<p className="text-red-500 text-[13px] mb-4">{error}</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profile info */}
|
{/* Profile info */}
|
||||||
{profile && (
|
{profile && (
|
||||||
<div style={{ marginBottom: 24, textAlign: "left" }}>
|
<div className="mb-6 text-left">
|
||||||
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}>
|
<p className="text-sm text-gray-400 my-1">
|
||||||
<strong>Pseudo :</strong> {profile.nickname}
|
<strong>Pseudo :</strong> {profile.nickname}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}>
|
<p className="text-sm text-gray-400 my-1">
|
||||||
<strong>Email :</strong> {profile.email || "—"}
|
<strong>Email :</strong> {profile.email || "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Linked providers */}
|
{/* Linked providers */}
|
||||||
<h2 style={{ fontSize: 18, marginBottom: 12 }}>Comptes liés</h2>
|
<h2 className="text-lg mb-3">Comptes liés</h2>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div className="flex flex-col gap-2">
|
||||||
{PROVIDERS.map((provider) => {
|
{PROVIDERS.map((provider) => {
|
||||||
const linked = linkedNames.has(provider);
|
const linked = linkedNames.has(provider);
|
||||||
const isLoading = actionLoading === provider;
|
const isLoading = actionLoading === provider;
|
||||||
@@ -165,39 +161,32 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider}
|
key={provider}
|
||||||
style={{
|
className={`flex items-center justify-between px-3 py-2 rounded-lg border ${
|
||||||
display: "flex",
|
linked
|
||||||
alignItems: "center",
|
? "bg-[#1a2a1a] border-[#2a4a2a]"
|
||||||
justifyContent: "space-between",
|
: "bg-[#1a1a2a] border-[#2a2a4a]"
|
||||||
padding: "8px 12px",
|
}`}
|
||||||
background: linked ? "#1a2a1a" : "#1a1a2a",
|
|
||||||
borderRadius: 8,
|
|
||||||
border: `1px solid ${linked ? "#2a4a2a" : "#2a2a4a"}`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 14 }}>
|
<span className="text-sm">
|
||||||
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
|
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||||
{linked && (
|
{linked && (
|
||||||
<span style={{ color: "#4ade80", fontSize: 12, marginLeft: 8 }}>
|
<span className="text-green-400 text-xs ml-2">✓ lié</span>
|
||||||
✓ lié
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{linked ? (
|
{linked ? (
|
||||||
<button
|
<button
|
||||||
className="btn-return"
|
className="btn-return text-xs! py-1! px-2.5!"
|
||||||
style={{ fontSize: 12, padding: "4px 10px", opacity: canUnlink ? 1 : 0.4 }}
|
|
||||||
disabled={!canUnlink || isLoading}
|
disabled={!canUnlink || isLoading}
|
||||||
onClick={() => handleUnlink(provider)}
|
onClick={() => handleUnlink(provider)}
|
||||||
type="button"
|
type="button"
|
||||||
|
style={{ opacity: canUnlink ? 1 : 0.4 }}
|
||||||
>
|
>
|
||||||
{isLoading ? "..." : "Délier"}
|
{isLoading ? "..." : "Délier"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn-return"
|
className="btn-return text-xs! py-1! px-2.5!"
|
||||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={() => handleLink(provider)}
|
onClick={() => handleLink(provider)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -212,8 +201,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<button
|
<button
|
||||||
className="btn-return"
|
className="btn-return mt-6 w-full!"
|
||||||
style={{ marginTop: 24, width: "100%" }}
|
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 1280px;
|
|
||||||
font-family: var(--font);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3rem;
|
|
||||||
padding: 15rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-grey);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
.mentionslegales {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 1280px;
|
|
||||||
font-family: var(--font);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3rem;
|
|
||||||
padding: 15rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-grey);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
.fullachieve {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-top: 6rem;
|
|
||||||
padding-bottom: 3rem;
|
|
||||||
background-color: var(--color-blue-light);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-height: 80vh;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 2.5rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-counter {
|
|
||||||
text-align: center;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.achievementscontainer {
|
|
||||||
margin: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.achievementscardcontainer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
min-height: 200px;
|
|
||||||
gap: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 1.2rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 380px;
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-unlocked {
|
|
||||||
background: rgba(16, 185, 129, 0.12);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-locked {
|
|
||||||
background: rgba(107, 114, 128, 0.08);
|
|
||||||
border: 1px solid rgba(107, 114, 128, 0.15);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-icon {
|
|
||||||
font-size: 2rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-name {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
.achieve-desc {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
.primary-button {
|
|
||||||
display: flex;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
|
|
||||||
height: fit-content;
|
|
||||||
background-color: var(--color-red-light);
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-white) !important;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
transition: transform 0.1s ease-in-out;
|
|
||||||
|
|
||||||
border: none;
|
|
||||||
&:hover {
|
|
||||||
transform: scale(0.95);
|
|
||||||
background-color: var(--color-red-light);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary-button {
|
|
||||||
display: flex;
|
|
||||||
padding: 1rem 1rem;
|
|
||||||
background-color: var(--color-white);
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
justify-content: center;
|
|
||||||
width: fit-content;
|
|
||||||
height: fit-content;
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-grey)!important;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: transform 0.1s ease-in-out;
|
|
||||||
border: none;
|
|
||||||
&:hover {
|
|
||||||
transform: scale(0.95);
|
|
||||||
background-color: var(--color-grey-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
.footer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
align-items: center;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
border-top: solid 1px var(--color-grey);
|
|
||||||
padding: 2rem 0;
|
|
||||||
gap: 2rem;
|
|
||||||
|
|
||||||
|
|
||||||
.footer-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 90%;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-logo {
|
|
||||||
background-image: url(/svg/tadpole.svg);
|
|
||||||
background-size: contain;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
width: 250px;
|
|
||||||
height: 100px;
|
|
||||||
transition: all 0.15s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.4rem;
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
text-decoration-line: underline;
|
|
||||||
text-underline-offset: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-text {
|
|
||||||
max-width: 26ch;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
}
|
|
||||||
.section-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
gap: 1rem;
|
|
||||||
list-style: none;
|
|
||||||
|
|
||||||
.section-item,
|
|
||||||
a {
|
|
||||||
width: fit-content;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
transition: all 0.15s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.spacing {
|
|
||||||
min-width: 150px;
|
|
||||||
width: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-github {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-grey);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.15s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.copyright {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--color-grey);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
// game-panels.scss — Système de style partagé pour tous les panels du cockpit
|
|
||||||
// Modifier les tokens dans root.scss, pas ici.
|
|
||||||
|
|
||||||
// --- Panel de base ---
|
|
||||||
.gp {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--gp-gap);
|
|
||||||
padding: var(--gp-padding);
|
|
||||||
background: var(--gp-bg);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid var(--gp-border);
|
|
||||||
border-radius: var(--gp-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-title {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--gp-title);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--gp-text-color);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-label {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--gp-text-sm);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--gp-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-value {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--gp-text);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gp-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-accent-green { color: var(--gp-accent-green); }
|
|
||||||
.gp-accent-purple { color: var(--gp-accent-purple); }
|
|
||||||
.gp-accent-amber { color: var(--gp-accent-amber); }
|
|
||||||
|
|
||||||
// --- Row item (générateur, noeud évolution) ---
|
|
||||||
.gp-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.4rem 0.5rem;
|
|
||||||
border-radius: calc(var(--gp-radius) - 0.15rem);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-row--active {
|
|
||||||
border-color: rgba(16, 185, 129, 0.3);
|
|
||||||
background: var(--gp-accent-green-bg);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.18);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-row--locked {
|
|
||||||
border-color: var(--gp-border);
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-row--evolution {
|
|
||||||
border-color: rgba(251, 191, 36, 0.3);
|
|
||||||
background: var(--gp-accent-amber-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-row--unlocked {
|
|
||||||
border-color: rgba(16, 185, 129, 0.3);
|
|
||||||
background: var(--gp-accent-green-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bouton achat ---
|
|
||||||
.gp-btn {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--gp-text-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-btn--buy {
|
|
||||||
background: var(--gp-btn-bg);
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--gp-btn-bg-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-btn--disabled {
|
|
||||||
background: var(--gp-btn-disabled);
|
|
||||||
color: var(--gp-btn-text-disabled);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-btn--prestige {
|
|
||||||
background: #7c3aed;
|
|
||||||
color: white;
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
font-size: var(--gp-text);
|
|
||||||
animation: gp-pulse 2s ease-in-out infinite;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #8b5cf6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gp-pulse {
|
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.4); }
|
|
||||||
50% { box-shadow: 0 0 0 6px rgba(124, 58, 237, 0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Header cockpit (stats résumé) ---
|
|
||||||
.gp-cockpit-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 0.2rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Progress bar ---
|
|
||||||
.gp-progress {
|
|
||||||
height: 0.35rem;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gp-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 1rem;
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Section separator ---
|
|
||||||
.gp-sep {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--gp-border);
|
|
||||||
margin: 0.15rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Zone titles in sidebar ---
|
|
||||||
.gp-zone-label {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--gp-text-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gp-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
padding-left: 0.2rem;
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
.header-main {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 80px;
|
|
||||||
padding: 0rem 2rem;
|
|
||||||
top: 0;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
background-blend-mode: darken;
|
|
||||||
background-size: cover;
|
|
||||||
z-index: 99;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 999px) {
|
|
||||||
padding: 0rem 0.4rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
width: 5rem;
|
|
||||||
content: url(/svg/tadpole.svg);
|
|
||||||
transition: 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
width: 5rem;
|
|
||||||
transition: 0.2s;
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 1rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.nav-list {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.6rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
@media screen and (max-width: 999px) {
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
list-style: none;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-white);
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.mainLink {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-grey);
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 30px 0;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-red-light);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropLink {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-white);
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-red-light);
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style-type: none;
|
|
||||||
@media screen and (max-width: 999px) {
|
|
||||||
color: var(--color-white) !important;
|
|
||||||
}
|
|
||||||
li {
|
|
||||||
float: left;
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.dropdown-content {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
background: var(--color-black);
|
|
||||||
transform: translateY(30px);
|
|
||||||
min-width: 160px;
|
|
||||||
box-shadow: 0px 8px 16px 0px rgba(10, 10, 10, 0.2);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.dropdown {
|
|
||||||
display: inline-block;
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--color-white);
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
a:hover,
|
|
||||||
.dropdown:hover {
|
|
||||||
color: var(--color-red-light);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.dropdown-content a {
|
|
||||||
color: var(--color-black);
|
|
||||||
padding: 12px 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-black);
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown:hover .dropdown-content {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1000px) {
|
|
||||||
.menuToggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 999px) {
|
|
||||||
.menuToggle {
|
|
||||||
float: left;
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
top: 2px;
|
|
||||||
left: -10px;
|
|
||||||
z-index: 99;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.menuToggle a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-grey);
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
.menuToggle a:hover {
|
|
||||||
color: var(--color-red-light);
|
|
||||||
}
|
|
||||||
.menuToggle input {
|
|
||||||
display: block;
|
|
||||||
width: 40px;
|
|
||||||
height: 32px;
|
|
||||||
position: absolute;
|
|
||||||
top: -7px;
|
|
||||||
left: -5px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
z-index: 2;
|
|
||||||
-webkit-touch-callout: none;
|
|
||||||
}
|
|
||||||
.menuToggle span {
|
|
||||||
display: block;
|
|
||||||
width: 33px;
|
|
||||||
height: 4px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
position: relative;
|
|
||||||
background: var(--color-grey);
|
|
||||||
border-radius: 3px;
|
|
||||||
z-index: 1;
|
|
||||||
transform-origin: 4px 0px;
|
|
||||||
transition: transform 0.2s cubic-bezier(0.77, 0.2, 0.05, 1),
|
|
||||||
background 0.2s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease;
|
|
||||||
}
|
|
||||||
.menuToggle span:first-child {
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
}
|
|
||||||
.menuToggle span:nth-last-child(2) {
|
|
||||||
transform-origin: 0% 100%;
|
|
||||||
}
|
|
||||||
.menuToggle input:checked ~ span {
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(45deg) translate(-2px, -1px);
|
|
||||||
background: var(--color-white);
|
|
||||||
}
|
|
||||||
.menuToggle input:checked ~ span:nth-last-child(3) {
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(0deg) scale(0.2, 0.2);
|
|
||||||
}
|
|
||||||
.menuToggle input:checked ~ span:nth-last-child(2) {
|
|
||||||
transform: rotate(-45deg) translate(0, -1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 280px;
|
|
||||||
height: 110vh;
|
|
||||||
margin: -100px 0 0 -231px;
|
|
||||||
padding: 1.2rem;
|
|
||||||
padding-top: 100px;
|
|
||||||
background: var(--color-grey);
|
|
||||||
list-style-type: none;
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
overflow: hidden !important;
|
|
||||||
visibility: hidden;
|
|
||||||
opacity: 0%;
|
|
||||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
.menu li {
|
|
||||||
|
|
||||||
padding: 10px 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-white);
|
|
||||||
|
|
||||||
}
|
|
||||||
.menuToggle input:checked ~ ul {
|
|
||||||
visibility: visible;
|
|
||||||
opacity: 100;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
.sousmenu {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-left: 1.2rem;
|
|
||||||
color: var(--color-white);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-weight: 500;
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
|
|
||||||
}
|
|
||||||
.empty {
|
|
||||||
line-height: 20rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.auth-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
font-family: var(--font);
|
|
||||||
|
|
||||||
.auth-nickname {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-grey);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-btn {
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
border: 1px solid var(--color-grey);
|
|
||||||
border-radius: 0.4rem;
|
|
||||||
background: none;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-grey);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-grey);
|
|
||||||
color: var(--color-white);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
// home.scss — Game view styles (layout géré par zones.scss)
|
|
||||||
|
|
||||||
// --- Clicker zone ---
|
|
||||||
|
|
||||||
.click-zone {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 1rem;
|
|
||||||
padding-bottom: 2vh;
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
// Desktop: center
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
padding-right: 22rem; // offset for sidebar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tadpole-sprite {
|
|
||||||
width: 280px;
|
|
||||||
height: 280px;
|
|
||||||
background: url("/svg/tadpole.svg") no-repeat center / contain;
|
|
||||||
transition: transform 0.1s ease;
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
width: 320px;
|
|
||||||
height: 320px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-zone:active & {
|
|
||||||
transform: scale(0.95) rotate(2deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Compteur ressources sous le têtard ---
|
|
||||||
|
|
||||||
.click-zone-counter {
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: white;
|
|
||||||
text-shadow: 0 0 12px rgba(52, 211, 153, 0.5), 0 2px 6px rgba(0, 0, 0, 0.7);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Badge achievements sidebar ---
|
|
||||||
|
|
||||||
.achieve-badge {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.4rem;
|
|
||||||
border-radius: var(--gp-radius);
|
|
||||||
background: var(--gp-accent-green-bg);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: var(--gp-text-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--gp-accent-green);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(16, 185, 129, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Click feedback particle ---
|
|
||||||
|
|
||||||
.click-particle {
|
|
||||||
position: fixed;
|
|
||||||
pointer-events: none;
|
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #34d399;
|
|
||||||
text-shadow: 0 0 8px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.7);
|
|
||||||
z-index: 100;
|
|
||||||
animation: float-up 1.2s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float-up {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1.2);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-80px) scale(1.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Game sidebar ---
|
|
||||||
|
|
||||||
.game-sidebar {
|
|
||||||
position: fixed;
|
|
||||||
right: 0.75rem;
|
|
||||||
top: 5.5rem;
|
|
||||||
bottom: 0.75rem;
|
|
||||||
width: 20rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 10;
|
|
||||||
padding-right: 0.25rem;
|
|
||||||
|
|
||||||
// Mobile: bottom drawer
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
position: fixed;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
top: auto;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 45vh;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-top-left-radius: 1rem;
|
|
||||||
border-top-right-radius: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-width: 132ch;
|
|
||||||
width: 80%;
|
|
||||||
gap: 3rem;
|
|
||||||
margin: 150px auto 50px;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-black);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
text-align: center;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.separator {
|
|
||||||
border: solid 1px var(--color-gold-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.massageinfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
|
|
||||||
.info {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-grey);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-black);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: left;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-black);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: left;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.paragraphe {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-grey);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 400;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
list-style: inside;
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-gold-link);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
list-style: inside;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-gold-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.picture-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
@media (max-width: 449px) {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picture {
|
|
||||||
background-position: center;
|
|
||||||
background-size: cover;
|
|
||||||
width: 100%;
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Massages pages
|
|
||||||
|
|
||||||
.listing-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 3rem;
|
|
||||||
|
|
||||||
.listcontent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.listdetail {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-grey);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 400;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
list-style-type: none;
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//error pages
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 90vh;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.containererror {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-black);
|
|
||||||
font-size: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-grey);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 300;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-return {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
width: fit-content;
|
|
||||||
margin: auto;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: var(--color-grey);
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.6rem;
|
|
||||||
font-family: var(--font);
|
|
||||||
color: var(--color-white);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-grey);
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-blue-light: #dcecf3;
|
|
||||||
--color-purple-light: #e4e3f3;
|
|
||||||
--color-red-light: #c33636;
|
|
||||||
--color-white: #ffffff;
|
|
||||||
--color-light: #eaeaea;
|
|
||||||
--color-grey: #202020;
|
|
||||||
--color-grey-hover: #606060;
|
|
||||||
|
|
||||||
--bg-color: var(--color-blue-light);
|
|
||||||
|
|
||||||
--font: "Hanken Grotesk", sans-serif;
|
|
||||||
|
|
||||||
// --- Game panel tokens ---
|
|
||||||
--gp-bg: rgba(17, 17, 17, 0.75);
|
|
||||||
--gp-bg-hover: rgba(17, 17, 17, 0.85);
|
|
||||||
--gp-border: rgba(255, 255, 255, 0.08);
|
|
||||||
--gp-radius: 0.75rem;
|
|
||||||
--gp-padding: 0.75rem;
|
|
||||||
--gp-gap: 0.5rem;
|
|
||||||
|
|
||||||
// Text
|
|
||||||
--gp-title: 0.8rem;
|
|
||||||
--gp-text: 0.75rem;
|
|
||||||
--gp-text-sm: 0.65rem;
|
|
||||||
--gp-text-color: rgba(255, 255, 255, 0.9);
|
|
||||||
--gp-text-muted: rgba(255, 255, 255, 0.5);
|
|
||||||
|
|
||||||
// Accent colors
|
|
||||||
--gp-accent-green: #34d399;
|
|
||||||
--gp-accent-purple: #a78bfa;
|
|
||||||
--gp-accent-amber: #fbbf24;
|
|
||||||
--gp-accent-green-bg: rgba(16, 185, 129, 0.12);
|
|
||||||
--gp-accent-purple-bg: rgba(139, 92, 246, 0.12);
|
|
||||||
--gp-accent-amber-bg: rgba(251, 191, 36, 0.12);
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
--gp-btn-bg: #059669;
|
|
||||||
--gp-btn-bg-hover: #10b981;
|
|
||||||
--gp-btn-disabled: rgba(255, 255, 255, 0.08);
|
|
||||||
--gp-btn-text-disabled: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
min-height: 92vh;
|
|
||||||
margin-top: 80px;
|
|
||||||
padding: 0 0 2rem;
|
|
||||||
background-color: var(--bg-color);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// zones.scss — Système de zones visuelles
|
|
||||||
// Chaque page déclare sa zone via data-zone sur le wrapper.
|
|
||||||
// Le background, les tons, l'ambiance changent — navbar/footer restent fixes.
|
|
||||||
//
|
|
||||||
// Usage : <div className="zone" data-zone="swamp"> ... </div>
|
|
||||||
// Ajouter un nouveau biome : juste un nouveau [data-zone="xxx"] ici.
|
|
||||||
|
|
||||||
.zone {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 92vh;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: center;
|
|
||||||
background-size: cover;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
transition: background-image 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Biomes ---
|
|
||||||
|
|
||||||
[data-zone="swamp"] {
|
|
||||||
background-image: url("/webp/bg-cover.webp");
|
|
||||||
background-position: center 70%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-zone="landing"] {
|
|
||||||
background: var(--bg-color);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-zone="page"] {
|
|
||||||
background: var(--bg-color);
|
|
||||||
align-items: flex-start;
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Futur :
|
|
||||||
// [data-zone="cave"] { background-image: url("/webp/bg-cave.webp"); }
|
|
||||||
// [data-zone="volcano"] { background-image: url("/webp/bg-volcano.webp"); }
|
|
||||||
// [data-zone="ocean"] { background-image: url("/webp/bg-ocean.webp"); }
|
|
||||||
@@ -8,24 +8,44 @@ import {
|
|||||||
DEFAULT_STATE,
|
DEFAULT_STATE,
|
||||||
applyIdleGains,
|
applyIdleGains,
|
||||||
applyClick,
|
applyClick,
|
||||||
|
getClickGain,
|
||||||
|
getAutoClicksPerSecond,
|
||||||
buyGenerator,
|
buyGenerator,
|
||||||
buyEvolutionNode,
|
buyEvolutionNode,
|
||||||
|
resetEvolutionTree,
|
||||||
|
canResetTree,
|
||||||
|
upgradeConvergence,
|
||||||
|
canUpgradeConvergence,
|
||||||
|
claimMilestone as claimMilestoneFn,
|
||||||
applyPrestige,
|
applyPrestige,
|
||||||
canPrestige as canPrestigeCheck,
|
canPrestige as canPrestigeCheck,
|
||||||
totalProductionPerSecond,
|
totalProductionPerSecond,
|
||||||
generatorCost as genCost,
|
generatorCost as genCost,
|
||||||
|
computeOfflineGains,
|
||||||
|
offlineEfficiency,
|
||||||
} from "../core/economy";
|
} from "../core/economy";
|
||||||
|
import { migrateSave } from "../core/migrateSave";
|
||||||
|
import {
|
||||||
|
computeNewUnlocks,
|
||||||
|
equipCosmetic as equipCosmeticFn,
|
||||||
|
unequipSlot as unequipSlotFn,
|
||||||
|
addToInventory,
|
||||||
|
DEFAULT_COSMETIC_STATE,
|
||||||
|
type CosmeticSlot,
|
||||||
|
} from "../core/cosmetics";
|
||||||
|
|
||||||
const SAVE_KEY = "clickerz_state";
|
const SAVE_KEY = "clickerz_state";
|
||||||
|
const OFFLINE_THRESHOLD = 60_000; // 60s — same as economy.ts
|
||||||
|
|
||||||
function loadLocalState(): GameState {
|
function loadLocalState(): GameState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SAVE_KEY);
|
const raw = localStorage.getItem(SAVE_KEY);
|
||||||
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now() };
|
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||||
const saved = JSON.parse(raw) as GameState;
|
const parsed = JSON.parse(raw);
|
||||||
|
const saved = migrateSave(parsed);
|
||||||
return applyIdleGains(saved, Date.now());
|
return applyIdleGains(saved, Date.now());
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_STATE, lastTick: Date.now() };
|
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,11 +53,32 @@ function saveLocal(state: GameState): void {
|
|||||||
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
|
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OfflineReport {
|
||||||
|
wasOffline: boolean;
|
||||||
|
duration: number; // ms
|
||||||
|
gains: number;
|
||||||
|
efficiency: number; // 0-1 average
|
||||||
|
}
|
||||||
|
|
||||||
interface GameStore {
|
interface GameStore {
|
||||||
// State
|
// State
|
||||||
state: GameState;
|
state: GameState;
|
||||||
playSeconds: number;
|
playSeconds: number;
|
||||||
ready: boolean; // true once the authoritative state is loaded
|
ready: boolean;
|
||||||
|
|
||||||
|
// Offline report (shown once after load)
|
||||||
|
offlineReport: OfflineReport | null;
|
||||||
|
dismissOfflineReport: () => void;
|
||||||
|
|
||||||
|
// Prestige screen (modal fullscreen)
|
||||||
|
showPrestigeScreen: boolean;
|
||||||
|
openPrestigeScreen: () => void;
|
||||||
|
closePrestigeScreen: () => void;
|
||||||
|
|
||||||
|
// Last click result (for particle feedback)
|
||||||
|
lastClickGain: number;
|
||||||
|
lastClickDouble: boolean;
|
||||||
|
lastClickCrit: boolean;
|
||||||
|
|
||||||
// Derived (recalculated on tick)
|
// Derived (recalculated on tick)
|
||||||
canPrestige: boolean;
|
canPrestige: boolean;
|
||||||
@@ -49,26 +90,99 @@ interface GameStore {
|
|||||||
buy: (genId: string) => void;
|
buy: (genId: string) => void;
|
||||||
buyNode: (nodeId: string) => void;
|
buyNode: (nodeId: string) => void;
|
||||||
prestige: () => void;
|
prestige: () => void;
|
||||||
|
resetTree: () => void;
|
||||||
|
upgradeConvergenceNode: () => void;
|
||||||
|
claimMilestone: (milestoneId: string) => void;
|
||||||
|
equipCosmetic: (cosmeticId: string) => void;
|
||||||
|
unequipCosmetic: (slot: CosmeticSlot) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
loadFromServer: (serverState: GameState) => void;
|
loadFromServer: (serverState: GameState) => void;
|
||||||
initGuest: () => void; // fallback when no server save (guest mode)
|
initGuest: () => void;
|
||||||
generatorCost: typeof genCost;
|
generatorCost: typeof genCost;
|
||||||
|
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with DEFAULT_STATE — game is NOT ready until loadFromServer or initGuest
|
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
|
||||||
const initialState = { ...DEFAULT_STATE, lastTick: Date.now() };
|
// migrateSave handles backfill — no manual patching needed here
|
||||||
|
const elapsed = now - saved.lastTick;
|
||||||
|
|
||||||
|
if (elapsed <= OFFLINE_THRESHOLD) {
|
||||||
|
// Normal idle — no offline report
|
||||||
|
const hydrated = applyIdleGains(saved, now);
|
||||||
|
return {
|
||||||
|
state: { ...hydrated, lastOnline: now },
|
||||||
|
report: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline — use degraded curve
|
||||||
|
const gains = computeOfflineGains(saved, now);
|
||||||
|
const pps = totalProductionPerSecond(saved);
|
||||||
|
const fullGains = pps * (elapsed / 1000);
|
||||||
|
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
|
||||||
|
|
||||||
|
const hydrated: GameState = {
|
||||||
|
...saved,
|
||||||
|
resources: saved.resources + gains,
|
||||||
|
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
|
||||||
|
lastTick: now,
|
||||||
|
lastOnline: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: hydrated,
|
||||||
|
report: {
|
||||||
|
wasOffline: true,
|
||||||
|
duration: elapsed,
|
||||||
|
gains,
|
||||||
|
efficiency: avgEfficiency,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||||
|
|
||||||
export const useGameStore = create<GameStore>((set, get) => ({
|
export const useGameStore = create<GameStore>((set, get) => ({
|
||||||
state: initialState,
|
state: initialState,
|
||||||
playSeconds: 0,
|
playSeconds: 0,
|
||||||
ready: false,
|
ready: false,
|
||||||
|
offlineReport: null,
|
||||||
|
showPrestigeScreen: false,
|
||||||
|
openPrestigeScreen: () => set({ showPrestigeScreen: true }),
|
||||||
|
closePrestigeScreen: () => set({ showPrestigeScreen: false }),
|
||||||
|
lastClickGain: 0,
|
||||||
|
lastClickDouble: false,
|
||||||
|
lastClickCrit: false,
|
||||||
canPrestige: false,
|
canPrestige: false,
|
||||||
productionPerSecond: 0,
|
productionPerSecond: 0,
|
||||||
|
|
||||||
|
dismissOfflineReport: () => set({ offlineReport: null }),
|
||||||
|
|
||||||
tick: () => {
|
tick: () => {
|
||||||
if (!get().ready) return;
|
if (!get().ready) return;
|
||||||
|
const now = Date.now();
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const updated = applyIdleGains(s.state, Date.now());
|
const updated = applyIdleGains(s.state, now);
|
||||||
|
updated.lastOnline = now;
|
||||||
|
|
||||||
|
// Auto-click from evolution tree
|
||||||
|
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
|
||||||
|
if (autoClicks > 0) {
|
||||||
|
const autoGain = getClickGain(updated) * autoClicks;
|
||||||
|
updated.resources += autoGain;
|
||||||
|
updated.lifetimeTadpoles += autoGain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cosmetic unlocks every 5s
|
||||||
|
if (s.playSeconds % 5 === 0) {
|
||||||
|
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
|
||||||
|
const newUnlocks = computeNewUnlocks(updated, cosState);
|
||||||
|
if (newUnlocks.length > 0) {
|
||||||
|
const newCos = addToInventory(cosState, newUnlocks);
|
||||||
|
updated.cosmeticInventory = newCos.inventory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveLocal(updated);
|
saveLocal(updated);
|
||||||
return {
|
return {
|
||||||
state: updated,
|
state: updated,
|
||||||
@@ -82,12 +196,15 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
click: () => {
|
click: () => {
|
||||||
if (!get().ready) return;
|
if (!get().ready) return;
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const updated = applyClick(applyIdleGains(s.state, Date.now()));
|
const result = applyClick(applyIdleGains(s.state, Date.now()));
|
||||||
saveLocal(updated);
|
saveLocal(result.state);
|
||||||
return {
|
return {
|
||||||
state: updated,
|
state: result.state,
|
||||||
canPrestige: canPrestigeCheck(updated),
|
lastClickGain: result.gain,
|
||||||
productionPerSecond: totalProductionPerSecond(updated),
|
lastClickDouble: result.isDouble,
|
||||||
|
lastClickCrit: result.isCrit,
|
||||||
|
canPrestige: canPrestigeCheck(result.state),
|
||||||
|
productionPerSecond: totalProductionPerSecond(result.state),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -129,44 +246,108 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
|||||||
state: updated,
|
state: updated,
|
||||||
canPrestige: canPrestigeCheck(updated),
|
canPrestige: canPrestigeCheck(updated),
|
||||||
productionPerSecond: totalProductionPerSecond(updated),
|
productionPerSecond: totalProductionPerSecond(updated),
|
||||||
|
showPrestigeScreen: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
equipCosmetic: (cosmeticId: string) => {
|
||||||
|
if (!get().ready) return;
|
||||||
|
set((s) => {
|
||||||
|
const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped };
|
||||||
|
const updated = equipCosmeticFn(cosState, cosmeticId);
|
||||||
|
const newState = { ...s.state, cosmeticEquipped: updated.equipped };
|
||||||
|
saveLocal(newState);
|
||||||
|
return { state: newState };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
unequipCosmetic: (slot: CosmeticSlot) => {
|
||||||
|
if (!get().ready) return;
|
||||||
|
set((s) => {
|
||||||
|
const cosState = { inventory: s.state.cosmeticInventory, equipped: s.state.cosmeticEquipped };
|
||||||
|
const updated = unequipSlotFn(cosState, slot);
|
||||||
|
const newState = { ...s.state, cosmeticEquipped: updated.equipped };
|
||||||
|
saveLocal(newState);
|
||||||
|
return { state: newState };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetTree: () => {
|
||||||
|
if (!get().ready) return;
|
||||||
|
set((s) => {
|
||||||
|
if (!canResetTree(s.state)) return s;
|
||||||
|
const updated = resetEvolutionTree(s.state);
|
||||||
|
saveLocal(updated);
|
||||||
|
return {
|
||||||
|
state: updated,
|
||||||
|
productionPerSecond: totalProductionPerSecond(updated),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
upgradeConvergenceNode: () => {
|
||||||
|
if (!get().ready) return;
|
||||||
|
set((s) => {
|
||||||
|
const updated = upgradeConvergence(s.state);
|
||||||
|
if (!updated) return s;
|
||||||
|
saveLocal(updated);
|
||||||
|
return {
|
||||||
|
state: updated,
|
||||||
|
productionPerSecond: totalProductionPerSecond(updated),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
claimMilestone: (milestoneId: string) => {
|
||||||
|
if (!get().ready) return;
|
||||||
|
set((s) => {
|
||||||
|
const updated = claimMilestoneFn(s.state, milestoneId);
|
||||||
|
if (!updated) return s;
|
||||||
|
saveLocal(updated);
|
||||||
|
return { state: updated };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
|
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||||
saveLocal(fresh);
|
saveLocal(fresh);
|
||||||
set({
|
set({
|
||||||
state: fresh,
|
state: fresh,
|
||||||
playSeconds: 0,
|
playSeconds: 0,
|
||||||
ready: true,
|
ready: true,
|
||||||
|
offlineReport: null,
|
||||||
canPrestige: false,
|
canPrestige: false,
|
||||||
productionPerSecond: 0,
|
productionPerSecond: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Server save loaded — this IS the authority
|
|
||||||
loadFromServer: (serverState: GameState) => {
|
loadFromServer: (serverState: GameState) => {
|
||||||
const hydrated = applyIdleGains(serverState, Date.now());
|
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
|
||||||
saveLocal(hydrated); // mirror to localStorage as cache
|
const { state: hydrated, report } = hydrateWithOffline(migrated, Date.now());
|
||||||
|
saveLocal(hydrated);
|
||||||
set({
|
set({
|
||||||
state: hydrated,
|
state: hydrated,
|
||||||
ready: true,
|
ready: true,
|
||||||
|
offlineReport: report,
|
||||||
canPrestige: canPrestigeCheck(hydrated),
|
canPrestige: canPrestigeCheck(hydrated),
|
||||||
productionPerSecond: totalProductionPerSecond(hydrated),
|
productionPerSecond: totalProductionPerSecond(hydrated),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Guest mode — no server save, use localStorage or fresh state
|
|
||||||
initGuest: () => {
|
initGuest: () => {
|
||||||
const local = loadLocalState();
|
const local = loadLocalState();
|
||||||
|
const { state: hydrated, report } = hydrateWithOffline(local, Date.now());
|
||||||
|
saveLocal(hydrated);
|
||||||
set({
|
set({
|
||||||
state: local,
|
state: hydrated,
|
||||||
ready: true,
|
ready: true,
|
||||||
canPrestige: canPrestigeCheck(local),
|
offlineReport: report,
|
||||||
productionPerSecond: totalProductionPerSecond(local),
|
canPrestige: canPrestigeCheck(hydrated),
|
||||||
|
productionPerSecond: totalProductionPerSecond(hydrated),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
generatorCost: genCost,
|
generatorCost: genCost,
|
||||||
|
generatorCostWithTree: (gen) => genCost(gen, get().state.evolutionTree),
|
||||||
}));
|
}));
|
||||||
|
|||||||
62
docs/GDD.md
@@ -165,18 +165,63 @@ Sprint 1 — linéaire (5 nœuds). Sprint 2+ → branches.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Hors scope Sprint 1
|
## Sprint 2 — Offline, Branches & Cosmétiques
|
||||||
|
|
||||||
|
Brief technique : `docs/SPRINT2.md`
|
||||||
|
|
||||||
|
| Feature | Design |
|
||||||
|
|---------|--------|
|
||||||
|
| Offline gains | Courbe inversée 100%→0% sur 2h, cap 25% prod idle, écran résumé au retour |
|
||||||
|
| Arbre 3 voies | Ponte (click) / Marais (production) / Adaptation (utility), ~15 nœuds, reset gratuit |
|
||||||
|
| Cosmétiques V1 | 5 slots SVG overlay, récompenses achievements + prestige tiers, inventaire |
|
||||||
|
|
||||||
|
## Hors scope Sprint 2
|
||||||
|
|
||||||
- Boucle 3 (méta, events, leaderboard)
|
- Boucle 3 (méta, events, leaderboard)
|
||||||
- Branches arbre d'évolution (linéaire suffit)
|
- Monétisation effective (boutique cosmétique payante)
|
||||||
- Cosmétiques / skins
|
|
||||||
- Monétisation effective
|
|
||||||
- Sound / musique
|
- Sound / musique
|
||||||
- Mobile responsive (desktop first)
|
- Mobile responsive (session dédiée)
|
||||||
- Offline gains calculés côté serveur (Sprint 2)
|
- Migration Express → Fastify
|
||||||
- Migration Express → Fastify (si besoin Sprint 3+)
|
|
||||||
- Intégration Twitch
|
- Intégration Twitch
|
||||||
- Multijoueur
|
- Multijoueur
|
||||||
|
- Coût reset arbre (gratuit Sprint 2, payant plus tard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sprint 3 — Prestige Loop (endless)
|
||||||
|
|
||||||
|
Brief technique : `docs/SPRINT3.md`
|
||||||
|
|
||||||
|
| Feature | Design |
|
||||||
|
|---------|--------|
|
||||||
|
| Migration saves | Pattern `saveVersion` + `migrateSave()` — backward compat Sprint 2, lazy au chargement |
|
||||||
|
| Prestige Experience | Écran redesigné (preview ADN, stats run, comparaison), animation reset, hooks audio-ready |
|
||||||
|
| Arbre V2 endless | ~30 nœuds (3 branches approfondies), capstones game-changers, post-capstone repeatable (scaling par tranche ×1.5/×1.8/×2.0), cross-branche |
|
||||||
|
| Milestones prestige | 8 paliers (1→100 prestiges), cosmétiques exclusifs + bonus gameplay légers |
|
||||||
|
| Reset arbre | 1 gratuit par prestige, payant linéaire au-delà (5 ADN × n). Build exportable (fondation Sprint 4 sharing) |
|
||||||
|
| Formule ADN rebalancée | `max(1, floor(50 × log10(t/1e6) × (1 + min(0.05×p, 3.0))))` — clamp + cap bonus ×4 |
|
||||||
|
|
||||||
|
### Capstones par branche
|
||||||
|
|
||||||
|
| Branche | Capstone | Effet |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| Ponte (click) | **Ponte Automatique** | Auto-click 1/sec, scale avec upgrades ponte |
|
||||||
|
| Marais (production) | **Symbiose Totale** | Chaque générateur booste les autres (+2% par type possédé) |
|
||||||
|
| Adaptation (utility) | **Mémoire du Marais** | Offline cap 25%→75%, durée 2h→8h |
|
||||||
|
|
||||||
|
### Profils joueurs émergents
|
||||||
|
|
||||||
|
- **Joueur Ponte** : joue activement, optimise les clics, capstone = auto-click idle
|
||||||
|
- **Joueur Marais** : optimise les achats générateurs, capstone = boucle multiplicative
|
||||||
|
- **Joueur Adaptation** : joue casual 2-3×/jour, capstone = idle puissant
|
||||||
|
|
||||||
|
## Hors scope Sprint 3
|
||||||
|
|
||||||
|
- Boucle 3 (méta, events, leaderboard, cross-promo TetaRdPG)
|
||||||
|
- Son / musique (prévu Sprint 4+ — hooks audio posés dans prestige)
|
||||||
|
- Mobile responsive / client natif Godot (projet séparé envisagé)
|
||||||
|
- Monétisation effective (boutique cosmétique payante)
|
||||||
|
- Analytics joueur (event log backend — Sprint 4)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,3 +231,6 @@ Sprint 1 — linéaire (5 nœuds). Sprint 2+ → branches.
|
|||||||
|------|------------|
|
|------|------------|
|
||||||
| 2026-03-17 | GDD initial — sprint1-step1, stack React+TS+Vite, mécaniques core |
|
| 2026-03-17 | GDD initial — sprint1-step1, stack React+TS+Vite, mécaniques core |
|
||||||
| 2026-03-20 | Refonte game-designer — Tetard Universe, Arbre d'Évolution, anti-triche backend, SuperOAuth, stack confirmée Express |
|
| 2026-03-20 | Refonte game-designer — Tetard Universe, Arbre d'Évolution, anti-triche backend, SuperOAuth, stack confirmée Express |
|
||||||
|
| 2026-03-28 | Sprint 1 livré (6/6). Sprint 2 briefé — offline gains courbe inversée, arbre 3 voies, cosmétiques récompenses |
|
||||||
|
| 2026-03-28 | Sprint 2 livré (3/3). Sprint 3 briefé — prestige loop endless, arbre V2 30 nœuds, capstones, milestones, formule ADN rebalancée |
|
||||||
|
| 2026-03-28 | Sprint 3 brainstorm — 5 décisions : saveVersion migration, formule ADN clamp+cap, scaling post-capstone par tranche, reset 1 gratuit/prestige + vision build-sharing, Convergence évolutif (Alpha→Omega, nœud unique à tiers) |
|
||||||
|
|||||||
143
docs/SPRINT2.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# SPRINT2.md — Offline, Branches & Cosmétiques
|
||||||
|
|
||||||
|
> Brief technique — Sprint 2
|
||||||
|
> Date : 2026-03-28
|
||||||
|
> Réf GDD : docs/GDD.md
|
||||||
|
> Dépend : Sprint 1 livré (6/6)
|
||||||
|
> Agents : game-designer (design) → build (implémentation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Trois features qui transforment Clickerz d'un prototype jouable en un jeu avec rétention :
|
||||||
|
- Offline gains avec courbe inversée (incite à jouer actif)
|
||||||
|
- Arbre d'Évolution 3 voies (builds, choix, rejouabilité)
|
||||||
|
- Cosmétiques par récompense (feedback visuel, motivation prestige)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 1 — Offline gains (courbe inversée)
|
||||||
|
|
||||||
|
**Scope :** Calculer les gains hors-ligne avec une courbe dégressive pour inciter le jeu actif.
|
||||||
|
|
||||||
|
**Design :**
|
||||||
|
- Courbe inversée : efficacité 100% → 0% sur 2h d'absence
|
||||||
|
- 0-15min : 100% de la prod idle
|
||||||
|
- 15min-1h : dégression linéaire 100% → 25%
|
||||||
|
- 1h-2h : dégression 25% → 0%
|
||||||
|
- >2h : cap — plus rien (le marais dort)
|
||||||
|
- Prod offline plafonnée à 25% de la prod idle moyenne
|
||||||
|
- Au retour du joueur : écran "Pendant ton absence..." avec résumé des gains
|
||||||
|
|
||||||
|
**Technique :**
|
||||||
|
- `applyIdleGains()` existe déjà — ajouter un facteur `offlineEfficiency(elapsedMs)`
|
||||||
|
- `offlineEfficiency(ms)` : retourne le multiplicateur (1.0 → 0.0) basé sur la courbe
|
||||||
|
- Backend : valider le gain offline au `GET /api/save` (même pattern anti-triche)
|
||||||
|
- Frontend : composant `OfflineReport` affiché si `elapsed > 60s` au chargement
|
||||||
|
- GameState : ajouter `lastOnline: number` (timestamp dernière activité réelle)
|
||||||
|
|
||||||
|
**Critère done :** fermer le jeu, revenir 30min après → écran résumé + gains corrects. Revenir 3h après → même résultat qu'après 2h (cap).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Arbre d'Évolution 3 voies
|
||||||
|
|
||||||
|
**Scope :** Transformer l'arbre linéaire en arbre à 3 branches avec choix de build.
|
||||||
|
|
||||||
|
**Design :**
|
||||||
|
- 3 voies depuis la racine :
|
||||||
|
- **Ponte** (click) — multiplicateurs click, click critique, auto-click
|
||||||
|
- **Marais** (production) — multiplicateurs idle, générateurs bonus, production globale
|
||||||
|
- **Adaptation** (utility) — start bonus, offline boost, prestige ADN bonus, cost reduction
|
||||||
|
- Chaque voie : 4-5 nœuds progressifs (coût ADN croissant)
|
||||||
|
- Le joueur peut investir dans plusieurs voies — pas de lock exclusif
|
||||||
|
- Choix intra-nœud : certains nœuds proposent 2 options mutuellement exclusives (pick one)
|
||||||
|
- Reset : gratuit pour l'instant (bouton "Réinitialiser l'Arbre" — rembourse tout l'ADN dépensé)
|
||||||
|
- Futur : coût ADN croissant par reset (1er gratuit, 2e = 10 ADN, etc.)
|
||||||
|
|
||||||
|
**Technique :**
|
||||||
|
- Refactorer `EvolutionNode` :
|
||||||
|
```ts
|
||||||
|
interface EvolutionNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cost: number;
|
||||||
|
effect: EffectType;
|
||||||
|
value: number;
|
||||||
|
unlocked: boolean;
|
||||||
|
requires: string | null; // nœud parent
|
||||||
|
branch: "ponte" | "marais" | "adaptation";
|
||||||
|
exclusive_with?: string; // id du nœud alternatif (pick one)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `DEFAULT_EVOLUTION_TREE` : ~15 nœuds (5 par voie)
|
||||||
|
- `resetEvolutionTree(state)` : rembourse tout l'ADN, relock tous les nœuds
|
||||||
|
- UI : `EvolutionTree.tsx` → layout 3 colonnes (une par voie), header avec couleur
|
||||||
|
- Ponte = vert, Marais = bleu, Adaptation = ambre
|
||||||
|
- Animer les connexions entre nœuds (lignes SVG ou borders)
|
||||||
|
|
||||||
|
**Critère done :** le joueur voit 3 voies distinctes, peut investir ADN dans chaque, constater les effets, et reset gratuitement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Cosmétiques V1 (récompenses)
|
||||||
|
|
||||||
|
**Scope :** Système d'équipement cosmétique sur le sprite têtard, gagné par achievements et prestige.
|
||||||
|
|
||||||
|
**Design :**
|
||||||
|
- 5 slots : hat, eyes, body, tail, accessory (déjà dans tadpole.svg V5)
|
||||||
|
- Sources de cosmétiques :
|
||||||
|
- **Achievements** : débloquer un cosmétique quand un achievement est complété
|
||||||
|
- **Prestige tiers** : skin spécial à 5, 10, 25, 50 prestiges
|
||||||
|
- Inventaire : liste des cosmétiques possédés, équipés (1 par slot max)
|
||||||
|
- Rendu : stack SVG — base (tadpole.svg) + overlays par slot équipé
|
||||||
|
- Pas de boutique / pas de currency cosmétique — uniquement des récompenses
|
||||||
|
|
||||||
|
**Technique :**
|
||||||
|
- GameState : ajouter `cosmetics: { inventory: string[], equipped: Record<Slot, string | null> }`
|
||||||
|
- Définir les cosmétiques :
|
||||||
|
```ts
|
||||||
|
interface Cosmetic {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slot: "hat" | "eyes" | "body" | "tail" | "accessory";
|
||||||
|
svg: string; // chemin vers le SVG overlay
|
||||||
|
source: "achievement" | "prestige";
|
||||||
|
sourceId: string; // achievement id ou "prestige_10"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 8-10 cosmétiques initiaux (2 par slot) :
|
||||||
|
- hat : couronne (prestige 10), casquette marais (achievement 10 succès)
|
||||||
|
- eyes : lunettes savant (prestige 5), masque grenouille (achievement 1M têtards)
|
||||||
|
- body : cape algues (prestige 25), armure écailles (achievement tous générateurs)
|
||||||
|
- tail : flamme (prestige 50), ruban (achievement premier prestige)
|
||||||
|
- accessory : aura-swamp (existe déjà), particules dorées (achievement 5 prestiges)
|
||||||
|
- UI : page `/cosmetics` ou section dans Settings — grille inventaire + preview sprite
|
||||||
|
- `TadpoleSprite.tsx` : composant qui empile base SVG + overlays équipés
|
||||||
|
|
||||||
|
**Critère done :** débloquer un achievement → cosmétique dans l'inventaire → équiper → voir le changement sur le sprite en jeu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé séquentiel
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 (offline gains) → Step 2 (arbre 3 voies) → Step 3 (cosmétiques)
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 1 = rétention. Step 2 = profondeur. Step 3 = récompense visuelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risques identifiés
|
||||||
|
|
||||||
|
| Risque | Mitigation |
|
||||||
|
|--------|------------|
|
||||||
|
| Courbe offline trop punitive | Playtester, ajuster les paliers. Logs pour traquer le % de gains offline |
|
||||||
|
| Arbre déséquilibré (une voie OP) | 3 voies testables indépendamment. Reset gratuit = le joueur corrige |
|
||||||
|
| Nœuds exclusifs confus UX | Tooltip clair "Choisis l'un des deux", preview de l'effet avant achat |
|
||||||
|
| SVG overlay perf (mobile) | Lazy load des SVG, max 5 overlays simultanés (1 par slot) |
|
||||||
|
| Reset arbre exploitable | Gratuit = pas de cheese possible. Le coût viendra plus tard |
|
||||||
315
docs/SPRINT3.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# SPRINT3.md — Prestige Loop
|
||||||
|
|
||||||
|
> Brief technique — Sprint 3
|
||||||
|
> Date : 2026-03-28
|
||||||
|
> Réf GDD : docs/GDD.md
|
||||||
|
> Dépend : Sprint 2 livré (3/3)
|
||||||
|
> Agents : game-designer (design) → build (implémentation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Transformer le prestige d'une mécanique de reset en une **boucle de progression motivante et endless**. Le joueur doit sentir l'accélération à chaque génération, faire des choix de build durables, et viser des paliers qui récompensent la spécialisation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pré-requis technique — Migration saves
|
||||||
|
|
||||||
|
> À traiter en amont de Step 1 — pas un step visible, mais bloquant.
|
||||||
|
|
||||||
|
### Pattern `saveVersion` + `migrateSave()` (décision session 2026-03-28)
|
||||||
|
|
||||||
|
Le GameState est stocké en JSON unique dans MySQL. Ajouter/modifier des champs
|
||||||
|
sans migration = `undefined` silencieux → NaN → bugs fantômes.
|
||||||
|
|
||||||
|
**Mécanisme :**
|
||||||
|
- Ajouter `saveVersion: number` au `GameState`
|
||||||
|
- Saves Sprint 2 existantes = version absente → traitées comme `v1`
|
||||||
|
- Sprint 3 = `v2`
|
||||||
|
- Fonction `migrateSave(state: unknown): GameState` appliquée au chargement
|
||||||
|
(frontend `useSaveSync` + backend `saveControllers`)
|
||||||
|
- `v1 → v2` : injecter defaults (`runStats` vide, `treeResetCount: 0`,
|
||||||
|
`freeResetAvailable: true`, `repeatableNodes: {}`, nouveaux nœuds arbre)
|
||||||
|
- Les 18 nœuds Sprint 2 conservent leur `id` — les nouveaux s'ajoutent
|
||||||
|
- Champ critique manquant → log warning + default safe (jamais de crash)
|
||||||
|
- Chaque sprint futur ajoute un step de migration (`v2 → v3`, etc.)
|
||||||
|
|
||||||
|
### Schéma DB
|
||||||
|
|
||||||
|
- `ALTER TABLE game_saves ADD COLUMN save_version INT DEFAULT 1`
|
||||||
|
- Pas de migration du JSON existant en DB — la migration est lazy (au chargement)
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- Charger une save Sprint 2 brute sur le nouveau code → `migrateSave` injecte v2
|
||||||
|
- Pas de perte de données, pas de reset forcé
|
||||||
|
- Les nœuds arbre existants (18) gardent leur position et état unlocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### Step 1 — Prestige Experience
|
||||||
|
|
||||||
|
**Scope :** Rendre le moment du prestige satisfaisant et informatif.
|
||||||
|
|
||||||
|
**Design :**
|
||||||
|
- Écran de prestige redesigné :
|
||||||
|
- Preview ADN gagné (avant de confirmer)
|
||||||
|
- Comparaison : "Run actuelle vs meilleure run" (durée, têtards, ADN)
|
||||||
|
- Compteur de générations (nombre total de prestiges)
|
||||||
|
- Statistiques de run persistées :
|
||||||
|
- Durée de la run
|
||||||
|
- Têtards produits (lifetime cette run)
|
||||||
|
- Vitesse de progression vs run précédente (% plus rapide / plus lent)
|
||||||
|
- Branche d'arbre principale utilisée
|
||||||
|
- Animation de reset : transition visuelle (le marais "renaît")
|
||||||
|
- Hooks audio-ready : prévoir les points d'ancrage pour le son (Sprint futur)
|
||||||
|
|
||||||
|
**Technique :**
|
||||||
|
- `PrestigeScreen.tsx` : composant modal fullscreen (pas un simple bouton)
|
||||||
|
- `RunStats` dans le GameState :
|
||||||
|
```ts
|
||||||
|
interface RunStats {
|
||||||
|
startedAt: number;
|
||||||
|
tadpolesProduced: number;
|
||||||
|
prestigeCount: number;
|
||||||
|
bestRun: {
|
||||||
|
duration: number;
|
||||||
|
tadpoles: number;
|
||||||
|
adn: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Backend : persister `runStats` dans la save (même pattern save serveur)
|
||||||
|
- Rebalancer la formule prestige pour l'endless (voir section Formules)
|
||||||
|
|
||||||
|
**Critère done :** le joueur clique Prestige → voit un écran avec preview ADN + stats comparées → confirme → animation → nouvelle run. Les stats de la meilleure run sont persistées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2 — Arbre d'Évolution V2 — Profondeur endless
|
||||||
|
|
||||||
|
**Scope :** Étendre l'arbre à ~30 nœuds avec capstones game-changers et scaling post-capstone infini.
|
||||||
|
|
||||||
|
**Design — Structure par branche :**
|
||||||
|
|
||||||
|
**Branche Ponte (click) — 8-10 nœuds :**
|
||||||
|
- Tier 1 : Ponte Améliorée (+100% click) — 1 ADN
|
||||||
|
- Tier 2 : Click Critique (5% chance ×10) — 5 ADN
|
||||||
|
- Tier 3 : Frénésie (click power +1% par click dans les 10 dernières sec, cap +50%) — 15 ADN
|
||||||
|
- Tier 3 alt (exclusif) : Concentration (+200% click, -25% idle) — 15 ADN
|
||||||
|
- Capstone : **Ponte Automatique** — auto-click 1/sec, scale avec upgrades ponte — 200 ADN
|
||||||
|
- Post-capstone (repeatable) : +5% auto-click speed, coût ×2 — départ 500 ADN
|
||||||
|
|
||||||
|
**Branche Marais (production) — 8-10 nœuds :**
|
||||||
|
- Tier 1 : Instinct Grégaire (+50% production tous générateurs) — 3 ADN
|
||||||
|
- Tier 2 : Spécialisation (un générateur au choix ×3) — 8 ADN
|
||||||
|
- Tier 3 : Écosystème (+10% prod par type de générateur possédé) — 25 ADN
|
||||||
|
- Tier 3 alt (exclusif) : Monoculture (un seul type ×5, les autres ×0.5) — 25 ADN
|
||||||
|
- Capstone : **Symbiose Totale** — chaque générateur booste les autres (+2% par type possédé) — 300 ADN
|
||||||
|
- Post-capstone (repeatable) : +1% symbiose, coût ×2 — départ 600 ADN
|
||||||
|
|
||||||
|
**Branche Adaptation (utility) — 8-10 nœuds :**
|
||||||
|
- Tier 1 : Mémoire Génétique (commence chaque run avec 100 têtards) — 2 ADN
|
||||||
|
- Tier 2 : Métabolisme Rapide (+25% offline cap) — 10 ADN
|
||||||
|
- Tier 3 : Héritage (conserve 5% des générateurs tier 1 entre prestiges) — 30 ADN
|
||||||
|
- Tier 3 alt (exclusif) : Mutation ADN (+25% ADN gagné au prestige) — 30 ADN
|
||||||
|
- Capstone : **Mémoire du Marais** — offline cap 25% → 75%, durée 2h → 8h — 250 ADN
|
||||||
|
- Post-capstone (repeatable) : +2% offline cap (au-delà de 75%), coût ×2 — départ 500 ADN
|
||||||
|
|
||||||
|
**Nœud cross-branche — Convergence (évolutif, décision session 2026-03-28) :**
|
||||||
|
|
||||||
|
Un seul nœud qui évolue quand le joueur atteint de nouveaux paliers de diversification.
|
||||||
|
Pattern `tier` sur le nœud — fondation pour d'autres nœuds évolutifs futurs.
|
||||||
|
|
||||||
|
```
|
||||||
|
Convergence Alpha (tier 1)
|
||||||
|
Condition : 1 capstone + tier 3 d'une 2e branche (~prestige 12-15)
|
||||||
|
Coût : 500 ADN
|
||||||
|
Effet : +10% à tous les effets de l'arbre
|
||||||
|
|
||||||
|
Convergence Omega (tier 2 — même nœud, upgrade auto)
|
||||||
|
Condition : 2 capstones atteintes
|
||||||
|
Coût : 500 ADN supplémentaires (total investi : 1000)
|
||||||
|
Effet : +10% tous effets + -20% coût post-capstones
|
||||||
|
```
|
||||||
|
|
||||||
|
Technique — champ `tier` sur `EvolutionNode` :
|
||||||
|
```ts
|
||||||
|
tier?: number; // 1 = Alpha, 2 = Omega (current level)
|
||||||
|
maxTier?: number; // 2
|
||||||
|
tierUpgradeCost?: number; // 500
|
||||||
|
tierUpgradeCondition?: string; // "2_capstones"
|
||||||
|
```
|
||||||
|
|
||||||
|
UX : notification "Convergence a évolué !" quand la condition tier 2 est remplie.
|
||||||
|
Le joueur voit sa récompense grandir — pas un 2e nœud à acheter séparément.
|
||||||
|
|
||||||
|
**Technique :**
|
||||||
|
- `DEFAULT_EVOLUTION_TREE` : ~30 nœuds (tree data structure, pas array)
|
||||||
|
- Ajouter `tier: number` et `repeatable: boolean` aux `EvolutionNode`
|
||||||
|
- `repeatableCount: number` dans le state pour les nœuds post-capstone
|
||||||
|
- UI : garder le layout 3 colonnes, ajouter scroll vertical par branche
|
||||||
|
- Capstones visuellement distincts (bordure dorée, icône spéciale)
|
||||||
|
- Cross-branche : section basse, verrouillée visuellement jusqu'aux conditions
|
||||||
|
- Convergence : badge évolutif (Alpha → Omega), indicateur de progression vers le prochain tier
|
||||||
|
|
||||||
|
**Reset arbre — 1 gratuit par prestige (décision session 2026-03-28) :**
|
||||||
|
|
||||||
|
Chaque prestige offre 1 reset gratuit (nouvelle génération = nouvelle chance de build).
|
||||||
|
Resets supplémentaires dans la même génération = payants.
|
||||||
|
|
||||||
|
```
|
||||||
|
freeResetAvailable: boolean // true après chaque prestige, false après usage
|
||||||
|
resetCostInGeneration: number = 5 × extraResetsUsed // linéaire, pas exponentiel
|
||||||
|
```
|
||||||
|
|
||||||
|
| Reset# (dans la même génération) | Coût ADN |
|
||||||
|
|----------------------------------|----------|
|
||||||
|
| 1er | Gratuit (offert par prestige) |
|
||||||
|
| 2e | 5 |
|
||||||
|
| 3e | 10 |
|
||||||
|
| 4e | 15 |
|
||||||
|
|
||||||
|
Pas punitif, encourage l'expérimentation, mais décourage le spam.
|
||||||
|
|
||||||
|
**Fondation build-sharing (Sprint 4+ vision) :**
|
||||||
|
Le build de l'arbre est exportable en string compacte (nœuds unlocked encodés).
|
||||||
|
Permet le partage de builds entre joueurs et les "runs prestige" communautaires.
|
||||||
|
→ Structurer l'arbre state pour faciliter l'export dès Sprint 3 :
|
||||||
|
`buildCode: string` = nœuds unlocked encodés base36 ou similaire.
|
||||||
|
|
||||||
|
**Critère done :** l'arbre affiche ~30 nœuds sur 3 branches + cross-branche, les capstones changent le gameplay de manière perceptible, les post-capstones sont achetables en boucle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3 — Milestones de Prestige
|
||||||
|
|
||||||
|
**Scope :** Récompenser la persévérance avec des paliers de prestige qui débloquent cosmétiques exclusifs et bonus légers.
|
||||||
|
|
||||||
|
**Design :**
|
||||||
|
|
||||||
|
| Palier | Récompense | Type |
|
||||||
|
|--------|------------|------|
|
||||||
|
| 1 prestige | Badge "Première Génération" + ruban queue | cosmétique |
|
||||||
|
| 3 prestiges | Titre "Gardien Récurrent" | cosmétique |
|
||||||
|
| 5 prestiges | Start avec 1 Nid gratuit | gameplay léger |
|
||||||
|
| 10 prestiges | Skin "Têtard Ancestral" (body prestige) + couronne dorée | cosmétique |
|
||||||
|
| 15 prestiges | +5% offline cap permanent | gameplay léger |
|
||||||
|
| 25 prestiges | Cape d'algues ancestrales + aura prestige | cosmétique |
|
||||||
|
| 50 prestiges | Titre "Légende du Marais" + particules dorées permanentes | cosmétique |
|
||||||
|
| 100 prestiges | Skin "Têtard Primordial" (full set) | cosmétique |
|
||||||
|
|
||||||
|
- Les bonus gameplay sont **légers** — jamais assez pour casser l'économie
|
||||||
|
- Les cosmétiques prestige-only ne sont pas obtenables autrement (exclusivité = motivation)
|
||||||
|
- Écran milestones accessible depuis le menu prestige (progress bar vers le prochain palier)
|
||||||
|
|
||||||
|
**Technique :**
|
||||||
|
- `PrestigeMilestone` type :
|
||||||
|
```ts
|
||||||
|
interface PrestigeMilestone {
|
||||||
|
id: string;
|
||||||
|
threshold: number; // nombre de prestiges requis
|
||||||
|
reward: MilestoneReward;
|
||||||
|
claimed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MilestoneReward =
|
||||||
|
| { type: "cosmetic"; cosmeticId: string }
|
||||||
|
| { type: "bonus"; effect: EffectType; value: number }
|
||||||
|
| { type: "title"; title: string };
|
||||||
|
```
|
||||||
|
- Intégrer avec le système cosmétiques V1 existant — ajouter `source: "prestige_milestone"` aux cosmétiques
|
||||||
|
- UI : `MilestonesPanel.tsx` — liste verticale avec progress bar, claim button, preview reward
|
||||||
|
- Backend : valider les claims (le joueur ne peut pas claim un milestone sans le nombre de prestiges)
|
||||||
|
|
||||||
|
**Critère done :** atteindre 5 prestiges → notification milestone → claim → Nid gratuit actif + cosmétique dans l'inventaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formules — Rebalancing endless (décisions session 2026-03-28)
|
||||||
|
|
||||||
|
### Formule ADN (remplace l'actuelle)
|
||||||
|
|
||||||
|
Actuelle : `adn = floor(150 × sqrt(lifetime_tadpoles / 1e9))` — s'aplatit trop vite.
|
||||||
|
|
||||||
|
Nouvelle :
|
||||||
|
```
|
||||||
|
adn = max(1, floor(base × log10(tadpoles / threshold) × (1 + bonus)))
|
||||||
|
|
||||||
|
base = 50
|
||||||
|
threshold = 1e6 (1M têtards — seuil minimum pour prestige)
|
||||||
|
bonus = min(0.05 × prestigeCount, 3.0) // cap ×4 max à 80 prestiges
|
||||||
|
clamp = minimum 1 ADN si tadpoles >= threshold
|
||||||
|
```
|
||||||
|
|
||||||
|
| Run | Tadpoles | Prestige# | Bonus | ADN gagné |
|
||||||
|
|-----|----------|-----------|-------|-----------|
|
||||||
|
| 1 | 1M (seuil) | 0 | ×1.0 | **1** (clamp) |
|
||||||
|
| 1 | 10M | 0 | ×1.0 | **50** |
|
||||||
|
| 5 | 100M | 4 | ×1.20 | **120** |
|
||||||
|
| 15 | 1B | 14 | ×1.70 | **255** |
|
||||||
|
| 30 | 10B | 29 | ×2.45 | **430** |
|
||||||
|
| 80+ | 100B | 80 | ×4.00 | **800** (cap bonus atteint) |
|
||||||
|
|
||||||
|
La courbe log scale bien : récompense toujours plus, oblige à aller exponentiellement plus loin.
|
||||||
|
Le cap du bonus évite que l'endgame trivialise l'arbre.
|
||||||
|
|
||||||
|
### Courbe coût post-capstones — paliers par tranche
|
||||||
|
|
||||||
|
Le ×2 brut crée un mur après ~8 achats. Scaling par tranche :
|
||||||
|
|
||||||
|
```
|
||||||
|
Achats 1-5 : cost = base × 1.5^n
|
||||||
|
Achats 6-10 : cost = base × 1.5^5 × 1.8^(n-5)
|
||||||
|
Achats 11+ : cost = base × 1.5^5 × 1.8^5 × 2.0^(n-10)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Achat# | Coût (base 500) | ~Runs nécessaires (400 ADN/run) |
|
||||||
|
|--------|-----------------|--------------------------------|
|
||||||
|
| 1 | 750 | ~2 |
|
||||||
|
| 5 | 3 797 | ~10 |
|
||||||
|
| 10 | 68 890 | ~172 |
|
||||||
|
| 15 | 2.2M | endgame long |
|
||||||
|
|
||||||
|
Chaque achat reste "quelques sessions" en mid-game, puis ralentit en endgame
|
||||||
|
sans frapper un mur infranchissable.
|
||||||
|
|
||||||
|
### Courbe coût nœuds standards
|
||||||
|
|
||||||
|
Définis manuellement par nœud (1, 3, 5, 8, 10, 15, 25, 30, 200, 250, 300, 500, 1000 ADN).
|
||||||
|
|
||||||
|
### Vérification d'équilibre
|
||||||
|
|
||||||
|
- Premier capstone : entre le 8e et 12e prestige (spécialisation une branche)
|
||||||
|
- Arbre "complet" (hors post-capstone) : ~15-20 prestiges
|
||||||
|
- Post-capstones : progression infinie, chaque achat = investissement plus lourd mais gain marginal
|
||||||
|
- Cross-branche : joueur dédié à 25+ prestiges
|
||||||
|
|
||||||
|
**Niveau de confiance : moyen** — les chiffres sont calibrés sur les formules mais devront être playtestés. Toutes les constantes centralisées dans `balance.ts` pour ajustement rapide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé séquentiel
|
||||||
|
|
||||||
|
```
|
||||||
|
Migration saves → Step 1 (prestige experience) → Step 2 (arbre V2 endless) → Step 3 (milestones)
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 1 = le moment. Step 2 = la profondeur. Step 3 = la motivation long terme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risques identifiés
|
||||||
|
|
||||||
|
| Risque | Mitigation | Statut |
|
||||||
|
|--------|------------|--------|
|
||||||
|
| Formule ADN mal calibrée | Constantes dans `balance.ts`. Clamp min 1 + cap bonus ×4. Playtest après Step 1 | ✅ adressé |
|
||||||
|
| Capstones trop puissants (Ponte Auto trivialise) | Tester chaque capstone isolément. Auto-click = 1/sec de base | à tester |
|
||||||
|
| Post-capstone wall en late-game | Scaling par tranche (×1.5/×1.8/×2.0) au lieu de ×2 brut | ✅ adressé |
|
||||||
|
| Migration saves casse les joueurs existants | Pattern `saveVersion` + `migrateSave()` lazy. Saves Sprint 2 = v1, migrées automatiquement | ✅ adressé |
|
||||||
|
| Reset arbre frustrant | 1 gratuit par prestige + linéaire au-delà. Pas d'exponentiel | ✅ adressé |
|
||||||
|
| Arbre V2 complexe visuellement | Progressive disclosure : griser les nœuds non débloquables, tooltip clair | à tester |
|
||||||
|
| Build-sharing string : encodage fragile | Valider le décodage côté client. Nœud inconnu dans le code = ignoré (forward compat) | Sprint 4 |
|
||||||