Compare commits
32 Commits
8cc9fdaa62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c9ed1187 | |||
| 7a8f4f325c | |||
| f9dd4c3ca4 | |||
| 45b89ebae1 | |||
| 9caa6691fe | |||
| c549ec259c | |||
| 7c651ded4e | |||
| f4bc25b3b1 | |||
| 25768e3665 | |||
| 120f4bedca | |||
| 38e63fdf22 | |||
| 9d27cb6648 | |||
| 39921aa8fc | |||
| 1488962537 | |||
| 67931eeadb | |||
| cce7fa3190 | |||
| 10ff2d32f5 | |||
| ce38975c10 | |||
| f6bff6e389 | |||
| 3de0492631 | |||
| a665fdf2f4 | |||
| 450d559216 | |||
| 1ca88df3ed | |||
| 4df6451dac | |||
| ed8cf87d4e | |||
| f80f071c24 | |||
| 2c924c1e4a | |||
| 2a242e97cc | |||
| ae50908bc9 | |||
| 3ba10dad5f | |||
| 90761b3e13 | |||
| b58d39e707 |
@@ -42,16 +42,26 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: Frontend
|
||||
run: npx vitest run
|
||||
|
||||
- name: Deploy frontend
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
mkdir -p /var/www/clickerz/frontend/dist
|
||||
rsync -a --delete Frontend/dist/ /var/www/clickerz/frontend/dist/
|
||||
echo "✅ Frontend Svelte deployed"
|
||||
|
||||
# ── Smoke test ───────────────────────────────────────────────────────────
|
||||
- name: Smoke test API
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
sleep 3
|
||||
curl -sf http://localhost:3520/api/auth/me 2>&1 | grep -q '401\|session\|Not authenticated'
|
||||
echo "✅ API responds OK"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3520/api/auth/me)
|
||||
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ API responds OK (HTTP $HTTP_CODE)"
|
||||
else
|
||||
echo "❌ API unreachable (HTTP $HTTP_CODE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
4
Backend/database/migrations/003_save_version.sql
Normal file
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,
|
||||
user_id INT NOT NULL UNIQUE,
|
||||
game_state JSON NOT NULL,
|
||||
save_version INT DEFAULT 1,
|
||||
last_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
lifetime_tadpoles BIGINT DEFAULT 0,
|
||||
prestige_count INT DEFAULT 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const tables = require("../tables");
|
||||
const { migrateSave } = require("../services/migrateSave");
|
||||
|
||||
// --- 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
|
||||
const gameState =
|
||||
const rawState =
|
||||
typeof save.game_state === "string"
|
||||
? JSON.parse(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({
|
||||
gameState,
|
||||
lastSave: save.last_save,
|
||||
|
||||
@@ -15,18 +15,20 @@ class GameSaveManager extends AbstractManager {
|
||||
|
||||
async upsert(userId, gameState, metadata) {
|
||||
const { lifetimeTadpoles, prestigeCount, playTimeSeconds } = metadata;
|
||||
const saveVersion = gameState.saveVersion ?? 1;
|
||||
const gameStateJson = JSON.stringify(gameState);
|
||||
|
||||
const [result] = await this.database.query(
|
||||
`INSERT INTO ${this.table} (user_id, game_state, lifetime_tadpoles, prestige_count, play_time_seconds)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`INSERT INTO ${this.table} (user_id, game_state, save_version, lifetime_tadpoles, prestige_count, play_time_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
game_state = VALUES(game_state),
|
||||
save_version = VALUES(save_version),
|
||||
lifetime_tadpoles = VALUES(lifetime_tadpoles),
|
||||
prestige_count = VALUES(prestige_count),
|
||||
play_time_seconds = VALUES(play_time_seconds),
|
||||
last_save = CURRENT_TIMESTAMP`,
|
||||
[userId, gameStateJson, lifetimeTadpoles, prestigeCount, playTimeSeconds]
|
||||
[userId, gameStateJson, saveVersion, lifetimeTadpoles, prestigeCount, playTimeSeconds]
|
||||
);
|
||||
|
||||
return result.affectedRows;
|
||||
|
||||
65
Backend/src/services/migrateSave.js
Normal file
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 };
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
43
Frontend/.gitignore
vendored
Executable file → Normal file
43
Frontend/.gitignore
vendored
Executable file → Normal file
@@ -1,25 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
/dist
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
1
Frontend/.npmrc
Normal file
1
Frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
1
Frontend/.nvmrc
Normal file
1
Frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
3
Frontend/.vscode/extensions.json
vendored
Normal file
3
Frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
43
Frontend/README.md
Executable file → Normal file
43
Frontend/README.md
Executable file → Normal file
@@ -1,11 +1,42 @@
|
||||
# Clickerz — Tetard Universe
|
||||
# sv
|
||||
|
||||
Idle clicker game. Fais éclore des têtards, construis ton empire et domine le marais.
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
### Stack
|
||||
## Creating a project
|
||||
|
||||
React · Vite · SCSS · Tailwind · Zustand
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
### Credits
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
Développé par [Kevin T](https://github.com/tetardtek) · Powered by Brain
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.13.0 create --template minimal --types ts --no-install Frontend-svelte
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="./svg/tadpole.svg"
|
||||
/>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta
|
||||
name="googlebot"
|
||||
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
||||
/>
|
||||
<meta
|
||||
name="bingbot"
|
||||
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
||||
/>
|
||||
<link rel="canonical" href="https://clickerz.tetardtek.com" />
|
||||
<meta property="og:url" content="https://clickerz.tetardtek.com" />
|
||||
<meta property="og:site_name" content="Clickerz" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Clickerz — Tetard Universe" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
<meta property="og:image:width" content="584" />
|
||||
<meta property="og:image:height" content="384" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Clickerz — Tetard Universe" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9020
Frontend/package-lock.json
generated
Executable file → Normal file
9020
Frontend/package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
59
Frontend/package.json
Executable file → Normal file
59
Frontend/package.json
Executable file → Normal file
@@ -1,36 +1,27 @@
|
||||
{
|
||||
"name": "template",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"react-router-dom": "^6.19.0",
|
||||
"sass": "^1.69.5",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
"name": "clickerz-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import Navbar from "./components/navbar";
|
||||
import Footer from "./components/footer";
|
||||
import { GameTick } from "./components/GameTick";
|
||||
import { GameSync } from "./components/GameSync";
|
||||
|
||||
import "./scss/root.scss";
|
||||
import "./scss/zones.scss";
|
||||
import "./scss/components/footer.scss";
|
||||
|
||||
import navData from "./data/NavBarData.json";
|
||||
|
||||
function App() {
|
||||
const [toggleRain, setToggleRain] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameTick />
|
||||
<GameSync />
|
||||
<Navbar
|
||||
navData={navData}
|
||||
toggleRain={toggleRain}
|
||||
setToggleRain={setToggleRain}
|
||||
/>
|
||||
<main>
|
||||
<Outlet context={[toggleRain, setToggleRain]} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
53
Frontend/src/__tests__/balance.test.ts
Normal file
53
Frontend/src/__tests__/balance.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { postCapstoneCost, treeResetCost } from "../lib/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
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 "../lib/core/cosmetics";
|
||||
import { DEFAULT_STATE } from "../lib/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,16 +7,23 @@ import {
|
||||
buyGenerator,
|
||||
applyPrestige,
|
||||
canPrestige,
|
||||
getPrestigeThreshold,
|
||||
computePrestigeDna,
|
||||
canBuyEvolutionNode,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
getClickMultiplierFromTree,
|
||||
getProductionMultiplierFromTree,
|
||||
getStartBonusFromTree,
|
||||
getPrestigeDnaBonus,
|
||||
getCostReduction,
|
||||
getAutoClicksPerSecond,
|
||||
offlineEfficiency,
|
||||
computeOfflineGains,
|
||||
DEFAULT_STATE,
|
||||
DEFAULT_GENERATORS,
|
||||
DEFAULT_EVOLUTION_TREE,
|
||||
} from "../core/economy";
|
||||
} from "../lib/core/economy";
|
||||
|
||||
// --- PrestigePanel visibility ---
|
||||
|
||||
@@ -167,13 +174,15 @@ describe("computeIdleGains (lazy calculation)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Click ---
|
||||
// --- Click (avec double + crit) ---
|
||||
|
||||
describe("applyClick", () => {
|
||||
it("augmente les ressources du clickMultiplier × prestigeMultiplier", () => {
|
||||
const state = { ...DEFAULT_STATE, clickMultiplier: 3, prestigeMultiplier: 2 };
|
||||
const result = applyClick(state);
|
||||
expect(result.resources).toBe(6);
|
||||
const result = applyClick(state, 0.99); // rng high → no double, no crit
|
||||
expect(result.state.resources).toBe(6);
|
||||
expect(result.isDouble).toBe(false);
|
||||
expect(result.isCrit).toBe(false);
|
||||
});
|
||||
|
||||
it("applique le multiplicateur click de l'arbre", () => {
|
||||
@@ -185,14 +194,58 @@ describe("applyClick", () => {
|
||||
n.id === "ponte_amelioree" ? { ...n, unlocked: true } : n
|
||||
),
|
||||
};
|
||||
const result = applyClick(state);
|
||||
expect(result.resources).toBe(2); // ×2 depuis Ponte Améliorée
|
||||
const result = applyClick(state, 0.99);
|
||||
expect(result.state.resources).toBe(2);
|
||||
});
|
||||
|
||||
it("incrémente lifetimeTadpoles", () => {
|
||||
const state = { ...DEFAULT_STATE, clickMultiplier: 5, prestigeMultiplier: 1 };
|
||||
const result = applyClick(state);
|
||||
expect(result.lifetimeTadpoles).toBe(5);
|
||||
const result = applyClick(state, 0.99);
|
||||
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);
|
||||
});
|
||||
|
||||
it("retourne 150 pour 1e9 têtards (sqrt(1) = 1)", () => {
|
||||
expect(computePrestigeDna(1e9)).toBe(150);
|
||||
it("retourne 0 sous le seuil de 1M", () => {
|
||||
expect(computePrestigeDna(999_999)).toBe(0);
|
||||
});
|
||||
|
||||
it("retourne 212 pour 2e9 têtards (sqrt(2) ≈ 1.414)", () => {
|
||||
expect(computePrestigeDna(2e9)).toBe(Math.floor(150 * Math.sqrt(2)));
|
||||
it("retourne 1 (clamp) à exactement 1M têtards", () => {
|
||||
expect(computePrestigeDna(1e6)).toBe(1);
|
||||
});
|
||||
|
||||
it("scaling sub-linéaire — 10× têtards ≠ 10× ADN", () => {
|
||||
const dna1 = computePrestigeDna(1e9);
|
||||
const dna10 = computePrestigeDna(10e9);
|
||||
expect(dna10 / dna1).toBeCloseTo(Math.sqrt(10), 1);
|
||||
it("retourne 50 pour 10M têtards (log10(10) = 1)", () => {
|
||||
expect(computePrestigeDna(10e6)).toBe(50);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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 };
|
||||
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", () => {
|
||||
const state = { ...DEFAULT_STATE, ancestralDna: 0 };
|
||||
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);
|
||||
expect(canBuyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBe(false);
|
||||
});
|
||||
|
||||
it("ne peut pas acheter un nœud dont le prérequis n'est pas débloqué", () => {
|
||||
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é", () => {
|
||||
@@ -299,7 +371,32 @@ describe("Evolution Tree", () => {
|
||||
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", () => {
|
||||
const result = buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree");
|
||||
expect(result).toBeNull();
|
||||
expect(buyEvolutionNode(DEFAULT_STATE, "ponte_amelioree")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetEvolutionTree", () => {
|
||||
it("rembourse tout l'ADN dépensé et relock tous les nœuds", () => {
|
||||
const state = {
|
||||
...DEFAULT_STATE,
|
||||
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 modifie pas les autres nœuds", () => {
|
||||
const state = { ...DEFAULT_STATE, ancestralDna: 5 };
|
||||
const result = buyEvolutionNode(state, "ponte_amelioree")!;
|
||||
const otherNodes = result.evolutionTree.filter((n) => n.id !== "ponte_amelioree");
|
||||
expect(otherNodes.every((n) => n.unlocked === false)).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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -363,4 +484,168 @@ describe("Evolution Tree", () => {
|
||||
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
123
Frontend/src/__tests__/migrateSave.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { migrateSave } from "../lib/core/migrateSave";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../lib/core/economy";
|
||||
import { CURRENT_SAVE_VERSION } from "../lib/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
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 "../lib/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// Ported from backend saveControllers.validateGameState for unit testing
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DEFAULT_STATE } from "../core/economy";
|
||||
import { DEFAULT_STATE } from "../lib/core/economy";
|
||||
|
||||
// Reproduce the validation logic client-side for testing
|
||||
const MAX_PRODUCTION_PER_SECOND = 750_000;
|
||||
|
||||
616
Frontend/src/app.css
Normal file
616
Frontend/src/app.css
Normal file
@@ -0,0 +1,616 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* -- Tailwind v4 theme -- tokens du jeu -- */
|
||||
@theme {
|
||||
--color-blue-light: #dcecf3;
|
||||
--color-purple-light: #e4e3f3;
|
||||
--color-red-light: #c33636;
|
||||
--color-light: #eaeaea;
|
||||
--color-grey: #202020;
|
||||
--color-grey-hover: #606060;
|
||||
|
||||
--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);
|
||||
|
||||
--radius-gp: 0.75rem;
|
||||
--spacing-gp: 0.75rem;
|
||||
--spacing-gp-gap: 0.5rem;
|
||||
|
||||
--font-size-gp-title: 0.8rem;
|
||||
--font-size-gp-text: 0.75rem;
|
||||
--font-size-gp-sm: 0.65rem;
|
||||
|
||||
--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;
|
||||
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; }
|
||||
|
||||
/* a11y — focus-visible ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-gp-accent-green);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Skip keyboard focus ring on mouse clicks */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
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); }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.gp-sep {
|
||||
height: 1px;
|
||||
background: var(--color-gp-border);
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.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 slide-in {
|
||||
from { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
@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);
|
||||
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;
|
||||
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); }
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- 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-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); }
|
||||
.copyright {
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 300;
|
||||
color: var(--color-grey);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Pages layout -- */
|
||||
@layer components {
|
||||
.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;
|
||||
}
|
||||
.container h2 {
|
||||
font-family: var(--font);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-grey);
|
||||
}
|
||||
|
||||
/* -- 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;
|
||||
}
|
||||
.achievementscardcontainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 200px;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
13
Frontend/src/app.d.ts
vendored
Normal file
13
Frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
15
Frontend/src/app.html
Normal file
15
Frontend/src/app.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/svg/tadpole.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,39 +0,0 @@
|
||||
// CockpitHeader.tsx — Dashboard résumé toujours visible en haut du cockpit
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
import { getClickGain } from "../core/economy";
|
||||
|
||||
export function CockpitHeader() {
|
||||
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
||||
const state = useGameStore((s) => s.state);
|
||||
const { prestigeMultiplier, ancestralDna, prestigeCount } = state;
|
||||
const clickGain = getClickGain(state);
|
||||
|
||||
return (
|
||||
<div className="gp gp-cockpit-header">
|
||||
<div className="gp-stat" title="Têtards générés automatiquement chaque seconde">
|
||||
<span className="gp-label">Prod/s</span>
|
||||
<span className="gp-value gp-accent-green">
|
||||
{formatNumber(productionPerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="Têtards gagnés à chaque clic sur le têtard">
|
||||
<span className="gp-label">/clic</span>
|
||||
<span className="gp-value">{formatNumber(clickGain)}</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="Multiplicateur global — augmente avec le prestige">
|
||||
<span className="gp-label">Mult</span>
|
||||
<span className="gp-value">x{prestigeMultiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="ADN Ancestral — monnaie de l'Arbre d'Évolution (gagné au prestige)">
|
||||
<span className="gp-label">ADN</span>
|
||||
<span className="gp-value gp-accent-purple">{ancestralDna}</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="Nombre de prestiges effectués (Nouvelles Générations)">
|
||||
<span className="gp-label">Gén.</span>
|
||||
<span className="gp-value">{prestigeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// EvolutionTree.tsx — Arbre d'Évolution permanent (jamais reset)
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { canBuyEvolutionNode } from "../core/economy";
|
||||
import type { EvolutionNode } from "../core/economy";
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} têtards au départ`,
|
||||
unlock_generator: () => `Lac Mystique dès le début`,
|
||||
achievement_scaling: (v) => `+${(v * 100).toFixed(0)}% prod/succès`,
|
||||
};
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
canBuy,
|
||||
onBuy,
|
||||
}: {
|
||||
node: EvolutionNode;
|
||||
canBuy: boolean;
|
||||
onBuy: () => void;
|
||||
}) {
|
||||
const rowClass = node.unlocked
|
||||
? "gp-row gp-row--unlocked"
|
||||
: canBuy
|
||||
? "gp-row gp-row--evolution"
|
||||
: "gp-row gp-row--locked";
|
||||
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<span className="gp-value">{node.name}</span>
|
||||
<span className="gp-label">{EFFECT_LABELS[node.effect](node.value)}</span>
|
||||
</div>
|
||||
{node.unlocked ? (
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
) : (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
style={canBuy ? { background: "#d97706" } : {}}
|
||||
>
|
||||
{node.cost} ADN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EvolutionTree() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const buyNode = useGameStore((s) => s.buyNode);
|
||||
const { evolutionTree, prestigeCount } = state;
|
||||
|
||||
if (prestigeCount < 1) return null;
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span className="gp-title">Évolution</span>
|
||||
<span className="gp-value gp-accent-amber">{state.ancestralDna} ADN</span>
|
||||
</div>
|
||||
{evolutionTree.map((node) => (
|
||||
<NodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
canBuy={canBuyEvolutionNode(state, node.id)}
|
||||
onBuy={() => buyNode(node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// GameSync.tsx — Bridge useSaveSync ↔ Zustand store
|
||||
// Serveur = autorité. Attend la save serveur avant de rendre le jeu jouable.
|
||||
// Guest mode (pas connecté) : init depuis localStorage immédiatement.
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useSaveSync } from "../hooks/useSaveSync";
|
||||
|
||||
export function GameSync() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const ready = useGameStore((s) => s.ready);
|
||||
const loadFromServer = useGameStore((s) => s.loadFromServer);
|
||||
const initGuest = useGameStore((s) => s.initGuest);
|
||||
const playSeconds = useGameStore((s) => s.playSeconds);
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const initDone = useRef(false);
|
||||
|
||||
const getGameState = useCallback(() => state, [state]);
|
||||
|
||||
const { serverLoaded } = useSaveSync({
|
||||
getGameState,
|
||||
onLoad: loadFromServer,
|
||||
playTimeSeconds: playSeconds,
|
||||
});
|
||||
|
||||
// Once auth resolves: if no user or no server save → init guest
|
||||
useEffect(() => {
|
||||
if (authLoading || initDone.current || ready) return;
|
||||
|
||||
// Not logged in → guest mode immediately
|
||||
if (!user) {
|
||||
initDone.current = true;
|
||||
initGuest();
|
||||
return;
|
||||
}
|
||||
|
||||
// Logged in but server save loaded (or confirmed empty) → useSaveSync handles it
|
||||
// If serverLoaded is true and store isn't ready yet, it means server had no save
|
||||
if (serverLoaded && !ready) {
|
||||
initDone.current = true;
|
||||
initGuest(); // use localStorage as starting point, server will save it on next sync
|
||||
}
|
||||
}, [authLoading, user, serverLoaded, ready, initGuest]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// GameTick.tsx — Lance le tick Zustand toutes les secondes
|
||||
// À monter une seule fois dans l'arbre React (dans App)
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
|
||||
export function GameTick() {
|
||||
const tick = useGameStore((s) => s.tick);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [tick]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// GeneratorShop.tsx — Boutique de générateurs (economy.ts)
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
export function GeneratorShop() {
|
||||
const generators = useGameStore((s) => s.state.generators);
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
||||
const buy = useGameStore((s) => s.buy);
|
||||
const generatorCost = useGameStore((s) => s.generatorCost);
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "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-value gp-accent-green" title="Production totale par seconde">{formatNumber(productionPerSecond)}/s</span>
|
||||
</div>
|
||||
{generators.map((gen) => {
|
||||
const cost = generatorCost(gen);
|
||||
const canAfford = resources >= cost;
|
||||
const currentProd = gen.baseProduction * gen.owned;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={gen.id}
|
||||
className={`gp-row ${canAfford ? "gp-row--active" : "gp-row--locked"}`}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<span className="gp-value">{gen.name}</span>
|
||||
{gen.owned > 0 && (
|
||||
<span className="gp-label gp-accent-green">x{gen.owned}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="gp-label">
|
||||
+{gen.baseProduction}/s
|
||||
{gen.owned > 0 && ` · ${formatNumber(currentProd)}/s total`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => buy(gen.id)}
|
||||
disabled={!canAfford}
|
||||
className={`gp-btn ${canAfford ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// MilestoneBar.tsx — Progression vers le prochain prestige
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
const PRESTIGE_THRESHOLD = 1_000_000;
|
||||
|
||||
export function MilestoneBar() {
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
|
||||
const progress = Math.min(resources / PRESTIGE_THRESHOLD, 1);
|
||||
const progressPercent = (progress * 100).toFixed(1);
|
||||
const remaining = Math.max(PRESTIGE_THRESHOLD - resources, 0);
|
||||
|
||||
return (
|
||||
<div className="gp" style={{ gap: "0.25rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span className="gp-label">Prochaine Génération</span>
|
||||
<span className="gp-label">
|
||||
{formatNumber(resources)} / {formatNumber(PRESTIGE_THRESHOLD)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gp-progress">
|
||||
<div
|
||||
className="gp-progress-fill"
|
||||
style={{
|
||||
width: `${progressPercent}%`,
|
||||
background: "linear-gradient(90deg, #7c3aed, #a78bfa)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="gp-label" style={{ textAlign: "right" }}>
|
||||
{remaining > 0
|
||||
? `${formatNumber(remaining)} restants`
|
||||
: "Nouvelle Génération disponible !"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// PrestigePanel.tsx — Nouvelle Génération (prestige)
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { computePrestigeDna } from "../core/economy";
|
||||
|
||||
export function PrestigePanel() {
|
||||
const { lifetimeTadpoles } = useGameStore((s) => s.state);
|
||||
const canPrestige = useGameStore((s) => s.canPrestige);
|
||||
const prestige = useGameStore((s) => s.prestige);
|
||||
|
||||
const dnaPreview = computePrestigeDna(lifetimeTadpoles);
|
||||
|
||||
const handlePrestige = () => {
|
||||
const confirmed = window.confirm(
|
||||
`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 (
|
||||
<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>
|
||||
{canPrestige ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
|
||||
<span className="gp-value gp-accent-purple">
|
||||
+{dnaPreview} ADN · +0.1x mult
|
||||
</span>
|
||||
<button onClick={handlePrestige} className="gp-btn gp-btn--prestige">
|
||||
Nouvelle Génération
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="gp-label">Atteins 1M têtards pour prestige</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { NavLink as Link } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
import "../scss/components/navbar.scss";
|
||||
|
||||
import "../scss/root.scss";
|
||||
import PrimaryButton from "./buttons/PrimaryButton";
|
||||
|
||||
export default function Burger({ navData }) {
|
||||
return (
|
||||
<nav className="menuToggle">
|
||||
|
||||
<input type="checkbox" aria-label="Menu" />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<ul className="menu">
|
||||
{navData.map((navIndex) => {
|
||||
if (navIndex.dropdown === undefined) {
|
||||
return navIndex.btn === false ? (
|
||||
<li key={navIndex.id}>
|
||||
<Link className="mainLink" to={navIndex.linkurl}>
|
||||
{navIndex.linkname}
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={navIndex.id}>
|
||||
<PrimaryButton
|
||||
btnText={navIndex.linkname}
|
||||
btnLink={navIndex.linkurl}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={navIndex.id} className="dropdown">
|
||||
<Link className="mainLink" to={navIndex.linkurl}>
|
||||
{navIndex.linkname}
|
||||
</Link>
|
||||
<ul className="sousmenu">
|
||||
{navIndex.dropdown.map((dropdown) => (
|
||||
<li key={dropdown.id}>
|
||||
<Link
|
||||
className="dropLink"
|
||||
to={navIndex.linkurl + dropdown.linkurl}
|
||||
>
|
||||
{dropdown.linkname}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Burger.propTypes = {
|
||||
navData: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
btn: PropTypes.bool,
|
||||
id: PropTypes.string.isRequired,
|
||||
linkname: PropTypes.string.isRequired,
|
||||
linkurl: PropTypes.string.isRequired,
|
||||
dropdown: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
btn: PropTypes.bool,
|
||||
id: PropTypes.string.isRequired,
|
||||
linkname: PropTypes.string.isRequired,
|
||||
linkurl: PropTypes.string.isRequired,
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
}.isRequired;
|
||||
@@ -1,16 +0,0 @@
|
||||
import PropTypes from "prop-types";
|
||||
import "../../scss/components/buttons.scss";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function PrimaryButton({ btnText, btnLink }) {
|
||||
PrimaryButton.propTypes = {
|
||||
btnText: PropTypes.string.isRequired,
|
||||
btnLink: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
return (
|
||||
<Link className="primary-button" to={btnLink}>
|
||||
{btnText}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import "../../scss/components/buttons.scss";
|
||||
import PropTypes from "prop-types";
|
||||
import { Link } from "react-router";
|
||||
|
||||
export default function SecondaryButton({ btnText, btnLink }) {
|
||||
SecondaryButton.propTypes = {
|
||||
btnText: PropTypes.string.isRequired,
|
||||
btnLink: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
return (
|
||||
<Link className="secondary-button" to={btnLink}>
|
||||
{btnText}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import "../scss/components/footer.scss";
|
||||
import { NavLink as Link } from "react-router-dom";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer-container">
|
||||
<Link
|
||||
to="/"
|
||||
className="footer-logo"
|
||||
aria-label="Logo Clickerz"
|
||||
title="Aller à la page d’accueil"
|
||||
/>
|
||||
<div className="section">
|
||||
<p className="section-title">A Propos</p>
|
||||
<p className="section-text">
|
||||
Clickerz — fais éclore des têtards, construis ton empire et domine
|
||||
le marais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<p className="section-title">Légal</p>
|
||||
<ul className="section-list">
|
||||
<li className="section-item">
|
||||
<Link
|
||||
to="/mentionslegales"
|
||||
title="Aller à la page Mentions Légales"
|
||||
>
|
||||
Mentions Légales
|
||||
</Link>
|
||||
</li>
|
||||
<li className="section-item">
|
||||
<Link to="/cookies" title="Aller à la page Cookies">
|
||||
Cookies
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="spacing" />
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<a
|
||||
href="https://github.com/tetardtek"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer-github"
|
||||
title="GitHub — Kevin T"
|
||||
>
|
||||
Kevin T
|
||||
</a>
|
||||
<p className="copyright">
|
||||
© 2026 Tetardtek · Powered by Brain
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { NavLink as Link } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
import "../scss/components/navbar.scss";
|
||||
import "../scss/root.scss";
|
||||
|
||||
import PrimaryButton from "./buttons/PrimaryButton";
|
||||
import Burger from "./burger";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import SnowOn from "../../public/NavBar/SnowOn.svg";
|
||||
import SnowOff from "../../public/NavBar/SnowOff.svg";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Navbar({ navData, toggleRain, setToggleRain }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [snowImageSrc, setSnowImageSrc] = useState(SnowOff);
|
||||
|
||||
function toggleRainBtn() {
|
||||
if (toggleRain === false) {
|
||||
setToggleRain(true);
|
||||
setSnowImageSrc(SnowOn);
|
||||
} else {
|
||||
setToggleRain(false);
|
||||
setSnowImageSrc(SnowOff);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="header-main">
|
||||
<Link
|
||||
className="logo"
|
||||
to="/"
|
||||
aria-label="Retourner à la page d'accueil"
|
||||
title="Logo Clickerz"
|
||||
/>
|
||||
<div className="navbar">
|
||||
<ul className="nav-list">
|
||||
{navData.map((navIndex) => {
|
||||
if (navIndex.dropdown === undefined) {
|
||||
return navIndex.btn === false ? (
|
||||
<li key={navIndex.id}>
|
||||
<Link className="mainLink" to={navIndex.linkurl}>
|
||||
{navIndex.linkname}
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<li key={navIndex.id}>
|
||||
<PrimaryButton
|
||||
btnText={navIndex.linkname}
|
||||
btnLink={navIndex.linkurl}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={navIndex.id} className="dropdown">
|
||||
<Link className="mainLink" to={navIndex.linkurl}>
|
||||
{navIndex.linkname}
|
||||
</Link>
|
||||
<ul className="dropdown-content">
|
||||
{navIndex.dropdown.map((dropdown) => (
|
||||
<li key={dropdown.id}>
|
||||
<Link
|
||||
className="dropLink"
|
||||
to={navIndex.linkurl + dropdown.linkurl}
|
||||
>
|
||||
{dropdown.linkname}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{user ? (
|
||||
<div className="auth-nav">
|
||||
<span className="auth-nickname">{user.nickname}</span>
|
||||
<Link className="mainLink" to="/settings" title="Paramètres">
|
||||
⚙
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link className="mainLink" to="/login">
|
||||
Connexion
|
||||
</Link>
|
||||
)}
|
||||
<img
|
||||
onClick={() => toggleRainBtn()}
|
||||
src={snowImageSrc}
|
||||
style={{ height: "28px", cursor: "pointer" }}
|
||||
alt="Activer/désactiver les bulles"
|
||||
title="Ambiance bulles"
|
||||
/>
|
||||
<Burger navData={navData} />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Navbar.propTypes = {
|
||||
navData: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
btn: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
linkname: PropTypes.string,
|
||||
linkurl: PropTypes.string,
|
||||
dropdown: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
btn: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
linkname: PropTypes.string,
|
||||
linkurl: PropTypes.string,
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
Navbar.defaultProps = {
|
||||
navData: [],
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const data = await apiFetch("/auth/me");
|
||||
setUser(data);
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onExpired = () => setUser(null);
|
||||
window.addEventListener("auth:expired", onExpired);
|
||||
return () => window.removeEventListener("auth:expired", onExpired);
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiFetch("/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const editUser = async (updatedFields) => {
|
||||
const data = await apiFetch(`/users/${user.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updatedFields),
|
||||
});
|
||||
setUser((prev) => ({ ...prev, ...data.user }));
|
||||
return "User updated successfully";
|
||||
};
|
||||
|
||||
const authContextValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
loading,
|
||||
logout,
|
||||
refresh,
|
||||
editUser,
|
||||
setUser: (newUser) => setUser(newUser),
|
||||
}),
|
||||
[user, loading]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
AuthProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { AuthProvider, useAuth };
|
||||
@@ -1,209 +0,0 @@
|
||||
// economy.ts — Core clicker logic (lazy calculation pattern)
|
||||
// Jamais de timer actif : tout est calculé au read depuis lastTick
|
||||
|
||||
export interface Generator {
|
||||
id: string;
|
||||
name: string;
|
||||
baseCost: number;
|
||||
baseProduction: number; // ressource/s
|
||||
owned: number;
|
||||
}
|
||||
|
||||
export type EffectType = "click_multiplier" | "production_multiplier" | "start_bonus" | "unlock_generator" | "achievement_scaling";
|
||||
|
||||
export interface EvolutionNode {
|
||||
id: string;
|
||||
name: string;
|
||||
cost: number; // en ADN Ancestral
|
||||
effect: EffectType;
|
||||
value: number;
|
||||
unlocked: boolean;
|
||||
requires: string | null; // id du nœud prérequis (null = racine)
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
resources: number;
|
||||
clickMultiplier: number;
|
||||
generators: Generator[];
|
||||
lastTick: number; // timestamp ms — lazy calc reference
|
||||
prestigeCount: number;
|
||||
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
|
||||
ancestralDna: number;
|
||||
evolutionTree: EvolutionNode[];
|
||||
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
|
||||
}
|
||||
|
||||
// --- Arbre d'Évolution ---
|
||||
|
||||
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 },
|
||||
{ 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" },
|
||||
{ id: "mutation_alpha", name: "Mutation Alpha", cost: 25, effect: "unlock_generator", value: 0, unlocked: false, requires: "memoire_genetique" },
|
||||
{ id: "symbiose", name: "Symbiose", cost: 50, effect: "achievement_scaling", value: 0.01, unlocked: false, requires: "mutation_alpha" },
|
||||
];
|
||||
|
||||
// Calcule l'ADN gagné lors d'un prestige : floor(150 × sqrt(lifetime / 1e9))
|
||||
export function computePrestigeDna(lifetimeTadpoles: number): number {
|
||||
return Math.floor(150 * Math.sqrt(lifetimeTadpoles / 1e9));
|
||||
}
|
||||
|
||||
// Vérifie si un nœud peut être acheté
|
||||
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId);
|
||||
if (!node || node.unlocked) return false;
|
||||
if (state.ancestralDna < node.cost) return false;
|
||||
if (node.requires) {
|
||||
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
||||
if (!prereq || !prereq.unlocked) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Achète un nœud d'évolution (retourne null si impossible)
|
||||
export function buyEvolutionNode(state: GameState, nodeId: string): GameState | null {
|
||||
if (!canBuyEvolutionNode(state, nodeId)) return null;
|
||||
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId)!;
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna - node.cost,
|
||||
evolutionTree: state.evolutionTree.map((n) =>
|
||||
n.id === nodeId ? { ...n, unlocked: true } : n
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Calcule le multiplicateur click total depuis l'arbre
|
||||
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "click_multiplier")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Calcule le multiplicateur production total depuis l'arbre
|
||||
export function getProductionMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "production_multiplier")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Bonus de départ (têtards offerts au début de chaque run)
|
||||
export function getStartBonusFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "start_bonus")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// --- Core economy (mis à jour pour intégrer l'arbre) ---
|
||||
|
||||
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned
|
||||
export function generatorCost(gen: Generator): number {
|
||||
return Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
|
||||
}
|
||||
|
||||
// Production totale par seconde de tous les générateurs
|
||||
export function totalProductionPerSecond(state: GameState): number {
|
||||
const base = state.generators.reduce(
|
||||
(sum, gen) => sum + gen.baseProduction * gen.owned,
|
||||
0
|
||||
);
|
||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||
return base * state.prestigeMultiplier * treeMultiplier;
|
||||
}
|
||||
|
||||
// Lazy calculation : ressources accumulées depuis lastTick
|
||||
export function computeIdleGains(state: GameState, now: number): number {
|
||||
const elapsedSeconds = (now - state.lastTick) / 1000;
|
||||
return totalProductionPerSecond(state) * elapsedSeconds;
|
||||
}
|
||||
|
||||
// Applique les gains idle et met à jour lastTick
|
||||
export function applyIdleGains(state: GameState, now: number): GameState {
|
||||
const gains = computeIdleGains(state, now);
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources + gains,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||||
lastTick: now,
|
||||
};
|
||||
}
|
||||
|
||||
// Gain réel par clic (pour affichage particule)
|
||||
export function getClickGain(state: GameState): number {
|
||||
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
|
||||
}
|
||||
|
||||
// Clic manuel
|
||||
export function applyClick(state: GameState): GameState {
|
||||
const gain = getClickGain(state);
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources + gain,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||||
};
|
||||
}
|
||||
|
||||
// Achat d'un générateur (retourne null si fonds insuffisants)
|
||||
export function buyGenerator(state: GameState, genId: string): GameState | null {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return null;
|
||||
|
||||
const gen = state.generators[genIndex];
|
||||
const cost = generatorCost(gen);
|
||||
if (state.resources < cost) return null;
|
||||
|
||||
const updatedGenerators = [...state.generators];
|
||||
updatedGenerators[genIndex] = { ...gen, owned: gen.owned + 1 };
|
||||
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources - cost,
|
||||
generators: updatedGenerators,
|
||||
};
|
||||
}
|
||||
|
||||
// Prestige : reset run, gain ADN, arbre persiste
|
||||
export function canPrestige(state: GameState): boolean {
|
||||
return state.resources >= 1_000_000;
|
||||
}
|
||||
|
||||
export function applyPrestige(state: GameState): GameState {
|
||||
const newPrestigeCount = state.prestigeCount + 1;
|
||||
const dnaGained = computePrestigeDna(state.lifetimeTadpoles);
|
||||
const startBonus = getStartBonusFromTree(state.evolutionTree);
|
||||
|
||||
return {
|
||||
...state,
|
||||
resources: startBonus,
|
||||
generators: state.generators.map((g) => ({ ...g, owned: 0 })),
|
||||
prestigeCount: newPrestigeCount,
|
||||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||||
ancestralDna: state.ancestralDna + dnaGained,
|
||||
lifetimeTadpoles: 0,
|
||||
lastTick: Date.now(),
|
||||
// evolutionTree persiste — jamais reset
|
||||
};
|
||||
}
|
||||
|
||||
// Valeurs par défaut — 5 tiers alignés GDD Tetard Universe (x10 coût / tier)
|
||||
export const DEFAULT_GENERATORS: Generator[] = [
|
||||
{ id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 },
|
||||
{ id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 },
|
||||
{ id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 },
|
||||
{ id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 },
|
||||
{ id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 },
|
||||
];
|
||||
|
||||
export const DEFAULT_STATE: GameState = {
|
||||
resources: 0,
|
||||
clickMultiplier: 1,
|
||||
generators: DEFAULT_GENERATORS,
|
||||
lastTick: Date.now(),
|
||||
prestigeCount: 0,
|
||||
prestigeMultiplier: 1,
|
||||
ancestralDna: 0,
|
||||
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||||
lifetimeTadpoles: 0,
|
||||
};
|
||||
@@ -1,229 +0,0 @@
|
||||
{
|
||||
"v": "5.4.4",
|
||||
"fr": 29.9700012207031,
|
||||
"ip": 0,
|
||||
"op": 120.0000048877,
|
||||
"w": 1080,
|
||||
"h": 620,
|
||||
"nm": "Comp 2",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Shape Layer 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [539.5, 310, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 81],
|
||||
[0, 0],
|
||||
[-77, 0],
|
||||
[0, -39],
|
||||
[0, 0],
|
||||
[17, -20],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, -81],
|
||||
[0, 0],
|
||||
[77, 0],
|
||||
[0, 39],
|
||||
[0, 0],
|
||||
[-17, 20],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-539.25, 234],
|
||||
[-244, 234],
|
||||
[-244, 126],
|
||||
[-385, 126],
|
||||
[-385, 62],
|
||||
[-263, -238],
|
||||
[-178, -238],
|
||||
[-178, 54],
|
||||
[-150, 54],
|
||||
[-150, 120],
|
||||
[-176, 120],
|
||||
[-176, 234],
|
||||
[-59, 234],
|
||||
[-101, 143],
|
||||
[-101, -160],
|
||||
[-9, -248],
|
||||
[100, -165],
|
||||
[101, 157],
|
||||
[80, 220],
|
||||
[50, 240],
|
||||
[288, 240],
|
||||
[287, 123],
|
||||
[148, 123],
|
||||
[148, 56],
|
||||
[268, -239],
|
||||
[357, -239],
|
||||
[357, 51],
|
||||
[380, 51],
|
||||
[380, 122],
|
||||
[359, 122],
|
||||
[359, 241.75],
|
||||
[540.5, 242]
|
||||
],
|
||||
"c": false
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.1, 0.1, 0.1, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 5, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "Stroke 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Shape 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tm",
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.667], "y": [0.992] },
|
||||
"o": { "x": [0.534], "y": [0.224] },
|
||||
"t": 47,
|
||||
"s": [0],
|
||||
"e": [100]
|
||||
},
|
||||
{ "t": 95.0000038694293 }
|
||||
],
|
||||
"ix": 1
|
||||
},
|
||||
"e": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.667], "y": [0.96] },
|
||||
"o": { "x": [0.677], "y": [0.024] },
|
||||
"t": 15,
|
||||
"s": [0],
|
||||
"e": [100]
|
||||
},
|
||||
{ "t": 82.0000033399285 }
|
||||
],
|
||||
"ix": 2
|
||||
},
|
||||
"o": { "a": 0, "k": 0, "ix": 3 },
|
||||
"m": 1,
|
||||
"ix": 2,
|
||||
"nm": "Trim Paths 1",
|
||||
"mn": "ADBE Vector Filter - Trim",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 120.0000048877,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"linkname": "Jeu",
|
||||
"linkurl": "/jeu",
|
||||
"btn": false
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"linkname": "Succès",
|
||||
"linkurl": "/achievements",
|
||||
"btn": false
|
||||
}
|
||||
]
|
||||
@@ -1,145 +0,0 @@
|
||||
// useSaveSync.ts — Auto-save game state to backend every 30s
|
||||
// Serveur = autorité. NEVER save before server state is loaded (ready guard).
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import type { GameState } from "../core/economy";
|
||||
|
||||
const SAVE_INTERVAL_MS = 30_000; // 30 seconds
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || "http://localhost:3310";
|
||||
|
||||
interface SaveSyncOptions {
|
||||
getGameState: () => GameState;
|
||||
onLoad: (state: GameState) => void;
|
||||
playTimeSeconds: number;
|
||||
}
|
||||
|
||||
async function apiRequest(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
console.warn(`[SaveSync] ${path} failed:`, res.status, body);
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useSaveSync({ getGameState, onLoad, playTimeSeconds }: SaveSyncOptions) {
|
||||
const { user } = useAuth();
|
||||
const ready = useGameStore((s) => s.ready);
|
||||
const lastSaveRef = useRef<string | null>(null);
|
||||
const loadedRef = useRef(false);
|
||||
const [serverLoaded, setServerLoaded] = useState(false);
|
||||
|
||||
// Load save from server on mount (once, only if logged in)
|
||||
useEffect(() => {
|
||||
if (loadedRef.current || !user) {
|
||||
if (!user) setServerLoaded(true);
|
||||
return;
|
||||
}
|
||||
loadedRef.current = true;
|
||||
|
||||
apiRequest("/save").then((data) => {
|
||||
if (data?.gameState) {
|
||||
onLoad(data.gameState);
|
||||
lastSaveRef.current = data.lastSave;
|
||||
console.info("[SaveSync] Loaded save from server — server is authority");
|
||||
} else {
|
||||
console.info("[SaveSync] No server save found — starting fresh");
|
||||
}
|
||||
setServerLoaded(true);
|
||||
}).catch(() => {
|
||||
console.warn("[SaveSync] Server unreachable — falling back to local");
|
||||
setServerLoaded(true);
|
||||
});
|
||||
}, [onLoad, user]);
|
||||
|
||||
// Save function — GUARDED by ready (never save DEFAULT_STATE)
|
||||
const saveToServer = useCallback(async () => {
|
||||
if (!user || !useGameStore.getState().ready) return;
|
||||
|
||||
const gameState = getGameState();
|
||||
const result = await apiRequest("/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ gameState, playTimeSeconds }),
|
||||
});
|
||||
|
||||
if (result?.lastSave) {
|
||||
lastSaveRef.current = result.lastSave;
|
||||
}
|
||||
}, [getGameState, playTimeSeconds, user]);
|
||||
|
||||
// Auto-save interval — only when ready
|
||||
useEffect(() => {
|
||||
if (!user || !ready) return undefined;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveToServer();
|
||||
}, SAVE_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [saveToServer, user, ready]);
|
||||
|
||||
// Multi-tab sync: save on blur, reload on focus — only when ready
|
||||
useEffect(() => {
|
||||
if (!user) return undefined;
|
||||
|
||||
const handleFocus = () => {
|
||||
// Small delay to let the other tab's blur save complete
|
||||
setTimeout(() => apiRequest("/save").then((data) => {
|
||||
if (data?.gameState && data.lastSave) {
|
||||
if (!lastSaveRef.current || new Date(data.lastSave) > new Date(lastSaveRef.current)) {
|
||||
onLoad(data.gameState);
|
||||
lastSaveRef.current = data.lastSave;
|
||||
console.info("[SaveSync] Reloaded from server on focus");
|
||||
}
|
||||
}
|
||||
}), 500);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!useGameStore.getState().ready) return;
|
||||
saveToServer();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [user, onLoad, saveToServer]);
|
||||
|
||||
// Save on page unload — GUARDED by ready
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
if (!user || !useGameStore.getState().ready) return;
|
||||
|
||||
const gameState = getGameState();
|
||||
const payload = JSON.stringify({ gameState, playTimeSeconds });
|
||||
|
||||
fetch(`${BACKEND_URL}/api/save`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
||||
}, [getGameState, playTimeSeconds, user]);
|
||||
|
||||
return { saveToServer, lastSave: lastSaveRef.current, serverLoaded };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
display: none;
|
||||
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Centralized API client — cookie-based auth with 401 auto-refresh
|
||||
|
||||
const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
let refreshPromise = null;
|
||||
|
||||
async function tryRefresh() {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 401 && path !== '/auth/refresh') {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (retry.ok) {
|
||||
if (retry.status === 204) return null;
|
||||
return retry.json();
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new Event('auth:expired'));
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(body.message || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
58
Frontend/src/lib/api.ts
Normal file
58
Frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Centralized API client — cookie-based auth with 401 auto-refresh
|
||||
|
||||
const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 401 && path !== '/auth/refresh') {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (retry.ok) {
|
||||
if (retry.status === 204) return null;
|
||||
return retry.json();
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('auth:expired'));
|
||||
}
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(body.message || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
121
Frontend/src/lib/components/ClickPanel.svelte
Normal file
121
Frontend/src/lib/components/ClickPanel.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { getClickBreakdown, clickUpgradeCost } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let b = $derived(getClickBreakdown(game.state));
|
||||
let expected = $derived(b.total * (1 + b.doubleChance + b.critChance * 9));
|
||||
|
||||
const CLICKS_PER_SEC = 5;
|
||||
let manualProd = $derived(expected * CLICKS_PER_SEC);
|
||||
let totalWithClicks = $derived(game.productionPerSecond + b.effectivePerSec + manualProd);
|
||||
let clickShare = $derived(totalWithClicks > 0 ? ((b.effectivePerSec + manualProd) / totalWithClicks * 100) : 0);
|
||||
|
||||
// Generator names for display
|
||||
const GEN_NAMES: Record<string, string> = {
|
||||
nid: 'Nid', mare: 'Mare', marecage: 'Marecage', etang: 'Etang', lac: 'Lac',
|
||||
};
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel title="Ponte (clic)" badge="{formatNumber(b.total)}" accentClass="gp-accent-amber" defaultOpen={false}>
|
||||
<!-- Gain par clic -->
|
||||
<div class="gp-row gp-row--active">
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="gp-value">Gain par clic</span>
|
||||
<span class="gp-label">
|
||||
base {b.base} × x{b.prestigeMult.toFixed(1)} × x{b.treeMult.toFixed(0)} × x{b.genBonus.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-value gp-accent-amber text-lg!">{formatNumber(b.total)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Click contribution -->
|
||||
<div class="gp-row" style="border-color: rgba(251,191,36,0.15); background: rgba(251,191,36,0.04);">
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="gp-value text-[0.7rem]!">Contribution clics</span>
|
||||
<span class="gp-label">
|
||||
~{CLICKS_PER_SEC} clics/s → {formatNumber(manualProd)}/s
|
||||
{#if b.autoClicksPerSec > 0} + auto {formatNumber(b.effectivePerSec)}/s{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label gp-accent-amber">{clickShare.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Click upgrades shop -->
|
||||
{#if (game.state.clickUpgrades ?? []).length > 0}
|
||||
<span class="gp-zone-label mt-1">Ameliorations de ponte</span>
|
||||
{#each game.state.clickUpgrades ?? [] as upgrade}
|
||||
{@const gen = game.state.generators.find((g) => g.id === upgrade.generatorId)}
|
||||
{@const hasGen = gen && gen.owned > 0}
|
||||
{@const cost = clickUpgradeCost(upgrade)}
|
||||
{@const canAfford = hasGen && game.state.resources >= cost}
|
||||
<div class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="gp-value text-[0.7rem]!">{upgrade.name}</span>
|
||||
{#if upgrade.level > 0}
|
||||
<span
|
||||
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
|
||||
style="background: rgba(251,191,36,0.15); color: var(--color-gp-accent-amber);"
|
||||
>
|
||||
nv.{upgrade.level}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="gp-label">
|
||||
{#if hasGen}
|
||||
+{upgrade.baseClickPower}/clic par niveau ({GEN_NAMES[upgrade.generatorId]})
|
||||
{:else}
|
||||
Necessite un {GEN_NAMES[upgrade.generatorId]}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if hasGen}
|
||||
<button
|
||||
onclick={() => game.buyClickUpgrade(upgrade.id)}
|
||||
disabled={!canAfford}
|
||||
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="gp-label">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Double/Crit/Auto -->
|
||||
<span class="gp-zone-label mt-1">Bonus (arbre)</span>
|
||||
|
||||
<div class="gp-row {b.doubleChance > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Double Ponte</span>
|
||||
<span class="gp-label">
|
||||
{b.doubleChance > 0 ? `${(b.doubleChance * 100).toFixed(0)}% chance de doubler` : 'Branche Ponte — 5 ADN'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label {b.doubleChance > 0 ? 'gp-accent-purple' : ''}">{b.doubleChance > 0 ? `${(b.doubleChance * 100).toFixed(0)}%` : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-row {b.critChance > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Ponte Critique</span>
|
||||
<span class="gp-label">
|
||||
{b.critChance > 0 ? `${(b.critChance * 100).toFixed(0)}% chance de x10` : 'Branche Ponte — 20 ADN'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label {b.critChance > 0 ? 'gp-accent-amber' : ''}">{b.critChance > 0 ? `${(b.critChance * 100).toFixed(0)}%` : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-row {b.autoClicksPerSec > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Auto-Ponte</span>
|
||||
<span class="gp-label">
|
||||
{b.autoClicksPerSec > 0 ? `${b.autoClicksPerSec.toFixed(1)} clics/s → ${formatNumber(b.effectivePerSec)}/s` : 'Capstone Ponte — 200 ADN'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label {b.autoClicksPerSec > 0 ? 'gp-accent-green' : ''}">{b.autoClicksPerSec > 0 ? `${formatNumber(b.effectivePerSec)}/s` : '—'}</span>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
50
Frontend/src/lib/components/ClickParticles.svelte
Normal file
50
Frontend/src/lib/components/ClickParticles.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
gain: number;
|
||||
isDouble: boolean;
|
||||
isCrit: boolean;
|
||||
}
|
||||
|
||||
let particles = $state<Particle[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
export function spawn(x: number, y: number, gain: number, isDouble: boolean, isCrit: boolean) {
|
||||
const id = nextId++;
|
||||
// Random horizontal spread
|
||||
const offsetX = (Math.random() - 0.5) * 40;
|
||||
particles = [...particles, { id, x: x + offsetX, y, gain, isDouble, isCrit }];
|
||||
setTimeout(() => {
|
||||
particles = particles.filter(p => p.id !== id);
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 pointer-events-none z-[100]">
|
||||
{#each particles as p (p.id)}
|
||||
{@const prefix = p.isCrit ? 'CRIT ' : p.isDouble ? 'x2 ' : ''}
|
||||
{@const color = p.isCrit ? '#f59e0b' : p.isDouble ? '#a78bfa' : '#34d399'}
|
||||
{@const size = p.isCrit ? '2rem' : p.isDouble ? '1.8rem' : '1.6rem'}
|
||||
<span
|
||||
class="absolute font-extrabold"
|
||||
style="
|
||||
left: {p.x}px;
|
||||
top: {p.y}px;
|
||||
color: {color};
|
||||
font-size: {size};
|
||||
font-family: var(--font);
|
||||
text-shadow: 0 0 10px {color}60, 0 2px 6px rgba(0,0,0,0.7);
|
||||
"
|
||||
in:fly={{ y: 0, duration: 50 }}
|
||||
out:fly={{ y: -90, duration: 900, easing: quintOut, opacity: 0 }}
|
||||
>
|
||||
{prefix}+{formatNumber(p.gain)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
34
Frontend/src/lib/components/CockpitHeader.svelte
Normal file
34
Frontend/src/lib/components/CockpitHeader.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { getClickBreakdown } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
let cb = $derived(getClickBreakdown(game.state));
|
||||
// Expected value per click = total × (1 + doubleChance + critChance × 9)
|
||||
let expectedPerClick = $derived(cb.total * (1 + cb.doubleChance + cb.critChance * 9));
|
||||
</script>
|
||||
|
||||
<div class="gp">
|
||||
<div class="grid grid-cols-5 gap-0.5 px-1">
|
||||
<div class="gp-stat" title="Production passive par seconde (generateurs)">
|
||||
<span class="gp-label">Passif</span>
|
||||
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(game.productionPerSecond)}/s</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Valeur attendue par clic (double + crit inclus)">
|
||||
<span class="gp-label">Clic</span>
|
||||
<span class="gp-value gp-accent-amber text-[0.8rem]!">{formatNumber(expectedPerClick)}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Multiplicateur global (prestige)">
|
||||
<span class="gp-label">Mult</span>
|
||||
<span class="gp-value text-[0.8rem]!">x{game.state.prestigeMultiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="ADN Ancestral">
|
||||
<span class="gp-label">ADN</span>
|
||||
<span class="gp-value gp-accent-purple text-[0.8rem]!">{game.state.ancestralDna}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Nombre de prestiges">
|
||||
<span class="gp-label">Gen.</span>
|
||||
<span class="gp-value text-[0.8rem]!">{game.state.prestigeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
51
Frontend/src/lib/components/CollapsiblePanel.svelte
Normal file
51
Frontend/src/lib/components/CollapsiblePanel.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
badge?: string;
|
||||
accentClass?: string;
|
||||
defaultOpen?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, badge = '', accentClass = '', defaultOpen = true, children }: Props = $props();
|
||||
|
||||
let open = $state(defaultOpen);
|
||||
</script>
|
||||
|
||||
<div class="gp overflow-hidden">
|
||||
<button
|
||||
class="flex items-center justify-between w-full cursor-pointer group"
|
||||
onclick={() => open = !open}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform duration-200 text-white/50 group-hover:text-white/80"
|
||||
class:rotate-90={open}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="gp-title {accentClass}">{title}</span>
|
||||
</div>
|
||||
{#if badge}
|
||||
<span class="gp-label">{badge}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 250, easing: quintOut }}>
|
||||
<div class="flex flex-col gap-[var(--spacing-gp-gap)] pt-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
54
Frontend/src/lib/components/CosmeticsPanel.svelte
Normal file
54
Frontend/src/lib/components/CosmeticsPanel.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
const SLOT_LABELS: Record<CosmeticSlot, string> = {
|
||||
hat: 'Tete', eyes: 'Yeux', body: 'Corps', tail: 'Queue', accessory: 'Aura',
|
||||
};
|
||||
const SLOT_ICONS: Record<CosmeticSlot, string> = {
|
||||
hat: '👑', eyes: '👁', body: '🛡', tail: '🦎', accessory: '✨',
|
||||
};
|
||||
const SLOT_ORDER: CosmeticSlot[] = ['hat', 'eyes', 'body', 'tail', 'accessory'];
|
||||
|
||||
let inventory = $derived(game.state.cosmeticInventory);
|
||||
let equipped = $derived(game.state.cosmeticEquipped);
|
||||
let ownedCosmetics = $derived(COSMETICS.filter((c) => inventory.includes(c.id)));
|
||||
</script>
|
||||
|
||||
{#if inventory.length > 0}
|
||||
<CollapsiblePanel
|
||||
title="Cosmetiques"
|
||||
badge="{inventory.length}/{COSMETICS.length}"
|
||||
defaultOpen={false}
|
||||
>
|
||||
{#each SLOT_ORDER as slot, si}
|
||||
{@const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot)}
|
||||
{#if slotCosmetics.length > 0}
|
||||
<div
|
||||
class="flex flex-col gap-0.5"
|
||||
in:fly={{ y: 15, delay: si * 60, duration: 250, easing: quintOut }}
|
||||
>
|
||||
<span class="gp-zone-label">{SLOT_ICONS[slot]} {SLOT_LABELS[slot]}</span>
|
||||
{#each slotCosmetics as cos}
|
||||
{@const isEquipped = equipped[slot] === cos.id}
|
||||
<div class="gp-row {isEquipped ? 'gp-row--unlocked' : 'gp-row--active'}">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="gp-value text-[0.7rem]!">{cos.name}</span>
|
||||
<span class="gp-label">{cos.description}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => isEquipped ? game.unequipCosmetic(slot) : game.equipCosmetic(cos.id)}
|
||||
class="gp-btn {isEquipped ? 'gp-btn--disabled' : 'gp-btn--buy'}"
|
||||
>
|
||||
{isEquipped ? 'Retirer' : 'Equiper'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</CollapsiblePanel>
|
||||
{/if}
|
||||
193
Frontend/src/lib/components/EvolutionTree.svelte
Normal file
193
Frontend/src/lib/components/EvolutionTree.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import {
|
||||
canBuyEvolutionNode,
|
||||
getSpentDna,
|
||||
getTreeResetCost,
|
||||
canResetTree,
|
||||
getRepeatableCost,
|
||||
canUpgradeConvergence,
|
||||
type EvolutionNode,
|
||||
type Branch,
|
||||
} from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} tetards au depart`,
|
||||
unlock_generator: () => `Lac Mystique des le debut`,
|
||||
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<string, { 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' },
|
||||
};
|
||||
|
||||
const BRANCHES: Branch[] = ['ponte', 'marais', 'adaptation'];
|
||||
|
||||
let activeBranch = $state<Branch>('ponte');
|
||||
|
||||
let branchConfig = $derived(BRANCH_CONFIG[activeBranch]);
|
||||
let branchNodes = $derived(game.state.evolutionTree.filter((n) => n.branch === activeBranch));
|
||||
let spentDna = $derived(getSpentDna(game.state.evolutionTree));
|
||||
let hasUnlocked = $derived(spentDna > 0);
|
||||
let resetCost = $derived(getTreeResetCost(game.state));
|
||||
let canReset = $derived(canResetTree(game.state));
|
||||
let conv = $derived(game.state.evolutionTree.find((n) => n.id === 'convergence'));
|
||||
let canBuyConv = $derived(canBuyEvolutionNode(game.state, 'convergence'));
|
||||
let canUpgradeConv = $derived(canUpgradeConvergence(game.state));
|
||||
|
||||
function handleReset() {
|
||||
if (!canReset) return;
|
||||
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : ' (gratuit)';
|
||||
const confirmed = window.confirm(
|
||||
`Reinitialiser l'Arbre d'Evolution ?\n\nTu recuperes ${spentDna} ADN Ancestral.${costLabel}\nTous les noeuds seront verrouilles.\n\nConfirmer ?`
|
||||
);
|
||||
if (confirmed) game.resetTree();
|
||||
}
|
||||
|
||||
function getNodeRowClass(node: EvolutionNode, isExcluded: boolean, canBuy: boolean): string {
|
||||
if (node.unlocked) return node.capstone ? 'gp-row gp-row--unlocked border-amber-400/40!' : 'gp-row gp-row--unlocked';
|
||||
if (isExcluded) return 'gp-row gp-row--locked opacity-30!';
|
||||
if (canBuy) return node.capstone ? 'gp-row gp-row--evolution border-amber-400/30!' : 'gp-row gp-row--evolution';
|
||||
return 'gp-row gp-row--locked';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if game.state.prestigeCount >= 1}
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="gp-title">Evolution</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="gp-value gp-accent-amber">{formatNumber(game.state.ancestralDna)} ADN</span>
|
||||
{#if hasUnlocked}
|
||||
<button
|
||||
onclick={handleReset}
|
||||
disabled={!canReset}
|
||||
class="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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch tabs -->
|
||||
<div class="flex gap-1">
|
||||
{#each BRANCHES as branch}
|
||||
{@const config = BRANCH_CONFIG[branch]}
|
||||
{@const isActive = activeBranch === branch}
|
||||
<button
|
||||
onclick={() => activeBranch = branch}
|
||||
class="gp-btn flex-1 py-1.5! text-[0.7rem]! font-bold! uppercase! tracking-wider! {isActive ? `gp-btn--buy ${config.accent}` : 'gp-btn--disabled'}"
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Active branch -->
|
||||
<div class="gp flex-1 min-w-0 border-t-2 {branchConfig.color}">
|
||||
<span class="gp-title text-center {branchConfig.accent}">{branchConfig.label}</span>
|
||||
{#each branchNodes as node}
|
||||
{@const isExcluded = node.exclusive_with ? (game.state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
|
||||
{@const canBuy = canBuyEvolutionNode(game.state, node.id)}
|
||||
{@const cost = node.repeatable && node.unlocked ? getRepeatableCost(node) : node.cost}
|
||||
<div class={getNodeRowClass(node, isExcluded, canBuy)}>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
{#if node.capstone}<span class="text-amber-400 text-[0.6rem]">★</span>{/if}
|
||||
<span class="gp-value text-[0.7rem]!">{node.name}</span>
|
||||
{#if node.repeatable && node.unlocked}
|
||||
<span class="gp-label text-[0.55rem]!">x{node.purchased ?? 0}</span>
|
||||
{/if}
|
||||
{#if node.exclusive_with && !node.unlocked && !isExcluded}
|
||||
<span class="gp-label text-[0.55rem]!">OU</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||
</div>
|
||||
{#if node.unlocked && !node.repeatable}
|
||||
<span class="gp-label gp-accent-green">OK</span>
|
||||
{:else if isExcluded}
|
||||
<span class="gp-label text-[0.55rem]!">verrouille</span>
|
||||
{:else}
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onclick={() => game.buyNode(node.id)}
|
||||
class="gp-btn {canBuy ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Convergence -->
|
||||
{#if conv}
|
||||
<div class="gp border-t-2 border-purple-500/30">
|
||||
<span class="gp-title text-center gp-accent-purple">
|
||||
Convergence {conv.unlocked ? ((conv.tier ?? 1) >= 2 ? 'Omega' : 'Alpha') : ''}
|
||||
</span>
|
||||
{#if conv.unlocked}
|
||||
{@const tier = conv.tier ?? 1}
|
||||
{@const maxTier = conv.maxTier ?? 2}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="gp-row gp-row--unlocked border-purple-400/30!">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">{tier >= 2 ? 'Omega' : 'Alpha'} (tier {tier}/{maxTier})</span>
|
||||
<span class="gp-label">
|
||||
{tier >= 2 ? '+10% tous effets + -20% cout post-capstones' : "+10% a tous les effets de l'arbre"}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label gp-accent-green">OK</span>
|
||||
</div>
|
||||
{#if tier < maxTier}
|
||||
<button
|
||||
disabled={!canUpgradeConv}
|
||||
onclick={() => game.upgradeConvergence()}
|
||||
class="gp-btn {canUpgradeConv ? 'gp-btn--buy' : 'gp-btn--disabled'} w-full"
|
||||
>
|
||||
{canUpgradeConv ? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)` : `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gp-row gp-row--locked">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Convergence Alpha</span>
|
||||
<span class="gp-label">+10% a tous les effets de l'arbre</span>
|
||||
<span class="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
|
||||
</div>
|
||||
<button
|
||||
disabled={!canBuyConv}
|
||||
onclick={() => game.buyNode('convergence')}
|
||||
class="gp-btn {canBuyConv ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{conv.cost}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
8
Frontend/src/lib/components/Footer.svelte
Normal file
8
Frontend/src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<footer class="footer">
|
||||
<div class="footer-container">
|
||||
<a href="/" aria-label="Accueil Clickerz">
|
||||
<div class="footer-logo"></div>
|
||||
</a>
|
||||
</div>
|
||||
<p class="copyright">© {new Date().getFullYear()} Clickerz — Tetard Universe</p>
|
||||
</footer>
|
||||
28
Frontend/src/lib/components/GameSync.svelte
Normal file
28
Frontend/src/lib/components/GameSync.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import {
|
||||
loadFromServer,
|
||||
startAutoSave,
|
||||
stopAutoSave,
|
||||
setupVisibilitySync,
|
||||
} from '$lib/save-sync.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
// Init auth
|
||||
await authStore.init();
|
||||
|
||||
// Load save or init guest
|
||||
if (authStore.user) {
|
||||
const loaded = await loadFromServer();
|
||||
if (!loaded && !game.ready) {
|
||||
game.initGuest();
|
||||
}
|
||||
startAutoSave();
|
||||
setupVisibilitySync();
|
||||
} else {
|
||||
game.initGuest();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
14
Frontend/src/lib/components/GameTick.svelte
Normal file
14
Frontend/src/lib/components/GameTick.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => game.tick(), 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
91
Frontend/src/lib/components/GeneratorShop.svelte
Normal file
91
Frontend/src/lib/components/GeneratorShop.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { generatorEffectiveProduction, maxAffordable, bulkCost } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
type BuyMode = 1 | 5 | 10 | 'max';
|
||||
let buyMode = $state<BuyMode>(1);
|
||||
|
||||
const MODES: BuyMode[] = [1, 5, 10, 'max'];
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel
|
||||
title="Generateurs"
|
||||
badge="{formatNumber(game.productionPerSecond)}/s"
|
||||
accentClass=""
|
||||
>
|
||||
<!-- Buy mode selector -->
|
||||
<div class="flex gap-0.5 p-0.5 rounded-lg" style="background: rgba(255,255,255,0.04);">
|
||||
{#each MODES as mode}
|
||||
<button
|
||||
class="flex-1 py-1 rounded-md text-[0.65rem] font-semibold transition-all duration-150"
|
||||
style="font-family: var(--font); {buyMode === mode
|
||||
? 'background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.95);'
|
||||
: 'background: transparent; color: rgba(255,255,255,0.4);'
|
||||
}"
|
||||
onclick={() => buyMode = mode}
|
||||
>
|
||||
{mode === 'max' ? 'MAX' : `x${mode}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each game.state.generators as gen, i}
|
||||
{@const qty = buyMode === 'max' ? maxAffordable(game.state, gen.id) : buyMode}
|
||||
{@const cost = qty <= 1 ? game.generatorCostWithTree(gen) : bulkCost(game.state, gen.id, qty)}
|
||||
{@const canAfford = game.state.resources >= cost && qty > 0}
|
||||
{@const effectiveProd = generatorEffectiveProduction(gen, game.state)}
|
||||
{@const nextGain = generatorEffectiveProduction({ ...gen, owned: qty || 1 }, game.state)}
|
||||
{@const share = game.productionPerSecond > 0 ? (effectiveProd / game.productionPerSecond * 100) : 0}
|
||||
<div
|
||||
class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}"
|
||||
in:scale={{ delay: i * 30, duration: 200, start: 0.95, easing: quintOut }}
|
||||
>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="gp-value">{gen.name}</span>
|
||||
{#if gen.owned > 0}
|
||||
<span
|
||||
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
|
||||
style="background: rgba(16,185,129,0.15); color: var(--color-gp-accent-green);"
|
||||
>
|
||||
x{gen.owned}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if gen.owned > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="gp-label gp-accent-green">{formatNumber(effectiveProd)}/s</span>
|
||||
<span class="gp-label">·</span>
|
||||
<span class="gp-label">{share.toFixed(0)}%</span>
|
||||
<span class="gp-label">·</span>
|
||||
<span class="gp-label gp-accent-amber">+{formatNumber(nextGain)}/s</span>
|
||||
</div>
|
||||
<div class="h-[2px] rounded-full mt-0.5" style="background: rgba(255,255,255,0.06);">
|
||||
<div class="h-full rounded-full" style="width: {Math.min(share, 100)}%; background: var(--color-gp-accent-green); opacity: 0.5;"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="gp-label">
|
||||
<span class="gp-accent-amber">+{formatNumber(nextGain)}/s</span> par unite
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => game.buy(gen.id, qty || 1)}
|
||||
disabled={!canAfford}
|
||||
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{#if buyMode === 'max' && qty > 1}
|
||||
{formatNumber(cost)} (x{qty})
|
||||
{:else if buyMode !== 1 && buyMode !== 'max'}
|
||||
{formatNumber(cost)} (x{buyMode})
|
||||
{:else}
|
||||
{formatNumber(cost)}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</CollapsiblePanel>
|
||||
23
Frontend/src/lib/components/MilestoneBar.svelte
Normal file
23
Frontend/src/lib/components/MilestoneBar.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import { getPrestigeThreshold } from '$lib/core/economy';
|
||||
|
||||
let threshold = $derived(getPrestigeThreshold(game.state));
|
||||
let progress = $derived(Math.min(game.state.resources / threshold, 1));
|
||||
let progressPercent = $derived((progress * 100).toFixed(1));
|
||||
let remaining = $derived(Math.max(threshold - game.state.resources, 0));
|
||||
</script>
|
||||
|
||||
<div class="gp gap-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Prochaine Generation</span>
|
||||
<span class="gp-label">{formatNumber(game.state.resources)} / {formatNumber(threshold)}</span>
|
||||
</div>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<span class="gp-label text-right">
|
||||
{remaining > 0 ? `${formatNumber(remaining)} restants` : 'Nouvelle Generation disponible !'}
|
||||
</span>
|
||||
</div>
|
||||
72
Frontend/src/lib/components/MilestonesPanel.svelte
Normal file
72
Frontend/src/lib/components/MilestonesPanel.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy';
|
||||
import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let claimable = $derived(getClaimableMilestones(game.state));
|
||||
let nextMilestone = $derived(getNextMilestone(game.state));
|
||||
let claimed = $derived(game.state.claimedMilestones ?? []);
|
||||
let totalClaimed = $derived(claimed.length);
|
||||
</script>
|
||||
|
||||
{#if game.state.prestigeCount >= 1}
|
||||
<CollapsiblePanel
|
||||
title="Milestones"
|
||||
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
|
||||
accentClass="gp-accent-amber"
|
||||
>
|
||||
{#if claimable.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each claimable as m, i}
|
||||
<div
|
||||
class="gp-row gp-row--evolution border-purple-400/30!"
|
||||
in:fly={{ y: 20, delay: i * 80, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="gp-value text-[0.7rem]!">{m.name}</span>
|
||||
<span class="gp-label">{m.reward.label}</span>
|
||||
</div>
|
||||
<button onclick={() => game.claimMilestone(m.id)} class="gp-btn gp-btn--buy">
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if nextMilestone}
|
||||
{@const progressPct = Math.min((game.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Prochain : {nextMilestone.name}</span>
|
||||
<span class="gp-label">{game.state.prestigeCount}/{nextMilestone.threshold}</span>
|
||||
</div>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400" style="width: {progressPct}%"></div>
|
||||
</div>
|
||||
<span class="gp-label">{nextMilestone.reward.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !nextMilestone && claimable.length === 0}
|
||||
<span class="gp-label text-center gp-accent-purple">Tous les milestones reclames !</span>
|
||||
{/if}
|
||||
|
||||
{#if totalClaimed > 0 && claimable.length === 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each PRESTIGE_MILESTONES.filter((m) => claimed.includes(m.id)) as m, i}
|
||||
<span
|
||||
class="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}"
|
||||
in:scale={{ delay: i * 40, duration: 200 }}
|
||||
>
|
||||
{m.threshold}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsiblePanel>
|
||||
{/if}
|
||||
43
Frontend/src/lib/components/Navbar.svelte
Normal file
43
Frontend/src/lib/components/Navbar.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Jeu', url: '/jeu' },
|
||||
{ name: 'Succes', url: '/achievements' },
|
||||
{ name: 'Guide', url: '/guide' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<header class="header-main" role="banner">
|
||||
<a href="/" aria-label="Accueil Clickerz">
|
||||
<img class="logo" alt="Clickerz" />
|
||||
</a>
|
||||
<nav class="navbar" aria-label="Navigation principale">
|
||||
<ul class="nav-list" role="list">
|
||||
{#each navLinks as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
class="mainLink"
|
||||
aria-current={page.url.pathname === link.url ? 'page' : undefined}
|
||||
style={page.url.pathname === link.url ? 'color: var(--color-red-light); font-weight: 600;' : ''}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="auth-nav">
|
||||
{#if authStore.loading}
|
||||
<span class="auth-nickname" aria-live="polite">...</span>
|
||||
{:else if authStore.user}
|
||||
<span class="auth-nickname">{authStore.user.nickname}</span>
|
||||
<a href="/settings" class="auth-btn">Profil</a>
|
||||
<button class="auth-btn" onclick={() => authStore.logout()} type="button">Deconnexion</button>
|
||||
{:else}
|
||||
<a href="/login" class="auth-btn">Connexion</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
61
Frontend/src/lib/components/OfflineReport.svelte
Normal file
61
Frontend/src/lib/components/OfflineReport.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly, scale } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}h${minutes % 60}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && game.offlineReport) game.dismissOfflineReport(); }} />
|
||||
|
||||
{#if game.offlineReport}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);"
|
||||
transition:fade={{ duration: 250 }}
|
||||
onclick={() => game.dismissOfflineReport()}
|
||||
>
|
||||
<div
|
||||
class="gp max-w-sm w-full mx-4 text-center"
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
in:scale={{ duration: 400, start: 0.8, easing: backOut }}
|
||||
out:scale={{ duration: 200 }}
|
||||
>
|
||||
<div in:fly={{ y: -15, delay: 100, duration: 350, easing: quintOut }}>
|
||||
<h2 class="gp-title text-lg!">Retour au Marais</h2>
|
||||
<p class="gp-label mt-2">
|
||||
Absent pendant <span class="gp-accent-green">{formatDuration(game.offlineReport.duration)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}>
|
||||
<p
|
||||
class="gp-value text-3xl! mt-4 mb-2 gp-accent-green"
|
||||
style="text-shadow: 0 0 15px rgba(52,211,153,0.3);"
|
||||
>
|
||||
+{formatNumber(game.offlineReport.gains)} tetards
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
|
||||
Efficacite : {Math.round(game.offlineReport.efficiency * 100)}%
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
|
||||
onclick={() => game.dismissOfflineReport()}
|
||||
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
|
||||
>
|
||||
Continuer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
35
Frontend/src/lib/components/PrestigePanel.svelte
Normal file
35
Frontend/src/lib/components/PrestigePanel.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let baseDna = $derived(computePrestigeDna(game.state.lifetimeTadpoles, game.state.prestigeCount));
|
||||
let dnaBonus = $derived(getPrestigeDnaBonus(game.state.evolutionTree));
|
||||
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
|
||||
let threshold = $derived(getPrestigeThreshold(game.state));
|
||||
let progress = $derived(Math.min(game.state.lifetimeTadpoles / threshold * 100, 100));
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
|
||||
{#if game.canPrestige}
|
||||
<div class="flex flex-col gap-2" in:scale={{ duration: 300, start: 0.9, easing: quintOut }}>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="gp-value gp-accent-purple">+{dnaPreview} ADN</span>
|
||||
<span class="gp-label">+0.1x mult</span>
|
||||
</div>
|
||||
<button onclick={() => game.openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="gp-label">Atteins {formatNumber(threshold)} tetards</span>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progress.toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsiblePanel>
|
||||
162
Frontend/src/lib/components/PrestigeScreen.svelte
Normal file
162
Frontend/src/lib/components/PrestigeScreen.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, backOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/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`;
|
||||
}
|
||||
|
||||
let baseDna = $derived(computePrestigeDna(game.state.lifetimeTadpoles, game.state.prestigeCount));
|
||||
let dnaBonus = $derived(getPrestigeDnaBonus(game.state.evolutionTree));
|
||||
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
|
||||
let threshold = $derived(getPrestigeThreshold(game.state));
|
||||
let canPrestige = $derived(game.state.lifetimeTadpoles >= threshold);
|
||||
let runDuration = $derived(Date.now() - game.state.runStats.startedAt);
|
||||
let bestRun = $derived(game.state.runStats.bestRun);
|
||||
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
|
||||
let isBestTadpoles = $derived(!bestRun || game.state.lifetimeTadpoles > bestRun.tadpoles);
|
||||
|
||||
function handlePrestige() {
|
||||
if (canPrestige) game.prestige();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && game.showPrestigeScreen) game.closePrestige(); }} />
|
||||
|
||||
{#if game.showPrestigeScreen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.85); backdrop-filter: blur(8px);"
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Modal card -->
|
||||
<div
|
||||
class="gp max-w-md w-full mx-4"
|
||||
in:scale={{ duration: 400, start: 0.85, easing: backOut }}
|
||||
out:scale={{ duration: 200, start: 0.95 }}
|
||||
>
|
||||
<!-- Header with generation number -->
|
||||
<div class="text-center" in:fly={{ y: -20, delay: 100, duration: 400, easing: quintOut }}>
|
||||
<span class="gp-title text-lg!">Nouvelle Generation</span>
|
||||
<p class="gp-label mt-1">Generation #{game.state.prestigeCount + 1}</p>
|
||||
</div>
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- ADN Preview — the hero number -->
|
||||
<div
|
||||
class="flex flex-col items-center gap-1 py-3"
|
||||
in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}
|
||||
>
|
||||
<span class="gp-label">ADN Ancestral</span>
|
||||
<span
|
||||
class="text-4xl font-extrabold"
|
||||
style="color: #a78bfa; font-family: var(--font); text-shadow: 0 0 20px rgba(167,139,250,0.4);"
|
||||
>
|
||||
+{formatNumber(dnaPreview)}
|
||||
</span>
|
||||
{#if dnaBonus > 0}
|
||||
<span class="gp-label">(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)</span>
|
||||
{/if}
|
||||
<span class="gp-label mt-1">Total apres : {formatNumber(game.state.ancestralDna + dnaPreview)} ADN</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- Run Stats -->
|
||||
<div class="flex flex-col gap-2" in:fly={{ y: 20, delay: 300, duration: 400, easing: quintOut }}>
|
||||
<span class="gp-zone-label">Stats de la run</span>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Duree</span>
|
||||
<span class="gp-value">{formatDuration(runDuration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Tetards produits</span>
|
||||
<span class="gp-value {isBestTadpoles ? 'gp-accent-green' : ''}">
|
||||
{formatNumber(game.state.lifetimeTadpoles)}
|
||||
{#if isBestTadpoles && bestRun} ★{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">ADN cette run</span>
|
||||
<span class="gp-value {isBestAdn ? 'gp-accent-green' : ''}">
|
||||
{formatNumber(dnaPreview)}
|
||||
{#if isBestAdn && bestRun} ★{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if bestRun}
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Vitesse vs meilleure</span>
|
||||
<span class="gp-value {runDuration < bestRun.duration ? 'gp-accent-green' : 'gp-accent-amber'}">
|
||||
{#if runDuration < bestRun.duration}
|
||||
{Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide
|
||||
{:else if runDuration > bestRun.duration}
|
||||
{Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent
|
||||
{:else}
|
||||
identique
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if bestRun}
|
||||
<div class="gp-sep"></div>
|
||||
<div class="flex flex-col gap-1" in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}>
|
||||
<span class="gp-zone-label">Meilleure run</span>
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Duree</span>
|
||||
<span class="gp-value">{formatDuration(bestRun.duration)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">ADN</span>
|
||||
<span class="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- Reset info -->
|
||||
<div class="text-center" in:fade={{ delay: 350, duration: 300 }}>
|
||||
<p class="gp-label">Tetards et generateurs remis a zero.</p>
|
||||
<p class="gp-label">Arbre d'Evolution et cosmetiques conserves.</p>
|
||||
<p class="gp-label mt-1 gp-accent-green">+1 reset d'arbre gratuit offert.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-1" in:fly={{ y: 20, delay: 450, duration: 300, easing: quintOut }}>
|
||||
<button
|
||||
onclick={() => game.closePrestige()}
|
||||
class="gp-btn flex-1 py-2.5! text-[0.8rem]!"
|
||||
style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6);"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
{#if canPrestige}
|
||||
<button onclick={handlePrestige} class="gp-btn gp-btn--prestige flex-1 py-2.5! text-[0.8rem]!">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
{:else}
|
||||
<button class="gp-btn gp-btn--disabled flex-1 py-2.5!" disabled>
|
||||
{formatNumber(threshold - game.state.lifetimeTadpoles)} manquants
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
59
Frontend/src/lib/components/SidebarTabs.svelte
Normal file
59
Frontend/src/lib/components/SidebarTabs.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab?: string;
|
||||
children: Snippet<[string]>;
|
||||
}
|
||||
|
||||
let { tabs, activeTab = tabs[0]?.id ?? '', children }: Props = $props();
|
||||
|
||||
let current = $state(activeTab);
|
||||
let direction = $state(1);
|
||||
|
||||
function switchTab(tabId: string) {
|
||||
const oldIdx = tabs.findIndex(t => t.id === current);
|
||||
const newIdx = tabs.findIndex(t => t.id === tabId);
|
||||
direction = newIdx > oldIdx ? 1 : -1;
|
||||
current = tabId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-0.5 p-0.5 rounded-lg" style="background: rgba(255,255,255,0.04);">
|
||||
{#each tabs as tab}
|
||||
{@const isActive = current === tab.id}
|
||||
<button
|
||||
onclick={() => switchTab(tab.id)}
|
||||
class="flex-1 flex items-center justify-center gap-1 py-2 px-1 rounded-md text-[0.7rem] font-semibold uppercase tracking-wider transition-all duration-200"
|
||||
style={isActive
|
||||
? 'background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.95); box-shadow: 0 1px 3px rgba(0,0,0,0.3);'
|
||||
: 'background: transparent; color: rgba(255,255,255,0.4);'
|
||||
}
|
||||
style:font-family="var(--font)"
|
||||
>
|
||||
<span class="text-sm">{tab.icon}</span>
|
||||
<span class="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tab content with directional fly -->
|
||||
{#key current}
|
||||
<div
|
||||
in:fly={{ x: 30 * direction, duration: 200, easing: quintOut }}
|
||||
out:fly={{ x: -30 * direction, duration: 150, easing: quintOut }}
|
||||
class="flex flex-col gap-[var(--spacing-gp-gap)]"
|
||||
>
|
||||
{@render children(current)}
|
||||
</div>
|
||||
{/key}
|
||||
66
Frontend/src/lib/components/TadpoleSprite.svelte
Normal file
66
Frontend/src/lib/components/TadpoleSprite.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
|
||||
|
||||
const SLOT_ORDER: CosmeticSlot[] = ['body', 'tail', 'eyes', 'hat', 'accessory'];
|
||||
|
||||
let overlays = $derived(
|
||||
SLOT_ORDER
|
||||
.map((slot) => {
|
||||
const cosId = game.state.cosmeticEquipped[slot];
|
||||
if (!cosId) return null;
|
||||
return COSMETICS.find((c) => c.id === cosId) ?? null;
|
||||
})
|
||||
.filter((c) => c !== null)
|
||||
);
|
||||
|
||||
// Click bounce animation
|
||||
let bouncing = $state(false);
|
||||
|
||||
export function bounce() {
|
||||
bouncing = true;
|
||||
setTimeout(() => bouncing = false, 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px] transition-transform duration-100"
|
||||
class:scale-[0.92]={bouncing}
|
||||
class:rotate-[3deg]={bouncing}
|
||||
style="filter: drop-shadow(0 0 20px rgba(52,211,153,0.15));"
|
||||
>
|
||||
<!-- Base sprite -->
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Tetard"
|
||||
class="w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- Cosmetic overlays -->
|
||||
{#each overlays as cos}
|
||||
<img
|
||||
src={cos.svg}
|
||||
alt={cos.name}
|
||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Glow ring on click -->
|
||||
{#if bouncing}
|
||||
<div
|
||||
class="absolute inset-0 rounded-full"
|
||||
style="
|
||||
background: radial-gradient(circle, rgba(52,211,153,0.15) 0%, transparent 70%);
|
||||
animation: click-ring 0.3s ease-out;
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes click-ring {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
36
Frontend/src/lib/components/ToastContainer.svelte
Normal file
36
Frontend/src/lib/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
import { getToasts } from '$lib/stores/toast.svelte';
|
||||
|
||||
const variantStyles: Record<string, { color: string; border: string; bg: string }> = {
|
||||
success: { color: '#34d399', border: 'rgba(52,211,153,0.3)', bg: 'rgba(16,185,129,0.08)' },
|
||||
info: { color: '#60a5fa', border: 'rgba(96,165,250,0.3)', bg: 'rgba(59,130,246,0.08)' },
|
||||
reward: { color: '#fbbf24', border: 'rgba(251,191,36,0.3)', bg: 'rgba(245,158,11,0.08)' },
|
||||
warning: { color: '#f87171', border: 'rgba(248,113,113,0.3)', bg: 'rgba(239,68,68,0.08)' },
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if getToasts().length > 0}
|
||||
<div class="fixed top-24 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
{#each getToasts() as t (t.id)}
|
||||
{@const style = variantStyles[t.variant] || variantStyles.info}
|
||||
<div
|
||||
class="pointer-events-auto px-4 py-2.5 rounded-xl font-semibold text-sm shadow-2xl"
|
||||
style="
|
||||
background: rgba(17,17,17,0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid {style.border};
|
||||
color: {style.color};
|
||||
font-family: var(--font);
|
||||
box-shadow: 0 0 20px {style.bg}, 0 8px 32px rgba(0,0,0,0.4);
|
||||
"
|
||||
in:fly={{ x: 80, duration: 350, easing: backOut }}
|
||||
out:scale={{ duration: 200, start: 0.95, opacity: 0 }}
|
||||
role="alert"
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
68
Frontend/src/lib/core/balance.ts
Normal file
68
Frontend/src/lib/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/lib/core/cosmetics.ts
Normal file
97
Frontend/src/lib/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] };
|
||||
}
|
||||
908
Frontend/src/lib/core/economy.ts
Normal file
908
Frontend/src/lib/core/economy.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
// economy.ts — Core clicker logic (lazy calculation pattern)
|
||||
// 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
baseCost: number;
|
||||
baseProduction: number; // ressource/s
|
||||
owned: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
cost: number; // en ADN Ancestral (base cost for repeatables)
|
||||
effect: EffectType;
|
||||
value: number;
|
||||
unlocked: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Click Upgrades (achetables en têtards, liés aux générateurs) ---
|
||||
|
||||
export interface ClickUpgrade {
|
||||
id: string;
|
||||
name: string;
|
||||
generatorId: string; // lié à quel générateur
|
||||
baseClickPower: number; // bonus clic par niveau
|
||||
baseCost: number; // coût de base
|
||||
level: number; // niveaux achetés
|
||||
}
|
||||
|
||||
export const DEFAULT_CLICK_UPGRADES: ClickUpgrade[] = [
|
||||
{ id: "nid_douillet", name: "Nid Douillet", generatorId: "nid", baseClickPower: 1, baseCost: 50, level: 0 },
|
||||
{ id: "eau_fertile", name: "Eau Fertile", generatorId: "mare", baseClickPower: 3, baseCost: 500, level: 0 },
|
||||
{ id: "spores_actives", name: "Spores Actives", generatorId: "marecage", baseClickPower: 8, baseCost: 5_000, level: 0 },
|
||||
{ id: "courant_vital", name: "Courant Vital", generatorId: "etang", baseClickPower: 20, baseCost: 50_000, level: 0 },
|
||||
{ id: "source_ancestrale", name: "Source Ancestrale", generatorId: "lac", baseClickPower: 50, baseCost: 500_000, level: 0 },
|
||||
];
|
||||
|
||||
export function clickUpgradeCost(upgrade: ClickUpgrade): number {
|
||||
return Math.floor(upgrade.baseCost * Math.pow(1.2, upgrade.level));
|
||||
}
|
||||
|
||||
export function totalClickUpgradePower(clickUpgrades: ClickUpgrade[]): number {
|
||||
return clickUpgrades.reduce((sum, u) => sum + u.baseClickPower * u.level, 0);
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
saveVersion: number;
|
||||
resources: number;
|
||||
clickMultiplier: number;
|
||||
generators: Generator[];
|
||||
clickUpgrades: ClickUpgrade[];
|
||||
lastTick: number; // timestamp ms — lazy calc reference
|
||||
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
|
||||
prestigeCount: number;
|
||||
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
|
||||
ancestralDna: number;
|
||||
evolutionTree: EvolutionNode[];
|
||||
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 ---
|
||||
|
||||
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
|
||||
// ═══ PONTE (click) — 10 nœuds ═══
|
||||
|
||||
// Tier 1
|
||||
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" },
|
||||
// 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" },
|
||||
];
|
||||
|
||||
// Formule ADN Sprint 3 : max(1, floor(base × log10(t / threshold) × (1 + bonus)))
|
||||
// Clamp min 1 si seuil atteint, cap bonus ×4 à 80 prestiges
|
||||
|
||||
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[] {
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
return PRESTIGE_MILESTONES.filter(
|
||||
(m) => state.prestigeCount >= m.threshold && !claimed.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;
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes(milestoneId)) return null;
|
||||
|
||||
let newState = {
|
||||
...state,
|
||||
claimedMilestones: [...claimed, 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é
|
||||
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId);
|
||||
if (!node) 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) {
|
||||
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
||||
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;
|
||||
}
|
||||
|
||||
// Achète un nœud d'évolution (retourne null si impossible)
|
||||
export function buyEvolutionNode(state: GameState, nodeId: string): GameState | null {
|
||||
if (!canBuyEvolutionNode(state, nodeId)) return null;
|
||||
|
||||
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 {
|
||||
...state,
|
||||
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) =>
|
||||
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
|
||||
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "click_multiplier")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Calcule le multiplicateur production total depuis l'arbre
|
||||
export function getProductionMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "production_multiplier")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Bonus de départ (têtards offerts au début de chaque run)
|
||||
export function getStartBonusFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "start_bonus")
|
||||
.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) ---
|
||||
|
||||
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned × (1 - costReduction)
|
||||
export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
|
||||
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 effective d'un seul générateur (avec tous les bonus appliqués)
|
||||
export function generatorEffectiveProduction(gen: Generator, state: GameState): number {
|
||||
if (gen.owned === 0) return 0;
|
||||
const nidBoost = gen.id === "nid" ? getGeneratorBoostFromTree(state.evolutionTree) : 1;
|
||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||||
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
|
||||
return gen.baseProduction * gen.owned * nidBoost * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
|
||||
}
|
||||
|
||||
// Production totale par seconde de tous les générateurs
|
||||
export function totalProductionPerSecond(state: GameState): number {
|
||||
return state.generators.reduce((sum, gen) => sum + generatorEffectiveProduction(gen, state), 0);
|
||||
}
|
||||
|
||||
// Lazy calculation : ressources accumulées depuis lastTick
|
||||
export function computeIdleGains(state: GameState, now: number): number {
|
||||
const elapsedSeconds = (now - state.lastTick) / 1000;
|
||||
return totalProductionPerSecond(state) * elapsedSeconds;
|
||||
}
|
||||
|
||||
// Applique les gains idle et met à jour lastTick
|
||||
export function applyIdleGains(state: GameState, now: number): GameState {
|
||||
const gains = computeIdleGains(state, now);
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources + gains,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||||
lastTick: now,
|
||||
runStats: {
|
||||
...state.runStats,
|
||||
tadpolesProduced: state.runStats.tadpolesProduced + gains,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Bonus clic depuis les générateurs (diversité + quantité)
|
||||
export function getGeneratorClickBonus(generators: Generator[]): number {
|
||||
const typesOwned = generators.filter((g) => g.owned > 0).length;
|
||||
const totalOwned = generators.reduce((sum, g) => sum + g.owned, 0);
|
||||
return 1 + typesOwned * 2 + totalOwned * 0.05;
|
||||
}
|
||||
|
||||
// Gain par clic — base + upgrades, le tout multiplié par prestige × arbre × infra
|
||||
export function getClickGain(state: GameState): number {
|
||||
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||
const genBonus = getGeneratorClickBonus(state.generators);
|
||||
const upgradePower = totalClickUpgradePower(state.clickUpgrades ?? []);
|
||||
const base = state.clickMultiplier + upgradePower;
|
||||
return Math.floor(base * state.prestigeMultiplier * treeClickMult * genBonus);
|
||||
}
|
||||
|
||||
// Achat d'un click upgrade (coûte des têtards)
|
||||
export function buyClickUpgrade(state: GameState, upgradeId: string): GameState | null {
|
||||
const idx = (state.clickUpgrades ?? []).findIndex((u) => u.id === upgradeId);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const upgrade = state.clickUpgrades[idx];
|
||||
// Requires owning the linked generator
|
||||
const gen = state.generators.find((g) => g.id === upgrade.generatorId);
|
||||
if (!gen || gen.owned === 0) return null;
|
||||
|
||||
const cost = clickUpgradeCost(upgrade);
|
||||
if (state.resources < cost) return null;
|
||||
|
||||
const updatedUpgrades = [...state.clickUpgrades];
|
||||
updatedUpgrades[idx] = { ...upgrade, level: upgrade.level + 1 };
|
||||
|
||||
return { ...state, resources: state.resources - cost, clickUpgrades: updatedUpgrades };
|
||||
}
|
||||
|
||||
// Breakdown complet du clic (pour affichage cockpit)
|
||||
export interface ClickBreakdown {
|
||||
base: number;
|
||||
prestigeMult: number;
|
||||
treeMult: number;
|
||||
genBonus: number; // multiplicateur depuis generateurs (types + quantite)
|
||||
genTypes: number; // types possedes
|
||||
genTotal: number; // total unites possedees
|
||||
total: number; // gain par clic (floor)
|
||||
doubleChance: number;
|
||||
critChance: number;
|
||||
autoClicksPerSec: number;
|
||||
effectivePerSec: number;
|
||||
}
|
||||
|
||||
export function getClickBreakdown(state: GameState): ClickBreakdown {
|
||||
const upgradePower = totalClickUpgradePower(state.clickUpgrades ?? []);
|
||||
const base = state.clickMultiplier + upgradePower;
|
||||
const prestigeMult = state.prestigeMultiplier;
|
||||
const treeMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||
const genBonus = getGeneratorClickBonus(state.generators);
|
||||
const genTypes = state.generators.filter((g) => g.owned > 0).length;
|
||||
const genTotal = state.generators.reduce((sum, g) => sum + g.owned, 0);
|
||||
const total = Math.floor(base * prestigeMult * treeMult * genBonus);
|
||||
const doubleChance = getDoubleClickChance(state.evolutionTree);
|
||||
const critChance = getCritClickChance(state.evolutionTree);
|
||||
const autoClicksPerSec = getAutoClicksPerSecond(state.evolutionTree);
|
||||
const effectivePerSec = autoClicksPerSec * total;
|
||||
|
||||
return { base, prestigeMult, treeMult, genBonus, genTypes, genTotal, total, doubleChance, critChance, autoClicksPerSec, effectivePerSec };
|
||||
}
|
||||
|
||||
export interface ClickResult {
|
||||
state: GameState;
|
||||
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 {
|
||||
state: {
|
||||
...state,
|
||||
resources: state.resources + gain,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||||
runStats: {
|
||||
...state.runStats,
|
||||
tadpolesProduced: state.runStats.tadpolesProduced + gain,
|
||||
},
|
||||
},
|
||||
gain,
|
||||
isDouble,
|
||||
isCrit,
|
||||
};
|
||||
}
|
||||
|
||||
// Achat d'un générateur (retourne null si fonds insuffisants)
|
||||
export function buyGenerator(state: GameState, genId: string, quantity = 1): GameState | null {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return null;
|
||||
|
||||
let gen = { ...state.generators[genIndex] };
|
||||
let resources = state.resources;
|
||||
|
||||
let bought = 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (resources < cost) break;
|
||||
resources -= cost;
|
||||
gen = { ...gen, owned: gen.owned + 1 };
|
||||
bought++;
|
||||
}
|
||||
|
||||
if (bought === 0) return null;
|
||||
|
||||
const updatedGenerators = [...state.generators];
|
||||
updatedGenerators[genIndex] = gen;
|
||||
|
||||
return { ...state, resources, generators: updatedGenerators };
|
||||
}
|
||||
|
||||
// Calcule combien d'unités on peut acheter avec les ressources actuelles
|
||||
export function maxAffordable(state: GameState, genId: string): number {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return 0;
|
||||
|
||||
let gen = { ...state.generators[genIndex] };
|
||||
let resources = state.resources;
|
||||
let count = 0;
|
||||
|
||||
while (true) {
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (resources < cost) break;
|
||||
resources -= cost;
|
||||
gen = { ...gen, owned: gen.owned + 1 };
|
||||
count++;
|
||||
if (count > 1000) break; // safety
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Cout total pour acheter N unités
|
||||
export function bulkCost(state: GameState, genId: string, quantity: number): number {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return Infinity;
|
||||
|
||||
let gen = { ...state.generators[genIndex] };
|
||||
let total = 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
total += generatorCost(gen, state.evolutionTree);
|
||||
gen = { ...gen, owned: gen.owned + 1 };
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Prestige : reset run, gain ADN, arbre persiste
|
||||
|
||||
export function getPrestigeThreshold(state: GameState): number {
|
||||
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
|
||||
const scaling = Math.pow(1 + 0.1 * state.prestigeCount, 2);
|
||||
return Math.floor(BASE_PRESTIGE_THRESHOLD * scaling * (1 - reduction));
|
||||
}
|
||||
|
||||
export function canPrestige(state: GameState): boolean {
|
||||
return state.resources >= getPrestigeThreshold(state);
|
||||
}
|
||||
|
||||
export function applyPrestige(state: GameState): GameState {
|
||||
const newPrestigeCount = state.prestigeCount + 1;
|
||||
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
|
||||
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
|
||||
const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
|
||||
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 {
|
||||
...state,
|
||||
resources: startBonus,
|
||||
generators: state.generators.map((g) => ({
|
||||
...g,
|
||||
owned:
|
||||
(hasUnlockGen && g.id === "lac") ? 1 :
|
||||
(milestoneNid > 0 && g.id === "nid") ? milestoneNid :
|
||||
0,
|
||||
})),
|
||||
prestigeCount: newPrestigeCount,
|
||||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||||
ancestralDna: state.ancestralDna + dnaGained,
|
||||
lifetimeTadpoles: 0,
|
||||
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,
|
||||
// Click upgrades reset au prestige (comme les générateurs)
|
||||
clickUpgrades: (state.clickUpgrades ?? DEFAULT_CLICK_UPGRADES).map((u) => ({ ...u, level: 0 })),
|
||||
// evolutionTree persiste — jamais reset
|
||||
};
|
||||
}
|
||||
|
||||
// Valeurs par défaut — 5 tiers alignés GDD Tetard Universe (x10 coût / tier)
|
||||
export const DEFAULT_GENERATORS: Generator[] = [
|
||||
{ id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 },
|
||||
{ id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 },
|
||||
{ id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 },
|
||||
{ id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 },
|
||||
{ id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 },
|
||||
];
|
||||
|
||||
export const DEFAULT_STATE: GameState = {
|
||||
saveVersion: CURRENT_SAVE_VERSION,
|
||||
resources: 0,
|
||||
clickMultiplier: 1,
|
||||
generators: DEFAULT_GENERATORS,
|
||||
clickUpgrades: DEFAULT_CLICK_UPGRADES,
|
||||
lastTick: Date.now(),
|
||||
lastOnline: Date.now(),
|
||||
prestigeCount: 0,
|
||||
prestigeMultiplier: 1,
|
||||
ancestralDna: 0,
|
||||
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||||
lifetimeTadpoles: 0,
|
||||
cosmeticInventory: [],
|
||||
cosmeticEquipped: {},
|
||||
runStats: {
|
||||
startedAt: Date.now(),
|
||||
tadpolesProduced: 0,
|
||||
bestRun: null,
|
||||
},
|
||||
freeResetAvailable: true,
|
||||
extraResetsUsed: 0,
|
||||
claimedMilestones: [],
|
||||
};
|
||||
178
Frontend/src/lib/core/migrateSave.ts
Normal file
178
Frontend/src/lib/core/migrateSave.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// 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, ClickUpgrade } from "./economy";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS, DEFAULT_CLICK_UPGRADES } 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);
|
||||
|
||||
// Always rebuild tree & generators from defaults — the server/localStorage
|
||||
// may not store all fields (branch, cost, effect, baseProduction, etc.)
|
||||
state.evolutionTree = mergeEvolutionTree(
|
||||
state.evolutionTree as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
state.generators = mergeGenerators(
|
||||
state.generators as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
|
||||
// Click upgrades — merge with defaults (preserves levels, adds new upgrades)
|
||||
state.clickUpgrades = mergeClickUpgrades(
|
||||
state.clickUpgrades as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge les click upgrades sauvegardés avec DEFAULT_CLICK_UPGRADES.
|
||||
* Conserve le level, met à jour les stats de base.
|
||||
*/
|
||||
function mergeClickUpgrades(
|
||||
saved: Array<Record<string, unknown>> | undefined
|
||||
): ClickUpgrade[] {
|
||||
if (!saved || !Array.isArray(saved)) {
|
||||
return DEFAULT_CLICK_UPGRADES.map((u) => ({ ...u }));
|
||||
}
|
||||
|
||||
const savedById = new Map(saved.map((u) => [u.id as string, u]));
|
||||
|
||||
return DEFAULT_CLICK_UPGRADES.map((def) => {
|
||||
const s = savedById.get(def.id);
|
||||
if (s) {
|
||||
return { ...def, level: typeof s.level === "number" ? s.level : 0 };
|
||||
}
|
||||
return { ...def };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// achievements.ts — Milestones Clickerz basés sur le GameState réel
|
||||
import { GameState } from "../core/economy";
|
||||
import type { GameState } from "../core/economy";
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
@@ -165,9 +165,12 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
{
|
||||
id: "full_tree",
|
||||
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: "🌳",
|
||||
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 ---
|
||||
@@ -211,6 +214,6 @@ export const ACHIEVEMENTS: Achievement[] = [
|
||||
name: "Le Cercle de la Vie",
|
||||
description: "Symbiose activée. Même Mufasa serait fier.",
|
||||
icon: "🦁",
|
||||
check: (s) => hasEvolutionNode(s, "symbiose"),
|
||||
check: (s) => hasEvolutionNode(s, "symbiose_algale"),
|
||||
},
|
||||
];
|
||||
76
Frontend/src/lib/data/prestigeMilestones.ts
Normal file
76
Frontend/src/lib/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)" },
|
||||
},
|
||||
];
|
||||
1
Frontend/src/lib/index.ts
Normal file
1
Frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,83 +0,0 @@
|
||||
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
|
||||
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
|
||||
|
||||
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
|
||||
|
||||
function base64UrlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier) {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function buildAuthUrl(redirectUri, provider, scope = 'openid profile email', clientId = OAUTH_CLIENT_ID) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
provider,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
|
||||
verifier,
|
||||
};
|
||||
}
|
||||
|
||||
export async function exchangeCode(code, verifier, redirectUri, clientId = OAUTH_CLIENT_ID) {
|
||||
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) throw new Error('No access_token in OAuth response');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveVerifier(verifier) {
|
||||
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
|
||||
}
|
||||
|
||||
export function loadVerifier() {
|
||||
return localStorage.getItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
|
||||
export function clearVerifier() {
|
||||
localStorage.removeItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
89
Frontend/src/lib/oauth.ts
Normal file
89
Frontend/src/lib/oauth.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
|
||||
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
|
||||
|
||||
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function buildAuthUrl(
|
||||
redirectUri: string,
|
||||
provider: string,
|
||||
scope = 'openid profile email',
|
||||
clientId = OAUTH_CLIENT_ID
|
||||
) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
provider,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return { url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`, verifier };
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
clientId = OAUTH_CLIENT_ID
|
||||
) {
|
||||
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) throw new Error('No access_token in OAuth response');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveVerifier(verifier: string) {
|
||||
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
|
||||
}
|
||||
|
||||
export function loadVerifier(): string | null {
|
||||
return localStorage.getItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
|
||||
export function clearVerifier() {
|
||||
localStorage.removeItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
127
Frontend/src/lib/save-sync.svelte.ts
Normal file
127
Frontend/src/lib/save-sync.svelte.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// save-sync.ts — Auto-save game state to backend every 30s
|
||||
// Server = authority. NEVER save before server state is loaded (ready guard).
|
||||
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { migrateSave } from '$lib/core/migrateSave';
|
||||
import type { GameState } from '$lib/core/economy';
|
||||
|
||||
const SAVE_INTERVAL_MS = 30_000;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[SaveSync] ${path} failed:`, res.status);
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
let lastSave: string | null = null;
|
||||
let loaded = false;
|
||||
let saveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Snapshot the $state proxy into a plain object for serialization
|
||||
function snapshotState(): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(game.state));
|
||||
}
|
||||
|
||||
export async function saveToServer() {
|
||||
if (!authStore.user || !game.ready) return;
|
||||
const result = await apiRequest('/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
gameState: snapshotState(),
|
||||
playTimeSeconds: game.playSeconds,
|
||||
}),
|
||||
});
|
||||
if (result?.lastSave) {
|
||||
lastSave = result.lastSave;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFromServer(): Promise<boolean> {
|
||||
if (loaded || !authStore.user) {
|
||||
if (!authStore.user) loaded = true;
|
||||
return false;
|
||||
}
|
||||
loaded = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest('/save');
|
||||
if (data?.gameState) {
|
||||
const migrated = migrateSave(data.gameState);
|
||||
game.loadFromServer(migrated);
|
||||
lastSave = data.lastSave;
|
||||
console.info('[SaveSync] Loaded save from server (v%d)', migrated.saveVersion);
|
||||
return true;
|
||||
}
|
||||
console.info('[SaveSync] No server save found');
|
||||
return false;
|
||||
} catch {
|
||||
console.warn('[SaveSync] Server unreachable');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoSave() {
|
||||
stopAutoSave();
|
||||
saveInterval = setInterval(() => {
|
||||
if (authStore.user && game.ready) saveToServer();
|
||||
}, SAVE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopAutoSave() {
|
||||
if (saveInterval) {
|
||||
clearInterval(saveInterval);
|
||||
saveInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupVisibilitySync() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
if (!authStore.user) return;
|
||||
setTimeout(async () => {
|
||||
const data = await apiRequest('/save');
|
||||
if (data?.gameState && data.lastSave) {
|
||||
if (!lastSave || new Date(data.lastSave) > new Date(lastSave)) {
|
||||
const migrated = migrateSave(data.gameState);
|
||||
game.loadFromServer(migrated);
|
||||
lastSave = data.lastSave;
|
||||
console.info('[SaveSync] Reloaded from server on focus');
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
if (authStore.user && game.ready) saveToServer();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (!authStore.user || !game.ready) return;
|
||||
const payload = JSON.stringify({
|
||||
gameState: snapshotState(),
|
||||
playTimeSeconds: game.playSeconds,
|
||||
});
|
||||
fetch(`${BACKEND_URL}/api/save`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
export function resetSaveSync() {
|
||||
loaded = false;
|
||||
lastSave = null;
|
||||
}
|
||||
64
Frontend/src/lib/stores/auth.svelte.ts
Normal file
64
Frontend/src/lib/stores/auth.svelte.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// auth.svelte.ts — Auth store (Svelte 5 runes)
|
||||
// Cookie-based auth with SuperOAuth PKCE
|
||||
|
||||
import { apiFetch } from '$lib/api';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await apiFetch('/auth/me');
|
||||
user = data as User;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await refresh();
|
||||
loading = false;
|
||||
|
||||
// Listen for expired session
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('auth:expired', () => {
|
||||
user = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiFetch('/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
user = null;
|
||||
}
|
||||
|
||||
async function editUser(updatedFields: Record<string, unknown>) {
|
||||
if (!user) return;
|
||||
const data = await apiFetch(`/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updatedFields),
|
||||
});
|
||||
if (data?.user) {
|
||||
user = { ...user, ...data.user };
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
get user() { return user; },
|
||||
get loading() { return loading; },
|
||||
init,
|
||||
refresh,
|
||||
logout,
|
||||
editUser,
|
||||
};
|
||||
228
Frontend/src/lib/stores/game.svelte.ts
Normal file
228
Frontend/src/lib/stores/game.svelte.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// game.svelte.ts — Game store (Svelte 5 class pattern)
|
||||
// Server = authority. localStorage = fallback guest only.
|
||||
// Pattern: singleton class with $state fields — the officially recommended
|
||||
// Svelte 5 approach for shared reactive state across components.
|
||||
|
||||
import {
|
||||
type GameState,
|
||||
DEFAULT_STATE,
|
||||
applyIdleGains,
|
||||
applyClick,
|
||||
getClickGain,
|
||||
getAutoClicksPerSecond,
|
||||
buyGenerator,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
canResetTree,
|
||||
upgradeConvergence,
|
||||
claimMilestone as claimMilestoneFn,
|
||||
applyPrestige,
|
||||
canPrestige as canPrestigeCheck,
|
||||
totalProductionPerSecond,
|
||||
generatorCost as genCost,
|
||||
computeOfflineGains,
|
||||
buyClickUpgrade as buyClickUpgradeFn,
|
||||
} from '$lib/core/economy';
|
||||
import { migrateSave } from '$lib/core/migrateSave';
|
||||
import { toast } from './toast.svelte';
|
||||
import {
|
||||
computeNewUnlocks,
|
||||
equipCosmetic as equipCosmeticFn,
|
||||
unequipSlot as unequipSlotFn,
|
||||
addToInventory,
|
||||
type CosmeticSlot,
|
||||
} from '$lib/core/cosmetics';
|
||||
|
||||
const SAVE_KEY = 'clickerz_state';
|
||||
const OFFLINE_THRESHOLD = 60_000;
|
||||
|
||||
export interface OfflineReport {
|
||||
wasOffline: boolean;
|
||||
duration: number;
|
||||
gains: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
class Game {
|
||||
// --- Reactive fields ---
|
||||
state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
|
||||
playSeconds = $state(0);
|
||||
ready = $state(false);
|
||||
offlineReport = $state<OfflineReport | null>(null);
|
||||
showPrestigeScreen = $state(false);
|
||||
lastClickGain = $state(0);
|
||||
lastClickDouble = $state(false);
|
||||
lastClickCrit = $state(false);
|
||||
|
||||
// --- Derived (computed live from state) ---
|
||||
get canPrestige() { return canPrestigeCheck(this.state); }
|
||||
get productionPerSecond() { return totalProductionPerSecond(this.state); }
|
||||
get clickGain() { return getClickGain(this.state); }
|
||||
|
||||
// --- Private helpers ---
|
||||
private loadLocalState(): GameState {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
return applyIdleGains(migrateSave(JSON.parse(raw)), Date.now());
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
private saveLocal(s: GameState) {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
|
||||
}
|
||||
|
||||
private hydrateWithOffline(saved: GameState, now: number) {
|
||||
const elapsed = now - saved.lastTick;
|
||||
if (elapsed <= OFFLINE_THRESHOLD) {
|
||||
return { state: { ...applyIdleGains(saved, now), lastOnline: now }, report: null };
|
||||
}
|
||||
const gains = computeOfflineGains(saved, now);
|
||||
const pps = totalProductionPerSecond(saved);
|
||||
const fullGains = pps * (elapsed / 1000);
|
||||
return {
|
||||
state: { ...saved, resources: saved.resources + gains, lifetimeTadpoles: saved.lifetimeTadpoles + gains, lastTick: now, lastOnline: now },
|
||||
report: { wasOffline: true, duration: elapsed, gains, efficiency: fullGains > 0 ? gains / fullGains : 0 } as OfflineReport,
|
||||
};
|
||||
}
|
||||
|
||||
private applyState(updated: GameState) {
|
||||
this.saveLocal(updated);
|
||||
Object.assign(this.state, updated);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
tick() {
|
||||
if (!this.ready) return;
|
||||
const now = Date.now();
|
||||
const updated = { ...applyIdleGains(this.state, now), lastOnline: now };
|
||||
|
||||
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
|
||||
if (autoClicks > 0) {
|
||||
const autoGain = getClickGain(updated) * autoClicks;
|
||||
updated.resources += autoGain;
|
||||
updated.lifetimeTadpoles += autoGain;
|
||||
}
|
||||
|
||||
if (this.playSeconds % 5 === 0) {
|
||||
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
|
||||
const newUnlocks = computeNewUnlocks(updated, cosState);
|
||||
if (newUnlocks.length > 0) {
|
||||
updated.cosmeticInventory = addToInventory(cosState, newUnlocks).inventory;
|
||||
newUnlocks.forEach(() => toast('Nouveau cosmetique debloque !', 'reward'));
|
||||
}
|
||||
}
|
||||
|
||||
this.applyState(updated);
|
||||
this.playSeconds += 1;
|
||||
}
|
||||
|
||||
click() {
|
||||
if (!this.ready) return;
|
||||
const result = applyClick(applyIdleGains(this.state, Date.now()));
|
||||
this.applyState(result.state);
|
||||
this.lastClickGain = result.gain;
|
||||
this.lastClickDouble = result.isDouble;
|
||||
this.lastClickCrit = result.isCrit;
|
||||
}
|
||||
|
||||
buy(genId: string, quantity = 1) {
|
||||
if (!this.ready) return;
|
||||
const updated = buyGenerator(applyIdleGains(this.state, Date.now()), genId, quantity);
|
||||
if (updated) this.applyState(updated);
|
||||
}
|
||||
|
||||
buyClickUpgrade(upgradeId: string) {
|
||||
if (!this.ready) return;
|
||||
const updated = buyClickUpgradeFn(this.state, upgradeId);
|
||||
if (updated) this.applyState(updated);
|
||||
}
|
||||
|
||||
buyNode(nodeId: string) {
|
||||
if (!this.ready) return;
|
||||
const updated = buyEvolutionNode(this.state, nodeId);
|
||||
if (!updated) return;
|
||||
const node = updated.evolutionTree.find((n) => n.id === nodeId);
|
||||
if (node?.capstone) toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
|
||||
this.applyState(updated);
|
||||
}
|
||||
|
||||
prestige() {
|
||||
if (!this.ready || !canPrestigeCheck(this.state)) return;
|
||||
const updated = applyPrestige(this.state);
|
||||
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
|
||||
this.applyState(updated);
|
||||
this.showPrestigeScreen = false;
|
||||
}
|
||||
|
||||
equipCosmetic(cosmeticId: string) {
|
||||
if (!this.ready) return;
|
||||
const cosState = { inventory: this.state.cosmeticInventory, equipped: this.state.cosmeticEquipped };
|
||||
this.state.cosmeticEquipped = equipCosmeticFn(cosState, cosmeticId).equipped;
|
||||
this.saveLocal(this.state);
|
||||
}
|
||||
|
||||
unequipCosmetic(slot: CosmeticSlot) {
|
||||
if (!this.ready) return;
|
||||
const cosState = { inventory: this.state.cosmeticInventory, equipped: this.state.cosmeticEquipped };
|
||||
this.state.cosmeticEquipped = unequipSlotFn(cosState, slot).equipped;
|
||||
this.saveLocal(this.state);
|
||||
}
|
||||
|
||||
resetTree() {
|
||||
if (!this.ready || !canResetTree(this.state)) return;
|
||||
this.applyState(resetEvolutionTree(this.state));
|
||||
}
|
||||
|
||||
upgradeConvergence() {
|
||||
if (!this.ready) return;
|
||||
const updated = upgradeConvergence(this.state);
|
||||
if (updated) this.applyState(updated);
|
||||
}
|
||||
|
||||
claimMilestone(milestoneId: string) {
|
||||
if (!this.ready) return;
|
||||
const updated = claimMilestoneFn(this.state, milestoneId);
|
||||
if (!updated) return;
|
||||
toast('Milestone debloque !', 'reward', 4000);
|
||||
this.applyState(updated);
|
||||
}
|
||||
|
||||
reset() {
|
||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
this.applyState(fresh);
|
||||
this.playSeconds = 0;
|
||||
this.ready = true;
|
||||
this.offlineReport = null;
|
||||
}
|
||||
|
||||
loadFromServer(serverState: GameState) {
|
||||
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
|
||||
const result = this.hydrateWithOffline(migrated, Date.now());
|
||||
this.applyState(result.state);
|
||||
this.ready = true;
|
||||
this.offlineReport = result.report;
|
||||
}
|
||||
|
||||
initGuest() {
|
||||
const local = this.loadLocalState();
|
||||
const result = this.hydrateWithOffline(local, Date.now());
|
||||
this.applyState(result.state);
|
||||
this.ready = true;
|
||||
this.offlineReport = result.report;
|
||||
}
|
||||
|
||||
dismissOfflineReport() { this.offlineReport = null; }
|
||||
openPrestige() { this.showPrestigeScreen = true; }
|
||||
closePrestige() { this.showPrestigeScreen = false; }
|
||||
|
||||
generatorCost = genCost;
|
||||
generatorCostWithTree(gen: Parameters<typeof genCost>[0]) {
|
||||
return genCost(gen, this.state.evolutionTree);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — import { game } from '$lib/stores/game.svelte';
|
||||
export const game = new Game();
|
||||
33
Frontend/src/lib/stores/toast.svelte.ts
Normal file
33
Frontend/src/lib/stores/toast.svelte.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// toast.svelte.ts — Toast notification system (Svelte 5 runes)
|
||||
|
||||
export type ToastVariant = 'success' | 'info' | 'reward' | 'warning';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
export function getToasts() {
|
||||
return toasts;
|
||||
}
|
||||
|
||||
export function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, message, variant, duration }];
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
export function removeToast(id: number) {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
// Shorthand — usable from anywhere (stores, lib, components)
|
||||
export const toast = addToast;
|
||||
@@ -1,66 +0,0 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import Landing from "./pages/Landing";
|
||||
import Home from "./pages/Home";
|
||||
import ErrorPage from "./pages/404";
|
||||
import Login from "./pages/Login";
|
||||
import AuthCallback from "./pages/AuthCallback";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import Achievements from "./pages/Achievements";
|
||||
import Settings from "./pages/Settings";
|
||||
import Legal from "./pages/Legal";
|
||||
import Cookie from "./pages/Cookie";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <Landing />,
|
||||
},
|
||||
{
|
||||
path: "/jeu",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/achievements",
|
||||
element: <Achievements />,
|
||||
},
|
||||
{
|
||||
path: "/mentionslegales",
|
||||
element: <Legal />,
|
||||
},
|
||||
{
|
||||
path: "/cookies",
|
||||
element: <Cookie />,
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
element: <AuthCallback />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <ErrorPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
|
||||
root.render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "../scss/pages.scss";
|
||||
import Lottie from "react-lottie-player";
|
||||
import animation404 from "../data/404-animation.json";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror">
|
||||
<Lottie
|
||||
loop
|
||||
animationData={animation404}
|
||||
play
|
||||
style={{ width: 260, height: 150 }}
|
||||
/>
|
||||
<h1>Oops! Il semble que la page n'existe pas.</h1>
|
||||
<p className="message">
|
||||
Nous vous conseillons de retourner à la page d'accueil.
|
||||
</p>
|
||||
<Link className="btn-return" to="/">
|
||||
retourner à la page d'accueil
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
import "../scss/achievements.scss";
|
||||
|
||||
function Achievements() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
|
||||
const unlocked = ACHIEVEMENTS.filter((a) => a.check(state));
|
||||
const locked = ACHIEVEMENTS.filter((a) => !a.check(state));
|
||||
|
||||
return (
|
||||
<div className="fullachieve">
|
||||
<h1>Succès</h1>
|
||||
<p className="achieve-counter">
|
||||
{unlocked.length} / {ACHIEVEMENTS.length}
|
||||
</p>
|
||||
|
||||
<div className="achievementscontainer">
|
||||
<div className="achievementscardcontainer">
|
||||
{unlocked.map((a) => (
|
||||
<div key={a.id} className="achieve-card achieve-unlocked">
|
||||
<span className="achieve-icon">{a.icon}</span>
|
||||
<div className="achieve-info">
|
||||
<p className="achieve-name">{a.name}</p>
|
||||
<p className="achieve-desc">{a.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{locked.map((a) => (
|
||||
<div key={a.id} className="achieve-card achieve-locked">
|
||||
<span className="achieve-icon">🔒</span>
|
||||
<div className="achieve-info">
|
||||
<p className="achieve-name">{a.name}</p>
|
||||
<p className="achieve-desc">???</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Achievements;
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { exchangeCode, loadVerifier, clearVerifier } from "../lib/oauth";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import "../scss/pages.scss";
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { refresh } = useAuth();
|
||||
const [error, setError] = useState(null);
|
||||
const called = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (called.current) return;
|
||||
called.current = true;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const err = params.get("error");
|
||||
|
||||
if (err) {
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setError("Code manquant dans l'URL.");
|
||||
return;
|
||||
}
|
||||
|
||||
const verifier = loadVerifier();
|
||||
if (!verifier) {
|
||||
setError("Verifier PKCE manquant — réessaie la connexion.");
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
|
||||
exchangeCode(code, verifier, redirectUri)
|
||||
.then((tokens) => {
|
||||
clearVerifier();
|
||||
// Store SuperOAuth access_token for profile API calls (short-lived, 15min)
|
||||
localStorage.setItem("clkz_oauth_token", tokens.access_token);
|
||||
return apiFetch("/auth/session", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
token: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
}),
|
||||
});
|
||||
})
|
||||
.then(() => refresh())
|
||||
.then(() => navigate("/", { replace: true }))
|
||||
.catch((e) => {
|
||||
clearVerifier();
|
||||
setError(e.message || "Erreur de connexion.");
|
||||
});
|
||||
}, [navigate, refresh]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror">
|
||||
<h1>Erreur de connexion</h1>
|
||||
<p className="message">{error}</p>
|
||||
<Link className="btn-return" to="/login">
|
||||
Retour au login
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror">
|
||||
<p className="message">Connexion en cours...</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import "../scss/Cookie.scss";
|
||||
function Cookie() {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="item">
|
||||
<h2>Qu'est-ce qu'un cookie ?</h2>
|
||||
<p>
|
||||
Un cookie est un petit fichier texte sauvegardé sur votre ordinateur
|
||||
lorsque vous visitez un site web. Ce fichier texte enregistre des
|
||||
informations qui peuvent être lues par un site web lorsque vous le
|
||||
visitez de nouveau plus tard. Certains de ces cookies sont nécessaires
|
||||
pour accéder à certaines fonctionnalités d'un site. D'autres cookies
|
||||
sont d'utilité pratique pour le visiteur : ils sauvegardent de manière
|
||||
sécurisée votre nom d'utilisateur ou vos préférences linguistiques par
|
||||
exemple. Les cookies signifient tout simplement qu'à chaque fois que
|
||||
vous visitez un site web, vous n'avez pas besoin de saisir à nouveau
|
||||
les mêmes informations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<h2>Pourquoi Clickerz utilise des cookies ?</h2>
|
||||
<p>
|
||||
Nous utilisons des cookies pour vous fournir une expérience
|
||||
utilisateur optimale et adaptée à vos préférences personnelles.
|
||||
Les cookies sont également utilisés pour optimiser la performance
|
||||
du site. Clickerz a pris toutes les mesures organisationnelles et
|
||||
techniques pour protéger vos données personnelles ainsi que d'une
|
||||
éventuelle perte d'informations ou de toute forme de traitement
|
||||
illicite. Pour davantage d'informations, consultez notre Politique
|
||||
de confidentialité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<h2>Comment puis-je désactiver les cookies ?</h2>
|
||||
<p>
|
||||
Vous pouvez paramétrer votre navigateur Internet pour désactiver les
|
||||
cookies. Notez toutefois que si vous désactivez les cookies, votre nom
|
||||
d'utilisateur ainsi que votre mot de passe ne seront plus sauvegardés
|
||||
sur aucun site web.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<h2>Firefox :</h2>
|
||||
<p>
|
||||
1. Ouvrez Firefox <br />
|
||||
2. Appuyez sur la touche « Alt » <br />
|
||||
3. Dans le menu en haut de la page cliquez sur « Outils » puis «
|
||||
Options » <br />
|
||||
4. Sélectionnez l'onglet « Vie privée » <br />
|
||||
5. Dans le menu déroulant à droite de « Règles de conservation »,
|
||||
cliquez sur « utiliser les paramètres personnalisés pour l'historique
|
||||
» <br />
|
||||
6. Un peu plus bas, décochez « Accepter les cookies » <br />
|
||||
7. Sauvegardez vos préférences en cliquant sur « OK »
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<h2>Internet Explorer/Edge :</h2>
|
||||
<p>
|
||||
1. Ouvrez Internet Explorer <br />
|
||||
2. Dans le menu « Outils », sélectionnez « Options Internet » <br />
|
||||
3. Cliquez sur l'onglet « Confidentialité » <br />
|
||||
4. Cliquez sur « Avancé » et décochez « Accepter » <br />
|
||||
5. Sauvegardez vos préférences en cliquant sur « OK »
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<h2>Safari :</h2>
|
||||
<p>
|
||||
1. Ouvrez Safari <br />
|
||||
2. Dans la barre de menu en haut, cliquez sur « Safari », puis «
|
||||
Préférences » <br />
|
||||
3. Sélectionnez l'icône « Sécurité » <br />
|
||||
4. À côté de « Accepter les cookies », cochez « Jamais » <br />
|
||||
5. Si vous souhaitez voir les cookies qui sont déjà sauvegardés sur
|
||||
votre ordinateur, cliquez sur « Afficher les cookies »
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<h2>Google Chrome :</h2>
|
||||
<p>
|
||||
1. Ouvrez Google Chrome <br />
|
||||
2. Cliquez sur l'icône d'outils dans la barre de menu <br />
|
||||
3. Sélectionnez « Options » <br />
|
||||
4. Cliquez sur l'onglet « Options avancées » <br />
|
||||
5. Dans le menu déroulant « Paramètres des cookies », sélectionnez «
|
||||
Bloquer tous les cookies »
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cookie;
|
||||
@@ -1,153 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
import { getClickGain } from "../core/economy";
|
||||
import { GeneratorShop } from "../components/GeneratorShop";
|
||||
import { PrestigePanel } from "../components/PrestigePanel";
|
||||
import { EvolutionTree } from "../components/EvolutionTree";
|
||||
import { MilestoneBar } from "../components/MilestoneBar";
|
||||
import { CockpitHeader } from "../components/CockpitHeader";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
import "../scss/home.scss";
|
||||
import "../scss/components/game-panels.scss";
|
||||
|
||||
export default function Home() {
|
||||
const [toggleRain] = useOutletContext();
|
||||
const ready = useGameStore((s) => s.ready);
|
||||
const click = useGameStore((s) => s.click);
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
const state = useGameStore((s) => s.state);
|
||||
const clickGain = getClickGain(state);
|
||||
|
||||
const createParticle = useCallback((clientX, clientY) => {
|
||||
const particle = document.createElement("span");
|
||||
particle.className = "click-particle";
|
||||
particle.textContent = `+${formatNumber(clickGain)}`;
|
||||
particle.style.left = `${clientX}px`;
|
||||
particle.style.top = `${clientY}px`;
|
||||
document.body.appendChild(particle);
|
||||
setTimeout(() => {
|
||||
if (particle.parentNode) particle.parentNode.removeChild(particle);
|
||||
}, 800);
|
||||
}, [clickGain]);
|
||||
|
||||
const handleIncrement = useCallback((e) => {
|
||||
click();
|
||||
createParticle(e.clientX, e.clientY);
|
||||
}, [click, createParticle]);
|
||||
|
||||
// Rain effect (ambiance)
|
||||
useEffect(() => {
|
||||
const rain = {
|
||||
wind: 0, maxXrange: 40, minXrange: 20, maxSpeed: 1, minSpeed: 3,
|
||||
color: "#8ecae6", char: "°", maxSize: 28, minSize: 8,
|
||||
flakes: [], WIDTH: -10, HEIGHT: 0, running: false,
|
||||
init(nb) {
|
||||
const frag = document.createDocumentFragment();
|
||||
this.getSize();
|
||||
this.running = true;
|
||||
for (let i = 0; i < nb; i++) {
|
||||
const flake = {
|
||||
x: this.random(this.WIDTH), y: -this.maxSize,
|
||||
xrange: this.minXrange + this.random(this.maxXrange - this.minXrange),
|
||||
yspeed: this.minSpeed + this.random(this.maxSpeed - this.minSpeed, 100),
|
||||
life: 0, size: this.minSize + this.random(this.maxSize - this.minSize),
|
||||
html: document.createElement("span"),
|
||||
};
|
||||
Object.assign(flake.html.style, {
|
||||
position: "absolute", top: `${flake.y}px`, left: `${flake.x}px`,
|
||||
fontSize: `${flake.size}px`, color: this.color, userSelect: "none", overflow: "hidden",
|
||||
});
|
||||
flake.html.appendChild(document.createTextNode(this.char));
|
||||
frag.appendChild(flake.html);
|
||||
this.flakes.push(flake);
|
||||
}
|
||||
document.body.appendChild(frag);
|
||||
this.animate();
|
||||
window.onresize = () => this.getSize();
|
||||
},
|
||||
animate() {
|
||||
if (!this.running) return;
|
||||
for (const flake of this.flakes) {
|
||||
const top = flake.y + flake.yspeed;
|
||||
const left = flake.x + Math.sin(flake.life) * flake.xrange + this.wind;
|
||||
if (top < this.HEIGHT - flake.size - 10 && left < this.WIDTH - flake.size && left > 0) {
|
||||
flake.html.style.top = `${top}px`;
|
||||
flake.html.style.left = `${left}px`;
|
||||
flake.y = top;
|
||||
flake.x += this.wind;
|
||||
flake.life += 0.01;
|
||||
} else {
|
||||
flake.html.style.top = `${-this.maxSize}px`;
|
||||
flake.x = this.random(this.WIDTH);
|
||||
flake.y = -this.maxSize;
|
||||
flake.html.style.left = `${flake.x}px`;
|
||||
flake.life = 0;
|
||||
}
|
||||
}
|
||||
setTimeout(() => this.animate(), 20);
|
||||
},
|
||||
stop() {
|
||||
this.running = false;
|
||||
for (const flake of this.flakes) {
|
||||
if (flake.html.parentNode) flake.html.parentNode.removeChild(flake.html);
|
||||
}
|
||||
this.flakes = [];
|
||||
},
|
||||
random(range, num = 1) {
|
||||
return Math.floor(Math.random() * (range + 1) * num) / num;
|
||||
},
|
||||
getSize() {
|
||||
this.WIDTH = document.body.clientWidth || window.innerWidth;
|
||||
this.HEIGHT = document.body.clientHeight || window.innerHeight;
|
||||
},
|
||||
};
|
||||
|
||||
if (toggleRain) rain.init(10);
|
||||
return () => rain.stop();
|
||||
}, [toggleRain]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<section className="game-container">
|
||||
<p style={{ textAlign: "center", color: "#6b7a99", marginTop: "20vh" }}>
|
||||
Chargement de ta progression...
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="zone" data-zone="swamp">
|
||||
<Helmet>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</Helmet>
|
||||
|
||||
{/* Clicker area — centre */}
|
||||
<div className="click-zone" onClick={handleIncrement}>
|
||||
<div className="tadpole-sprite" />
|
||||
<div className="click-zone-counter">
|
||||
{formatNumber(resources)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cockpit — sidebar structurée en zones */}
|
||||
<aside className="game-sidebar">
|
||||
<CockpitHeader />
|
||||
<div className="gp-sep" />
|
||||
<MilestoneBar />
|
||||
<GeneratorShop />
|
||||
<div className="gp-sep" />
|
||||
<PrestigePanel />
|
||||
<EvolutionTree />
|
||||
<a href="/achievements" className="achieve-badge">
|
||||
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
|
||||
</a>
|
||||
</aside>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
</Helmet>
|
||||
<main className="zone mt-20 flex flex-col items-center justify-center gap-6" data-zone="landing">
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Têtard Clickerz"
|
||||
className="w-40 h-40 md:w-52 md:h-52 drop-shadow-lg animate-bounce"
|
||||
style={{ animationDuration: "3s" }}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3 text-center px-4">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-800 font-[var(--font)]">
|
||||
Clickerz
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-600 font-[var(--font)] max-w-md">
|
||||
Fais éclore des têtards, construis ton empire et domine le marais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/jeu"
|
||||
className="px-8 py-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-lg font-semibold font-[var(--font)] transition-all hover:scale-105 shadow-lg shadow-emerald-600/30"
|
||||
>
|
||||
Entrer dans le Marais
|
||||
</Link>
|
||||
|
||||
<p className="text-sm text-gray-500 font-[var(--font)]">
|
||||
Pas de compte requis — joue en mode invité
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import "../scss/Legal.scss";
|
||||
function Legal() {
|
||||
return (
|
||||
<div className="mentionslegales">
|
||||
<h2>Éditeur :</h2>
|
||||
<p>
|
||||
Clickerz est un projet indépendant faisant partie du Tetard Universe.
|
||||
</p>
|
||||
|
||||
<h2>Coordonnées :</h2>
|
||||
<p>
|
||||
E-mail : contact@tetardtek.com <br />
|
||||
Site : https://tetardtek.com <br />
|
||||
</p>
|
||||
|
||||
<h2>Responsabilité :</h2>
|
||||
<p>
|
||||
Clickerz décline toute responsabilité quant à l'utilisation du site.
|
||||
Les informations fournies sont à titre informatif et peuvent être
|
||||
sujettes à des erreurs.
|
||||
</p>
|
||||
|
||||
<h2>Propriété Intellectuelle :</h2>
|
||||
<p>
|
||||
Tout le contenu du site (textes, images, etc.) reste la propriété de
|
||||
Tetardtek. Toute reproduction est interdite sans autorisation
|
||||
préalable.
|
||||
</p>
|
||||
|
||||
<h2>Protection des Données Personnelles :</h2>
|
||||
<p>
|
||||
Clickerz utilise un système d'authentification via SuperOAuth.
|
||||
Les données de jeu sont sauvegardées côté serveur.
|
||||
Aucune donnée personnelle n'est partagée avec des tiers.
|
||||
</p>
|
||||
|
||||
<h2>Conditions Générales d'Utilisation :</h2>
|
||||
<p>
|
||||
L'utilisation du site Clickerz se fait à titre gratuit et sans engagement.
|
||||
</p>
|
||||
|
||||
<h2>Loi Applicable :</h2>
|
||||
<p>
|
||||
Le présent site est régi par la loi française. En cas de litige, les
|
||||
tribunaux compétents seront ceux du ressort du siège social de l'éditeur.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Legal;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { buildAuthUrl, saveVerifier } from "../lib/oauth";
|
||||
import "../scss/pages.scss";
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: "discord", label: "Discord", emoji: "🎮" },
|
||||
{ id: "github", label: "GitHub", emoji: "🐙" },
|
||||
{ id: "google", label: "Google", emoji: "🌐" },
|
||||
{ id: "twitch", label: "Twitch", emoji: "🎬" },
|
||||
];
|
||||
|
||||
export default function Login() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) navigate("/", { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
const handleLogin = async (provider) => {
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
|
||||
saveVerifier(verifier);
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror">
|
||||
<h1>Connexion</h1>
|
||||
<p className="message">Connecte-toi pour sauvegarder ta progression.</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 16 }}>
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className="btn-return"
|
||||
onClick={() => handleLogin(p.id)}
|
||||
type="button"
|
||||
>
|
||||
{p.emoji} Continuer avec {p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import "../scss/pages.scss";
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || "";
|
||||
const PROVIDERS = ["discord", "github", "google", "twitch"];
|
||||
const EMOJIS = { discord: "🎮", github: "🐙", google: "🌐", twitch: "🎬" };
|
||||
|
||||
function getOAuthToken() {
|
||||
return localStorage.getItem("clkz_oauth_token");
|
||||
}
|
||||
|
||||
async function oauthFetch(path, options = {}) {
|
||||
const token = getOAuthToken();
|
||||
if (!token) throw new Error("Not authenticated with SuperOAuth");
|
||||
|
||||
const res = await fetch(`${OAUTH_URL}/api/v1${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem("clkz_oauth_token");
|
||||
throw new Error("SuperOAuth token expired — re-login required");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.message || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { user, logout } = useAuth();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [linkedProviders, setLinkedProviders] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
|
||||
// Handle ?linked= or ?error= from link callback
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const linked = params.get("linked");
|
||||
const err = params.get("error");
|
||||
if (linked) {
|
||||
window.history.replaceState({}, "", "/settings");
|
||||
}
|
||||
if (err) {
|
||||
setError(err);
|
||||
window.history.replaceState({}, "", "/settings");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
fetchProfile();
|
||||
}, [user]);
|
||||
|
||||
async function fetchProfile() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await oauthFetch("/user/profile");
|
||||
setProfile(data.data.user);
|
||||
setLinkedProviders(data.data.linkedProviders);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLink(provider) {
|
||||
setActionLoading(provider);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await oauthFetch(`/oauth/${provider}/link`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
returnUrl: `${window.location.origin}/settings`,
|
||||
}),
|
||||
});
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = data.data.authUrl;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlink(provider) {
|
||||
if (!confirm(`Délier ${provider} ?`)) return;
|
||||
setActionLoading(provider);
|
||||
setError(null);
|
||||
try {
|
||||
await oauthFetch(`/oauth/${provider}/unlink`, { method: "DELETE" });
|
||||
await fetchProfile();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror">
|
||||
<p className="message">Connecte-toi pour accéder aux paramètres.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror">
|
||||
<p className="message">Chargement du profil...</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const linkedNames = new Set(linkedProviders.map((p) => p.provider));
|
||||
const canUnlink = linkedProviders.length > 1;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="containererror" style={{ maxWidth: 500 }}>
|
||||
<h1>Paramètres</h1>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: "#ef4444", fontSize: 13, marginBottom: 16 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Profile info */}
|
||||
{profile && (
|
||||
<div style={{ marginBottom: 24, textAlign: "left" }}>
|
||||
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}>
|
||||
<strong>Pseudo :</strong> {profile.nickname}
|
||||
</p>
|
||||
<p style={{ fontSize: 14, color: "#9ca3af", margin: "4px 0" }}>
|
||||
<strong>Email :</strong> {profile.email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked providers */}
|
||||
<h2 style={{ fontSize: 18, marginBottom: 12 }}>Comptes liés</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{PROVIDERS.map((provider) => {
|
||||
const linked = linkedNames.has(provider);
|
||||
const isLoading = actionLoading === provider;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 12px",
|
||||
background: linked ? "#1a2a1a" : "#1a1a2a",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${linked ? "#2a4a2a" : "#2a2a4a"}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>
|
||||
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
|
||||
{linked && (
|
||||
<span style={{ color: "#4ade80", fontSize: 12, marginLeft: 8 }}>
|
||||
✓ lié
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{linked ? (
|
||||
<button
|
||||
className="btn-return"
|
||||
style={{ fontSize: 12, padding: "4px 10px", opacity: canUnlink ? 1 : 0.4 }}
|
||||
disabled={!canUnlink || isLoading}
|
||||
onClick={() => handleUnlink(provider)}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "..." : "Délier"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn-return"
|
||||
style={{ fontSize: 12, padding: "4px 10px" }}
|
||||
disabled={isLoading}
|
||||
onClick={() => handleLink(provider)}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? "..." : "Lier"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
className="btn-return"
|
||||
style={{ marginTop: 24, width: "100%" }}
|
||||
onClick={logout}
|
||||
type="button"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
Frontend/src/routes/+error.svelte
Normal file
30
Frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>404 — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<div class="containererror">
|
||||
<div in:scale={{ duration: 500, start: 0.7, easing: backOut }}>
|
||||
<img src="/svg/tadpole.svg" alt="" class="w-28 h-28 mx-auto opacity-30" />
|
||||
</div>
|
||||
<h1 in:fly={{ y: 20, delay: 100, duration: 400, easing: quintOut }}>
|
||||
{page.status}
|
||||
</h1>
|
||||
<p class="message" in:fly={{ y: 15, delay: 200, duration: 400, easing: quintOut }}>
|
||||
{page.error?.message || 'Ce tetard s\'est perdu dans le marais.'}
|
||||
</p>
|
||||
<a
|
||||
class="btn-return"
|
||||
href="/"
|
||||
in:fly={{ y: 15, delay: 300, duration: 400, easing: quintOut }}
|
||||
>
|
||||
Retour au Marais
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
26
Frontend/src/routes/+layout.svelte
Normal file
26
Frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import GameTick from '$lib/components/GameTick.svelte';
|
||||
import GameSync from '$lib/components/GameSync.svelte';
|
||||
import OfflineReport from '$lib/components/OfflineReport.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="/svg/tadpole.svg" />
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</svelte:head>
|
||||
|
||||
<GameTick />
|
||||
<GameSync />
|
||||
<OfflineReport />
|
||||
<ToastContainer />
|
||||
<Navbar />
|
||||
<main style="min-height: 92vh; margin-top: 80px; padding: 0 0 2rem; background-color: var(--bg-color);">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
2
Frontend/src/routes/+layout.ts
Normal file
2
Frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
42
Frontend/src/routes/+page.svelte
Normal file
42
Frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="zone" data-zone="landing">
|
||||
<div class="flex flex-col items-center gap-8 text-center" style="font-family: var(--font);">
|
||||
<div in:scale={{ duration: 600, start: 0.6, easing: backOut }}>
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Clickerz"
|
||||
class="w-48 h-48"
|
||||
style="filter: drop-shadow(0 0 30px rgba(52,211,153,0.2));"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
class="text-4xl font-extrabold"
|
||||
style="color: var(--color-grey);"
|
||||
in:fly={{ y: 30, delay: 150, duration: 500, easing: quintOut }}
|
||||
>
|
||||
Clickerz
|
||||
</h1>
|
||||
<p
|
||||
class="text-lg max-w-md"
|
||||
style="color: var(--color-grey); opacity: 0.7;"
|
||||
in:fly={{ y: 20, delay: 300, duration: 500, easing: quintOut }}
|
||||
>
|
||||
Clicker idle dans le Tetard Universe. Fais eclore des tetards, evolue, prestige.
|
||||
</p>
|
||||
<div in:scale={{ delay: 450, duration: 400, start: 0.8, easing: backOut }}>
|
||||
<button class="primary-button text-lg! px-8! py-3!" onclick={() => goto('/jeu')}>
|
||||
Jouer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
100
Frontend/src/routes/achievements/+page.svelte
Normal file
100
Frontend/src/routes/achievements/+page.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, backOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { ACHIEVEMENTS } from '$lib/data/achievements';
|
||||
|
||||
let filter = $state<'all' | 'unlocked' | 'locked'>('all');
|
||||
|
||||
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(game.state)));
|
||||
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(game.state)));
|
||||
|
||||
let displayed = $derived(
|
||||
filter === 'unlocked' ? unlocked
|
||||
: filter === 'locked' ? locked
|
||||
: [...unlocked, ...locked]
|
||||
);
|
||||
|
||||
let progressPct = $derived(Math.round((unlocked.length / ACHIEVEMENTS.length) * 100));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Succes — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fullachieve">
|
||||
<!-- Header -->
|
||||
<div in:fly={{ y: -20, duration: 400, easing: quintOut }}>
|
||||
<h1>Succes</h1>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="max-w-xs mx-auto w-full mb-6"
|
||||
in:scale={{ delay: 100, duration: 300, start: 0.9 }}
|
||||
>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="achieve-counter mb-0!">{unlocked.length} / {ACHIEVEMENTS.length}</span>
|
||||
<span class="achieve-counter mb-0!">{progressPct}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full overflow-hidden" style="background: rgba(0,0,0,0.1);">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700 ease-out"
|
||||
style="width: {progressPct}%; background: linear-gradient(90deg, #059669, #34d399);"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div
|
||||
class="flex gap-1 justify-center mb-6"
|
||||
in:fade={{ delay: 200, duration: 300 }}
|
||||
>
|
||||
{#each [
|
||||
{ id: 'all', label: `Tous (${ACHIEVEMENTS.length})` },
|
||||
{ id: 'unlocked', label: `Debloques (${unlocked.length})` },
|
||||
{ id: 'locked', label: `Verrouilles (${locked.length})` },
|
||||
] as tab}
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
style="
|
||||
font-family: var(--font);
|
||||
{filter === tab.id
|
||||
? 'background: var(--color-grey); color: white;'
|
||||
: 'background: rgba(0,0,0,0.06); color: var(--color-grey); opacity: 0.7;'
|
||||
}
|
||||
"
|
||||
onclick={() => filter = tab.id as typeof filter}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
{#key filter}
|
||||
<div class="achievementscardcontainer">
|
||||
{#each displayed as a, i}
|
||||
{@const isUnlocked = unlocked.includes(a)}
|
||||
<div
|
||||
class="achieve-card {isUnlocked ? 'achieve-unlocked' : 'achieve-locked'}"
|
||||
in:fly={{ y: 20, delay: Math.min(i * 40, 400), duration: 300, easing: quintOut }}
|
||||
>
|
||||
<span class="achieve-icon">{isUnlocked ? a.icon : '🔒'}</span>
|
||||
<div class="achieve-info">
|
||||
<p class="achieve-name">{a.name}</p>
|
||||
<p class="achieve-desc">{isUnlocked ? a.description : '???'}</p>
|
||||
</div>
|
||||
{#if isUnlocked}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full shrink-0"
|
||||
style="background: rgba(16,185,129,0.15); color: #34d399; font-family: var(--font);"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
56
Frontend/src/routes/callback/+page.svelte
Normal file
56
Frontend/src/routes/callback/+page.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { exchangeCode, loadVerifier, clearVerifier } from '$lib/oauth';
|
||||
import { apiFetch } from '$lib/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const err = params.get('error');
|
||||
|
||||
if (err) { error = err; return; }
|
||||
if (!code) { error = "Code manquant dans l'URL."; return; }
|
||||
|
||||
const verifier = loadVerifier();
|
||||
if (!verifier) { error = 'Verifier PKCE manquant — reessaie la connexion.'; return; }
|
||||
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCode(code, verifier, redirectUri);
|
||||
clearVerifier();
|
||||
localStorage.setItem('clkz_oauth_token', tokens.access_token);
|
||||
|
||||
await apiFetch('/auth/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token: tokens.access_token, refreshToken: tokens.refresh_token }),
|
||||
});
|
||||
|
||||
await authStore.refresh();
|
||||
goto('/', { replaceState: true });
|
||||
} catch (e: any) {
|
||||
clearVerifier();
|
||||
error = e.message || 'Erreur de connexion.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Connexion... — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<div class="containererror">
|
||||
{#if error}
|
||||
<h1>Erreur de connexion</h1>
|
||||
<p class="message">{error}</p>
|
||||
<a class="btn-return" href="/login">Retour au login</a>
|
||||
{:else}
|
||||
<p class="message">Connexion en cours...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
15
Frontend/src/routes/cookies/+page.svelte
Normal file
15
Frontend/src/routes/cookies/+page.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<svelte:head>
|
||||
<title>Cookies — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey);">
|
||||
<h1>Politique des Cookies</h1>
|
||||
<div class="content">
|
||||
<p class="paragraphe">Clickerz utilise des cookies strictement necessaires au fonctionnement du jeu :</p>
|
||||
<ul class="paragraphe" style="margin-left: 1.5rem;">
|
||||
<li>Session d'authentification (httpOnly, secure)</li>
|
||||
<li>Sauvegarde locale (localStorage — fallback invite)</li>
|
||||
</ul>
|
||||
<p class="paragraphe">Aucun cookie de tracking ou publicitaire n'est utilise.</p>
|
||||
</div>
|
||||
</div>
|
||||
185
Frontend/src/routes/guide/+page.svelte
Normal file
185
Frontend/src/routes/guide/+page.svelte
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { fly, slide, fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
interface Section {
|
||||
icon: string;
|
||||
title: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
icon: '🏞',
|
||||
title: 'Le Marais',
|
||||
content: [
|
||||
'Tu es le **Gardien du Marais**. Les tetards naissent sous tes clics, grandissent grace a tes generateurs, et evoluent a chaque nouvelle generation.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'Boucle de jeu',
|
||||
content: [
|
||||
'**1. Clique** pour pondre des tetards. Achete des **generateurs** pour produire automatiquement.',
|
||||
'**2. Prestige** quand tu atteins le seuil de tetards. Reset tetards et generateurs, mais gagne de l\'**ADN Ancestral** + un multiplicateur permanent.',
|
||||
'**3. Arbre d\'Evolution** — depense ton ADN dans 3 branches pour booster ta production, tes clics, et ta progression.',
|
||||
'**4. Repete** — chaque prestige est plus rapide grace aux bonus accumules. Le seuil monte pour garder le challenge.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '👆',
|
||||
title: 'Ponte (clic)',
|
||||
content: [
|
||||
'Chaque clic rapporte : **base × prestige × arbre**. Le panneau "Ponte" dans Production te montre le breakdown complet.',
|
||||
'**Double Ponte** — chance de doubler le gain (branche Ponte, 5 ADN)',
|
||||
'**Ponte Critique** — chance de ×10 (branche Ponte, 20 ADN)',
|
||||
'**Auto-Ponte** — clics automatiques par seconde (capstone Ponte, 200 ADN). Scale avec les repeatables.',
|
||||
'Le panneau montre aussi les gains auto-ponte/s — ta production passive par les clics.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🏭',
|
||||
title: 'Generateurs',
|
||||
content: [
|
||||
'5 generateurs (Nid → Lac Mystique). Chaque unite produit des tetards/s automatiquement.',
|
||||
'Le cockpit Production montre la **production effective** (avec tous les bonus) et le **+X/s** que le prochain achat ajoute.',
|
||||
'**Achat multiple** — x1, x5, x10, ou MAX. Le cout et la quantite s\'affichent sur le bouton.',
|
||||
'La **barre de part** montre quelle fraction de ta production vient de chaque type.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🧬',
|
||||
title: 'Prestige',
|
||||
content: [
|
||||
'Le prestige reset tes tetards et generateurs. Tu gagnes de l\'**ADN Ancestral** et un multiplicateur permanent (×0.1 par prestige).',
|
||||
'Le **seuil augmente** a chaque prestige : 1M × (1 + 0.1 × N)². Plus tu prestiges, plus il faut de tetards.',
|
||||
'L\'arbre et les cosmetiques sont **conserves**. Chaque prestige offre **1 reset d\'arbre gratuit**.',
|
||||
'Les noeuds Adaptation peuvent **reduire le seuil** — strategique pour prestige plus souvent.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🌳',
|
||||
title: 'Arbre d\'Evolution',
|
||||
content: [
|
||||
'3 branches + Convergence. Depense ton ADN pour debloquer des noeuds permanents.',
|
||||
'**Ponte** — booste tes clics : multiplicateur, double ponte, critique, auto-ponte',
|
||||
'**Marais** — booste la production : multiplicateur, Nid boost, synergie entre types',
|
||||
'**Adaptation** — booste la progression : bonus ADN, offline, reduction seuil prestige',
|
||||
'Chaque branche a un **capstone** (noeud final puissant) + des **post-capstones** repeatables a l\'infini.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '★',
|
||||
title: 'Capstones & Convergence',
|
||||
content: [
|
||||
'**Ponte Automatique** — auto-clic 1/s qui scale avec les upgrades',
|
||||
'**Symbiose Totale** — chaque type de generateur booste les autres',
|
||||
'**Memoire du Marais** — offline cap a 75%, duree 8h',
|
||||
'**Convergence Alpha** (1 capstone + tier 3 d\'une 2e branche) → +10% tous effets',
|
||||
'**Convergence Omega** (2 capstones) → +10% tous effets + -20% cout post-capstones',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🏆',
|
||||
title: 'Milestones',
|
||||
content: [
|
||||
'8 paliers de prestige (1 a 100). Recompenses :',
|
||||
'1 → Ruban queue | 3 → Titre | 5 → 1 Nid gratuit | 10 → Couronne',
|
||||
'15 → +5% offline | 25 → Cape | 50 → Queue enflamee | 100 → Skin Primordial',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '✨',
|
||||
title: 'Cosmetiques',
|
||||
content: [
|
||||
'Purement visuels — **zero pay-to-win**. 5 slots : chapeau, yeux, corps, queue, accessoire.',
|
||||
'Debloques via achievements et milestones prestige.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🌙',
|
||||
title: 'Offline',
|
||||
content: [
|
||||
'Le marais continue de produire quand tu fermes le jeu.',
|
||||
'Efficacite : 100% (0-15min) → degressive → 0% a 2h.',
|
||||
'Les noeuds Adaptation et milestones augmentent le cap et la duree.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔃',
|
||||
title: 'Reset d\'arbre',
|
||||
content: [
|
||||
'Reinitialise ton arbre pour tester d\'autres builds.',
|
||||
'**1 gratuit par prestige**, puis 5 ADN par reset supplementaire. L\'ADN investi est rembourse.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let openSections = $state<Set<number>>(new Set([0, 1]));
|
||||
|
||||
function toggle(idx: number) {
|
||||
const next = new Set(openSections);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
openSections = next;
|
||||
}
|
||||
|
||||
function renderBold(text: string): string {
|
||||
return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Guide du Gardien — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey); max-width: 720px;">
|
||||
<h1 in:fly={{ y: -20, duration: 400, easing: quintOut }}>Guide du Gardien</h1>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each sections as section, i}
|
||||
<div
|
||||
class="rounded-xl overflow-hidden transition-all duration-200"
|
||||
style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06);"
|
||||
in:fly={{ y: 15, delay: i * 60, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<button
|
||||
class="flex items-center gap-3 w-full px-5 py-3.5 cursor-pointer text-left group"
|
||||
style="font-family: var(--font);"
|
||||
onclick={() => toggle(i)}
|
||||
aria-expanded={openSections.has(i)}
|
||||
>
|
||||
<span class="text-xl">{section.icon}</span>
|
||||
<span class="text-base font-semibold flex-1" style="color: var(--color-grey);">
|
||||
{section.title}
|
||||
</span>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200 opacity-40 group-hover:opacity-70"
|
||||
class:rotate-180={openSections.has(i)}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
{#if openSections.has(i)}
|
||||
<div transition:slide={{ duration: 250, easing: quintOut }}>
|
||||
<div class="flex flex-col gap-2 px-5 pb-4 pl-14">
|
||||
{#each section.content as line}
|
||||
<p
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--color-grey); opacity: 0.8; font-family: var(--font);"
|
||||
>
|
||||
{@html renderBold(line)}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
159
Frontend/src/routes/jeu/+page.svelte
Normal file
159
Frontend/src/routes/jeu/+page.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, elasticOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CockpitHeader from '$lib/components/CockpitHeader.svelte';
|
||||
import GeneratorShop from '$lib/components/GeneratorShop.svelte';
|
||||
import ClickPanel from '$lib/components/ClickPanel.svelte';
|
||||
import PrestigePanel from '$lib/components/PrestigePanel.svelte';
|
||||
import EvolutionTree from '$lib/components/EvolutionTree.svelte';
|
||||
import MilestoneBar from '$lib/components/MilestoneBar.svelte';
|
||||
import MilestonesPanel from '$lib/components/MilestonesPanel.svelte';
|
||||
import CosmeticsPanel from '$lib/components/CosmeticsPanel.svelte';
|
||||
import TadpoleSprite from '$lib/components/TadpoleSprite.svelte';
|
||||
import PrestigeScreen from '$lib/components/PrestigeScreen.svelte';
|
||||
import ClickParticles from '$lib/components/ClickParticles.svelte';
|
||||
import SidebarTabs from '$lib/components/SidebarTabs.svelte';
|
||||
import { ACHIEVEMENTS } from '$lib/data/achievements';
|
||||
|
||||
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(game.state)).length);
|
||||
|
||||
const sidebarTabs = [
|
||||
{ id: 'production', label: 'Production', icon: '🏭' },
|
||||
{ id: 'evolution', label: 'Evolution', icon: '🧬' },
|
||||
{ id: 'collection', label: 'Collection', icon: '✨' },
|
||||
];
|
||||
|
||||
// Component refs for imperative calls
|
||||
let clickParticles: ReturnType<typeof ClickParticles>;
|
||||
let tadpoleSprite: ReturnType<typeof TadpoleSprite>;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
game.click();
|
||||
clickParticles?.spawn(e.clientX, e.clientY, game.lastClickGain, game.lastClickDouble, game.lastClickCrit);
|
||||
tadpoleSprite?.bounce();
|
||||
}
|
||||
|
||||
// Mobile sidebar toggle
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
</svelte:head>
|
||||
|
||||
{#if !game.ready}
|
||||
<div class="flex items-center justify-center min-h-[80vh]" in:fade>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-16 h-16 border-4 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin"></div>
|
||||
<p class="text-slate-400" style="font-family: var(--font);">Chargement de ta progression...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="zone" data-zone="swamp" in:fade={{ duration: 400 }}>
|
||||
<PrestigeScreen />
|
||||
<ClickParticles bind:this={clickParticles} />
|
||||
|
||||
<!-- Click zone -->
|
||||
<div class="click-zone" onclick={handleClick}>
|
||||
<div in:scale={{ duration: 500, start: 0.8, easing: elasticOut }}>
|
||||
<TadpoleSprite bind:this={tadpoleSprite} />
|
||||
</div>
|
||||
<div
|
||||
class="click-zone-counter"
|
||||
in:fly={{ y: 20, duration: 400, easing: quintOut }}
|
||||
>
|
||||
{formatNumber(game.state.resources)}
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-semibold pointer-events-none"
|
||||
style="color: rgba(255,255,255,0.5); font-family: var(--font); text-shadow: 0 1px 4px rgba(0,0,0,0.6);"
|
||||
>
|
||||
+{formatNumber(game.clickGain)} / clic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="game-sidebar hidden md:flex" in:fly={{ x: 100, duration: 500, easing: quintOut }}>
|
||||
<!-- Pinned: always visible -->
|
||||
<CockpitHeader />
|
||||
<MilestoneBar />
|
||||
|
||||
<!-- Tabbed content -->
|
||||
<SidebarTabs tabs={sidebarTabs}>
|
||||
{#snippet children(activeTab)}
|
||||
{#if activeTab === 'production'}
|
||||
<ClickPanel />
|
||||
<GeneratorShop />
|
||||
<PrestigePanel />
|
||||
{:else if activeTab === 'evolution'}
|
||||
<EvolutionTree />
|
||||
<MilestonesPanel />
|
||||
{:else if activeTab === 'collection'}
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" class="achieve-badge">
|
||||
{achieveCount}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
<a href="/guide" class="achieve-badge" style="border-color: rgba(139, 92, 246, 0.2); background: rgba(139, 92, 246, 0.08); color: #a78bfa;">
|
||||
Guide du Gardien
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarTabs>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom bar: toggle button -->
|
||||
<button
|
||||
class="md:hidden fixed bottom-4 right-4 z-30 w-14 h-14 rounded-full flex items-center justify-center shadow-xl"
|
||||
style="background: rgba(17,17,17,0.9); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1);"
|
||||
onclick={() => sidebarOpen = !sidebarOpen}
|
||||
>
|
||||
<span class="text-2xl">{sidebarOpen ? '✕' : '🎮'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
{#if sidebarOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="md:hidden fixed inset-0 z-20"
|
||||
style="background: rgba(0,0,0,0.5);"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={() => sidebarOpen = false}
|
||||
></div>
|
||||
<div
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 z-25 flex flex-col gap-3 max-h-[75vh] overflow-y-auto rounded-t-2xl p-4 pb-20"
|
||||
style="background: rgba(10,10,10,0.95); backdrop-filter: blur(12px); border-top: 1px solid rgba(255,255,255,0.08);"
|
||||
transition:fly={{ y: 300, duration: 350, easing: quintOut }}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-10 h-1 rounded-full" style="background: rgba(255,255,255,0.2);"></div>
|
||||
</div>
|
||||
|
||||
<CockpitHeader />
|
||||
<MilestoneBar />
|
||||
|
||||
<SidebarTabs tabs={sidebarTabs}>
|
||||
{#snippet children(activeTab)}
|
||||
{#if activeTab === 'production'}
|
||||
<GeneratorShop />
|
||||
<PrestigePanel />
|
||||
{:else if activeTab === 'evolution'}
|
||||
<EvolutionTree />
|
||||
<MilestonesPanel />
|
||||
{:else if activeTab === 'collection'}
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" class="achieve-badge">
|
||||
{achieveCount}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarTabs>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
54
Frontend/src/routes/login/+page.svelte
Normal file
54
Frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { quintOut, backOut } from 'svelte/easing';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { buildAuthUrl, saveVerifier } from '$lib/oauth';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: 'discord', label: 'Discord', emoji: '🎮', color: '#5865F2' },
|
||||
{ id: 'github', label: 'GitHub', emoji: '🐙', color: '#333' },
|
||||
{ id: 'google', label: 'Google', emoji: '🌐', color: '#4285f4' },
|
||||
{ id: 'twitch', label: 'Twitch', emoji: '🎬', color: '#9146FF' },
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.user) goto('/', { replaceState: true });
|
||||
});
|
||||
|
||||
async function handleLogin(provider: string) {
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
|
||||
saveVerifier(verifier);
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Connexion — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<div class="containererror">
|
||||
<div in:scale={{ duration: 400, start: 0.7, easing: backOut }}>
|
||||
<img src="/svg/tadpole.svg" alt="" class="w-24 h-24 mx-auto mb-2 opacity-60" />
|
||||
</div>
|
||||
<h1 in:fly={{ y: 20, delay: 100, duration: 400, easing: quintOut }}>Connexion</h1>
|
||||
<p class="message" in:fly={{ y: 15, delay: 200, duration: 400, easing: quintOut }}>
|
||||
Connecte-toi pour sauvegarder ta progression.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 mt-6 w-full max-w-xs mx-auto">
|
||||
{#each PROVIDERS as p, i}
|
||||
<button
|
||||
class="btn-return flex items-center justify-center gap-2 py-3! text-base!"
|
||||
onclick={() => handleLogin(p.id)}
|
||||
type="button"
|
||||
in:fly={{ y: 20, delay: 300 + i * 80, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<span class="text-xl">{p.emoji}</span>
|
||||
Continuer avec {p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
11
Frontend/src/routes/mentionslegales/+page.svelte
Normal file
11
Frontend/src/routes/mentionslegales/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<svelte:head>
|
||||
<title>Mentions Legales — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey);">
|
||||
<h1>Mentions Legales</h1>
|
||||
<div class="content">
|
||||
<p class="paragraphe">Clickerz est un projet personnel developpe par Tetardtek.</p>
|
||||
<p class="paragraphe">Hebergement : VPS auto-gere.</p>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user