Compare commits

..

24 Commits

Author SHA1 Message Date
d9c9ed1187 fix: snapshot proxy before save — fixes 422 Unprocessable Entity
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
2026-03-28 21:26:43 +01:00
7a8f4f325c feat: click upgrades — buy click power with tadpoles, tied to generators
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
5 click upgrades, each linked to a generator type:
- Nid Douillet (+1/clic, 50 base) — requires owning a Nid
- Eau Fertile (+3/clic, 500 base) — requires a Mare
- Spores Actives (+8/clic, 5k base) — requires a Marecage
- Courant Vital (+20/clic, 50k base) — requires an Etang
- Source Ancestrale (+50/clic, 500k base) — requires a Lac

Cost scales x1.2 per level. Reset at prestige (like generators).
Click gain = (base + upgradePower) × prestige × tree × infraBonus.
ClickPanel shows upgrade shop with level badges and gen requirements.
Adds tadpole sink for active play — strategic choice vs buying generators.
2026-03-28 21:19:01 +01:00
f9dd4c3ca4 feat: click scales with generators (types + quantity), not prod/s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 20s
clickGain = base × prestige × tree × (1 + types×2 + totalOwned×0.05)

Click power is now its own system:
- Each generator TYPE owned: +2 to click mult (diversity = power)
- Each generator UNIT owned: +0.05 (stacking helps but less)
- 5 types × 10 each = x13.5 click multiplier from infra alone
- Decoupled from prod/s — buying generators boosts BOTH systems

ClickPanel shows infra breakdown (types bonus + units bonus).
2026-03-28 21:13:20 +01:00
45b89ebae1 feat: click gain scales with passive production (+1% of prod/s)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
clickGain = base × prestige × tree × (1 + prod/s × 0.01)

At 3.1k/s passive: 10 × 32 = 320 per click instead of 10.
Clicking stays relevant as production grows — always ~1% of prod/s
worth per click. ClickPanel shows the prod multiplier in breakdown.
2026-03-28 21:11:19 +01:00
9caa6691fe feat: show +X/clic under the tadpole counter
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
2026-03-28 21:08:03 +01:00
c549ec259c fix: remove FORMULE section + badge /clic label
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
2026-03-28 21:06:29 +01:00
7c651ded4e fix: game.game. double reference in PrestigeScreen + /clic label
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
PrestigeScreen had game.game.state (double ref) causing undefined error
on prestige. Fixed 5 occurrences. CockpitHeader label /clic → Clic.
2026-03-28 21:04:17 +01:00
f4bc25b3b1 feat: click expected value + contribution display
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
CockpitHeader: /clic shows expected value (double+crit included), amber.
ClickPanel: full breakdown with expected value, contribution estimate
(~5 clics/s → X/s + auto → Y% of total), formula visible.
Passif/clic distinction clear in the cockpit.
2026-03-28 21:01:17 +01:00
25768e3665 feat: click panel breakdown + guide updated
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 20s
ClickPanel in Production tab — full click breakdown:
- Gain per click with formula (base × prestige × tree)
- Double Ponte chance (+ how to unlock if not)
- Crit Ponte chance (+ how to unlock if not)
- Auto-Ponte rate + effective prod/s
- Hint to invest in Ponte branch when no tree bonuses

Guide updated with 11 sections:
- Ponte (clic) section — breakdown, double/crit/auto explained
- Generateurs section — effective prod, multi-buy, share bars
- Prestige section — quadratic scaling formula explained
- Arbre section — 3 branches + convergence detailed
- Capstones section — all 3 + convergence alpha/omega
2026-03-28 20:58:18 +01:00
120f4bedca feat: buy x1/x5/x10/xMax + production preview per generator
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
- buyGenerator() supports quantity param (multi-buy loop)
- maxAffordable() / bulkCost() — compute max purchasable + total cost
- GeneratorShop: mode selector (x1/x5/x10/MAX)
- Each generator shows +X/s in amber — what the next purchase adds
- Button shows total cost + quantity (e.g. "1.5k (x5)")
2026-03-28 20:52:30 +01:00
38e63fdf22 feat: prestige threshold scales quadratically with prestige count
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
threshold = 1M × (1 + 0.1 × prestigeCount)² × (1 - treeReduction)

P0=1M, P5=2.25M, P10=4M, P20=9M, P50=36M
Tree reduction still applies on top. Forces strategic prestige timing
and gives the idle loop longevity.
2026-03-28 20:50:34 +01:00
9d27cb6648 feat: show effective production per generator with all bonuses
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
New generatorEffectiveProduction() — applies prestige mult, tree mult,
nid boost, synergy, and convergence to per-generator production.

GeneratorShop now shows:
- Effective prod/s (green, with all bonuses)
- % share of total production
- Mini progress bar per generator
- When owned=0: shows effective prod per unit (so you see upgrade impact)
2026-03-28 20:47:24 +01:00
39921aa8fc fix: always merge evolutionTree/generators with defaults on load
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
Server saves don't store all node fields (branch, cost, effect, etc.).
migrateSave now always rebuilds tree from DEFAULT_EVOLUTION_TREE,
preserving unlocked state from the save. Fixes empty evolution tree.
2026-03-28 20:44:26 +01:00
1488962537 debug: log evolutionTree state to diagnose empty nodes
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
2026-03-28 20:42:31 +01:00
67931eeadb fix: refactor store to singleton class pattern (s.subscribe fix)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
Exported $state proxies were confused with Svelte stores by SvelteKit
runtime, causing "s.subscribe is not a function" on /jeu.

Fix: encapsulate all $state fields in a Game class, export singleton.
Components import { game } and access game.state, game.click(), etc.
Class fields are proper $state — no raw proxy exported.
2026-03-28 20:39:21 +01:00
cce7fa3190 fix: rename save-sync.ts → save-sync.svelte.ts for $state compat
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 23s
Plain .ts files can't read $state proxies from .svelte.ts modules.
The s.subscribe error was caused by SvelteKit treating the raw signal
as a legacy store. Renaming to .svelte.ts fixes the compilation.
2026-03-28 20:30:52 +01:00
10ff2d32f5 fix: refactor store to direct $state exports + Object.assign mutation
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
Svelte 5 can't export reassigned $state — use const $state + Object.assign.
All components now import state/actions directly (no gameStore wrapper).
Deep reactivity works: evolutionTree nodes, generators, cosmetics all tracked.
2026-03-28 20:23:57 +01:00
ce38975c10 fix: store reactivity — version counter pattern for deep state changes
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s
$state<GameState> caused Svelte 5 to lose track of deep property changes
(evolutionTree[i].branch, generators[i].owned, etc.).

Fix: _stateVersion counter + getState() creates explicit reactive deps.
canPrestige/productionPerSecond are now live getters, no manual updateDerived().
2026-03-28 20:10:21 +01:00
f6bff6e389 feat: migrate frontend React 18 → Svelte 5 + SvelteKit
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
Core logic portable (economy, balance, cosmetics, migrateSave) — zero rewrite.
136 tests green, identiques. Backend inchangé.

- Svelte 5 runes stores (game, auth, toast) remplacent Zustand
- SvelteKit adapter-static SPA (dist/ output, fallback index.html)
- Tailwind v4 conservé, design system .gp-* porté
- Transitions natives : slide, fly, scale, fade sur toute l'UI
- Sidebar tabbée (Production/Evolution/Collection) + CollapsiblePanel
- Mobile bottom sheet avec FAB toggle + backdrop blur
- Click particles réactifs Svelte (plus de DOM impératif)
- TadpoleSprite bounce + glow ring au clic
- Guide refait en accordéon, Achievements avec filtres
- a11y : focus-visible, Escape modals, aria-current, aria-labels
- CI/CD adapté (tests + build + rsync)
- Build 504K (vs ~1.2MB React)
2026-03-28 20:03:21 +01:00
3de0492631 fix: evolution tree tabs — one branch at a time instead of 3 cramped columns
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 18s
2026-03-28 18:52:14 +01:00
a665fdf2f4 feat: toast notifications + guide du gardien
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 19s
- Toast system: store Zustand + ToastContainer (slide-in, auto-dismiss)
- Toasts on: prestige, milestone claim, capstone unlock, cosmetic unlock
- Guide in-game: /guide route, toutes les mecaniques expliquees
- Lien navbar + sidebar
2026-03-28 18:47:41 +01:00
450d559216 fix: guard claimedMilestones in MilestonesPanel component
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 18s
2026-03-28 18:39:27 +01:00
1ca88df3ed fix: smoke test curl -sf fails on 401 — use http_code check instead
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 19s
2026-03-28 18:36:44 +01:00
4df6451dac fix: guard claimedMilestones undefined on v1 saves
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 19s
Saves loaded before migrateSave runs (or stale localStorage) can have
claimedMilestones undefined — guard with ?? [] in all accessors.
2026-03-28 18:33:19 +01:00
123 changed files with 5672 additions and 10142 deletions

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
engine-strict=true

1
Frontend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

3
Frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

43
Frontend/README.md Executable file → Normal file
View 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.

View File

@@ -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>

9057
Frontend/package-lock.json generated Executable file → Normal file

File diff suppressed because it is too large Load Diff

58
Frontend/package.json Executable file → Normal file
View File

@@ -1,35 +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",
"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"
}
}

View File

@@ -1,33 +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 { OfflineReport } from "./components/OfflineReport";
import navData from "./data/NavBarData.json";
function App() {
const [toggleRain, setToggleRain] = useState(false);
return (
<>
<GameTick />
<GameSync />
<OfflineReport />
<Navbar
navData={navData}
toggleRain={toggleRain}
setToggleRain={setToggleRain}
/>
<main>
<Outlet context={[toggleRain, setToggleRain]} />
</main>
<Footer />
</>
);
}
export default App;

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { postCapstoneCost, treeResetCost } from "../core/balance";
import { postCapstoneCost, treeResetCost } from "../lib/core/balance";
describe("postCapstoneCost", () => {
it("first purchase = base cost (no multiplier)", () => {

View File

@@ -7,8 +7,8 @@ import {
unequipSlot,
addToInventory,
DEFAULT_COSMETIC_STATE,
} from "../core/cosmetics";
import { DEFAULT_STATE } from "../core/economy";
} from "../lib/core/cosmetics";
import { DEFAULT_STATE } from "../lib/core/economy";
describe("Cosmetics system", () => {
describe("COSMETICS catalog", () => {

View File

@@ -23,7 +23,7 @@ import {
DEFAULT_STATE,
DEFAULT_GENERATORS,
DEFAULT_EVOLUTION_TREE,
} from "../core/economy";
} from "../lib/core/economy";
// --- PrestigePanel visibility ---

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { migrateSave } from "../core/migrateSave";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../core/economy";
import { CURRENT_SAVE_VERSION } from "../core/balance";
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> = {}) {

View File

@@ -6,7 +6,7 @@ import {
claimMilestone,
getMilestoneStartNid,
getMilestoneOfflineBonus,
} from "../core/economy";
} from "../lib/core/economy";
describe("Prestige Milestones", () => {
it("no claimable milestones at 0 prestiges", () => {

View File

@@ -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;

449
Frontend/src/index.css → Frontend/src/app.css Executable file → Normal file
View File

@@ -1,8 +1,7 @@
@import "tailwindcss";
/* ── Tailwind v4 theme tokens du jeu ── */
/* -- Tailwind v4 theme -- tokens du jeu -- */
@theme {
/* Base colors */
--color-blue-light: #dcecf3;
--color-purple-light: #e4e3f3;
--color-red-light: #c33636;
@@ -10,7 +9,6 @@
--color-grey: #202020;
--color-grey-hover: #606060;
/* Game panel tokens */
--color-gp-bg: rgba(17, 17, 17, 0.75);
--color-gp-bg-hover: rgba(17, 17, 17, 0.85);
--color-gp-border: rgba(255, 255, 255, 0.08);
@@ -27,17 +25,14 @@
--color-gp-btn-disabled: rgba(255, 255, 255, 0.08);
--color-gp-btn-text-disabled: rgba(255, 255, 255, 0.3);
/* Spacing / sizing tokens */
--radius-gp: 0.75rem;
--spacing-gp: 0.75rem;
--spacing-gp-gap: 0.5rem;
/* Font sizes */
--font-size-gp-title: 0.8rem;
--font-size-gp-text: 0.75rem;
--font-size-gp-sm: 0.65rem;
/* Animation */
--animate-gp-pulse: gp-pulse 2s ease-in-out infinite;
}
@@ -46,7 +41,7 @@
50% { box-shadow: 0 0 0 6px rgba(124, 58, 237, 0); }
}
/* ── Global reset & base ── */
/* -- Global reset & base -- */
@layer base {
* {
margin: 0;
@@ -60,15 +55,18 @@
--bg-color: var(--color-blue-light);
}
a {
text-decoration: none;
a { text-decoration: none; }
/* a11y — focus-visible ring */
:focus-visible {
outline: 2px solid var(--color-gp-accent-green);
outline-offset: 2px;
border-radius: 4px;
}
main {
min-height: 92vh;
margin-top: 80px;
padding: 0 0 2rem;
background-color: var(--bg-color);
/* Skip keyboard focus ring on mouse clicks */
:focus:not(:focus-visible) {
outline: none;
}
::-webkit-scrollbar {
@@ -77,7 +75,7 @@
}
}
/* ── Zone system (biomes) ── */
/* -- Zone system (biomes) -- */
@layer components {
.zone {
width: 100%;
@@ -107,7 +105,7 @@
min-height: auto;
}
/* ── Game panels design system ── */
/* -- Game panels design system -- */
.gp {
display: flex;
@@ -147,7 +145,6 @@
.gp-accent-purple { color: var(--color-gp-accent-purple); }
.gp-accent-amber { color: var(--color-gp-accent-amber); }
/* Row item (générateur, noeud évolution) */
.gp-row {
display: flex;
align-items: center;
@@ -163,9 +160,7 @@
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--active:hover { background: rgba(16, 185, 129, 0.18); }
.gp-row--locked {
border-color: var(--color-gp-border);
@@ -183,7 +178,6 @@
background: var(--color-gp-accent-green-bg);
}
/* Bouton achat */
.gp-btn {
font-family: var(--font);
font-size: var(--font-size-gp-sm);
@@ -200,9 +194,7 @@
background: var(--color-gp-btn);
color: white;
}
.gp-btn--buy:hover {
background: var(--color-gp-btn-hover);
}
.gp-btn--buy:hover { background: var(--color-gp-btn-hover); }
.gp-btn--disabled {
background: var(--color-gp-btn-disabled);
@@ -217,11 +209,8 @@
font-size: var(--font-size-gp-text);
animation: var(--animate-gp-pulse);
}
.gp-btn--prestige:hover {
background: #8b5cf6;
}
.gp-btn--prestige:hover { background: #8b5cf6; }
/* Header cockpit (stats résumé) */
.gp-cockpit-header {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -236,7 +225,6 @@
gap: 0.05rem;
}
/* Progress bar */
.gp-progress {
height: 0.35rem;
background: rgba(255, 255, 255, 0.08);
@@ -250,14 +238,12 @@
transition: width 0.5s ease;
}
/* Section separator */
.gp-sep {
height: 1px;
background: var(--color-gp-border);
margin: 0.15rem 0;
}
/* Zone titles in sidebar */
.gp-zone-label {
font-family: var(--font);
font-size: var(--font-size-gp-sm);
@@ -268,7 +254,7 @@
padding-left: 0.2rem;
}
/* ── Home / Game view ── */
/* -- Home / Game view -- */
.click-zone {
display: flex;
@@ -281,9 +267,7 @@
flex: 1;
}
@media (min-width: 768px) {
.click-zone {
padding-right: 22rem;
}
.click-zone { padding-right: 22rem; }
}
.click-zone:active img {
@@ -301,9 +285,7 @@
letter-spacing: 0.02em;
}
@media (min-width: 768px) {
.click-zone-counter {
font-size: 2.5rem;
}
.click-zone-counter { font-size: 2.5rem; }
}
.achieve-badge {
@@ -320,9 +302,7 @@
text-decoration: none;
transition: all 0.15s ease;
}
.achieve-badge:hover {
background: rgba(16, 185, 129, 0.2);
}
.achieve-badge:hover { background: rgba(16, 185, 129, 0.2); }
.click-particle {
position: fixed;
@@ -366,21 +346,18 @@
}
}
@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);
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(100%) scale(0.95); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
/* ── Navbar ── */
@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;
@@ -391,15 +368,11 @@
padding: 0 2rem;
top: 0;
background-color: var(--bg-color);
background-blend-mode: darken;
background-size: cover;
z-index: 99;
box-sizing: border-box;
}
@media (max-width: 999px) {
.header-main {
padding: 0 0.4rem;
}
.header-main { padding: 0 0.4rem; }
}
.logo {
@@ -407,16 +380,13 @@
content: url(/svg/tadpole.svg);
transition: 0.2s;
}
.logo:hover {
transform: scale(0.9);
}
.logo:hover { transform: scale(0.9); }
.navbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
box-sizing: border-box;
cursor: pointer;
}
@@ -428,9 +398,7 @@
list-style-type: none;
}
@media (max-width: 999px) {
.nav-list {
display: none;
}
.nav-list { display: none; }
}
.nav-list li {
@@ -449,41 +417,7 @@
font-weight: 500;
padding: 30px 0;
}
.mainLink:hover {
color: var(--color-red-light);
}
.dropLink {
text-decoration: none;
color: white;
font-weight: 400;
}
.dropLink:hover {
color: var(--color-red-light);
}
.dropdown-content {
display: none;
position: absolute;
background: var(--color-grey);
transform: translateY(30px);
min-width: 160px;
box-shadow: 0 8px 16px rgba(10, 10, 10, 0.2);
z-index: 1;
}
.dropdown-content a {
color: white;
padding: 12px 16px;
text-decoration: none;
display: block;
text-align: left;
}
.dropdown-content a:hover {
background-color: var(--color-grey-hover);
}
.dropdown:hover .dropdown-content {
display: block;
}
.mainLink:hover { color: var(--color-red-light); }
.auth-nav {
display: flex;
@@ -511,116 +445,9 @@
background: var(--color-grey);
color: white;
}
/* ── Burger menu (mobile) ── */
@media (min-width: 1000px) {
.menuToggle {
display: none;
}
}
}
@media (max-width: 999px) {
.menuToggle {
float: left;
position: relative;
box-sizing: border-box;
top: 2px;
left: -10px;
z-index: 99;
user-select: none;
}
.menuToggle a {
text-decoration: none;
color: var(--color-grey);
transition: color 0.3s ease;
}
.menuToggle a:hover {
color: var(--color-red-light);
}
.menuToggle input {
display: block;
width: 40px;
height: 32px;
position: absolute;
top: -7px;
left: -5px;
cursor: pointer;
opacity: 0;
z-index: 2;
}
.menuToggle span {
display: block;
width: 33px;
height: 4px;
margin-bottom: 5px;
position: relative;
background: var(--color-grey);
border-radius: 3px;
z-index: 1;
transform-origin: 4px 0;
transition: transform 0.2s cubic-bezier(0.77, 0.2, 0.05, 1),
background 0.2s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease;
}
.menuToggle span:first-child {
transform-origin: 0% 0%;
}
.menuToggle span:nth-last-child(2) {
transform-origin: 0% 100%;
}
.menuToggle input:checked ~ span {
opacity: 1;
transform: rotate(45deg) translate(-2px, -1px);
background: white;
}
.menuToggle input:checked ~ span:nth-last-child(3) {
opacity: 0;
transform: rotate(0deg) scale(0.2, 0.2);
}
.menuToggle input:checked ~ span:nth-last-child(2) {
transform: rotate(-45deg) translate(0, -1px);
}
.menu {
position: absolute;
display: flex;
flex-direction: column;
width: 280px;
height: 110vh;
margin: -100px 0 0 -231px;
padding: 1.2rem;
padding-top: 100px;
background: var(--color-grey);
list-style-type: none;
transform-origin: 0% 0%;
overflow: hidden;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.menu li {
padding: 10px 0;
font-size: 1.2rem;
font-family: var(--font);
font-weight: 500;
color: white;
}
.menuToggle input:checked ~ ul {
visibility: visible;
opacity: 1;
}
.sousmenu {
display: flex;
flex-direction: column;
margin-left: 1.2rem;
color: white;
font-size: 1.2rem;
font-family: var(--font);
font-weight: 500;
padding-bottom: 1rem;
}
}
/* ── Buttons ── */
/* -- Buttons -- */
@layer components {
.primary-button {
display: flex;
@@ -638,9 +465,7 @@
transition: transform 0.1s ease-in-out;
border: none;
}
.primary-button:hover {
transform: scale(0.95);
}
.primary-button:hover { transform: scale(0.95); }
.secondary-button {
display: flex;
@@ -664,7 +489,7 @@
}
}
/* ── Footer ── */
/* -- Footer -- */
@layer components {
.footer {
display: flex;
@@ -681,7 +506,6 @@
}
.footer-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
width: 90%;
@@ -696,62 +520,7 @@
height: 100px;
transition: all 0.15s ease-in-out;
}
.footer-logo:hover {
transform: scale(0.9);
}
.footer .section {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1.4rem;
}
.section-title {
font-family: var(--font);
font-size: 1.2rem;
color: var(--color-grey);
text-decoration-line: underline;
text-underline-offset: 0.5rem;
}
.section-text {
max-width: 26ch;
font-family: var(--font);
font-size: 1rem;
color: var(--color-grey);
}
.section-list {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
list-style: none;
}
.section-list .section-item,
.section-list a {
width: fit-content;
font-family: var(--font);
font-size: 1rem;
color: var(--color-grey);
transition: all 0.15s ease-in-out;
}
.section-list .section-item:hover,
.section-list a:hover {
transform: scale(0.9);
}
.spacing {
min-width: 150px;
width: 10%;
}
.footer-github {
font-family: var(--font);
font-size: 0.9rem;
font-weight: 500;
color: var(--color-grey);
text-decoration: none;
transition: all 0.15s ease-in-out;
}
.footer-github:hover {
transform: scale(0.95);
}
.footer-logo:hover { transform: scale(0.9); }
.copyright {
font-family: var(--font);
font-size: 0.8rem;
@@ -759,9 +528,10 @@
color: var(--color-grey);
text-align: center;
}
}
/* ── Pages layout (error, legal, settings, login) ── */
/* -- Pages layout -- */
@layer components {
.container {
display: flex;
flex-direction: column;
@@ -775,7 +545,6 @@
color: var(--color-grey);
font-size: 1.8rem;
text-align: center;
width: fit-content;
}
.container h2 {
font-family: var(--font);
@@ -783,82 +552,8 @@
font-weight: 600;
color: var(--color-grey);
}
.container .subtitle {
font-family: var(--font);
color: var(--color-grey);
font-size: 1.2rem;
font-weight: 600;
text-align: left;
margin-bottom: 0.8rem;
}
.container .content {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.container .paragraphe {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 400;
margin-bottom: 0.5rem;
list-style: inside;
}
.container .info {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 400;
}
section {
display: flex;
flex-direction: column;
height: 90vh;
justify-content: center;
width: 100%;
}
.containererror {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.containererror h1 {
font-family: var(--font);
color: var(--color-grey);
font-size: 2rem;
text-align: center;
width: fit-content;
}
.message {
font-family: var(--font);
color: var(--color-grey);
font-size: 1rem;
font-weight: 300;
text-align: center;
}
.btn-return {
display: flex;
justify-content: center;
width: fit-content;
margin: auto;
padding: 0.5rem 1rem;
background-color: var(--color-grey);
border: none;
border-radius: 0.6rem;
font-family: var(--font);
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.btn-return:hover {
transform: scale(0.9);
}
/* ── Achievements ── */
/* -- Achievements -- */
.fullachieve {
display: flex;
flex-direction: column;
@@ -885,13 +580,6 @@
opacity: 0.7;
margin-bottom: 2rem;
}
.achievementscontainer {
margin: auto;
display: flex;
align-items: center;
width: 100%;
padding: 0 2rem;
}
.achievementscardcontainer {
display: flex;
justify-content: center;
@@ -899,6 +587,7 @@
min-height: 200px;
gap: 1rem;
width: 100%;
padding: 0 2rem;
}
.achieve-card {
display: flex;
@@ -910,9 +599,7 @@
max-width: 380px;
transition: transform 0.15s ease;
}
.achieve-card:hover {
transform: translateY(-2px);
}
.achieve-card:hover { transform: translateY(-2px); }
.achieve-unlocked {
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.3);
@@ -922,46 +609,8 @@
border: 1px solid rgba(107, 114, 128, 0.15);
opacity: 0.5;
}
.achieve-icon {
font-size: 2rem;
flex-shrink: 0;
width: 3rem;
text-align: center;
}
.achieve-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.achieve-name {
font-family: var(--font);
font-size: 1rem;
font-weight: 600;
color: var(--color-grey);
}
.achieve-desc {
font-family: var(--font);
font-size: 0.85rem;
color: var(--color-grey);
opacity: 0.7;
}
/* ── Legal / Cookie pages ── */
.mentionslegales {
width: 100%;
margin: 0 auto;
max-width: 1280px;
font-family: var(--font);
display: flex;
flex-direction: column;
gap: 3rem;
padding: 15rem 1rem 4rem;
}
.mentionslegales h2 {
font-family: var(--font);
font-size: 2rem;
font-weight: 600;
color: var(--color-grey);
}
.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
View 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
View 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>

View File

@@ -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>
);
}

View File

@@ -1,68 +0,0 @@
// CosmeticsPanel.tsx — Inventaire cosmétique dans la sidebar
import { useGameStore } from "../store/useGameStore";
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
const SLOT_LABELS: Record<CosmeticSlot, string> = {
hat: "Tête",
eyes: "Yeux",
body: "Corps",
tail: "Queue",
accessory: "Aura",
};
const SLOT_ORDER: CosmeticSlot[] = ["hat", "eyes", "body", "tail", "accessory"];
export function CosmeticsPanel() {
const inventory = useGameStore((s) => s.state.cosmeticInventory);
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
const equip = useGameStore((s) => s.equipCosmetic);
const unequip = useGameStore((s) => s.unequipCosmetic);
if (inventory.length === 0) return null;
const ownedCosmetics = COSMETICS.filter((c) => inventory.includes(c.id));
return (
<div className="gp">
<div className="flex justify-between items-center">
<span className="gp-title">Cosmétiques</span>
<span className="gp-label">{inventory.length}/{COSMETICS.length}</span>
</div>
{SLOT_ORDER.map((slot) => {
const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot);
if (slotCosmetics.length === 0) return null;
const equippedId = equipped[slot];
return (
<div key={slot} className="flex flex-col gap-0.5">
<span className="gp-zone-label">{SLOT_LABELS[slot]}</span>
{slotCosmetics.map((cos) => {
const isEquipped = equippedId === cos.id;
return (
<div
key={cos.id}
className={`gp-row ${isEquipped ? "gp-row--unlocked" : "gp-row--active"}`}
>
<div className="flex flex-col min-w-0">
<span className="gp-value text-[0.7rem]!">{cos.name}</span>
<span className="gp-label">{cos.description}</span>
</div>
<button
onClick={() => isEquipped ? unequip(slot) : equip(cos.id)}
className={`gp-btn ${isEquipped ? "gp-btn--disabled" : "gp-btn--buy"}`}
>
{isEquipped ? "Retirer" : "Équiper"}
</button>
</div>
);
})}
</div>
);
})}
</div>
);
}

View File

@@ -1,265 +0,0 @@
// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
import { useGameStore } from "../store/useGameStore";
import {
canBuyEvolutionNode,
getSpentDna,
getTreeResetCost,
canResetTree,
getRepeatableCost,
canUpgradeConvergence,
} from "../core/economy";
import type { EvolutionNode, Branch } from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
const EFFECT_LABELS: Record<string, (v: number, 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<Branch | "cross", { label: string; color: string; accent: string }> = {
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
};
function NodeRow({
node,
canBuy,
isExcluded,
onBuy,
}: {
node: EvolutionNode;
canBuy: boolean;
isExcluded: boolean;
onBuy: () => void;
}) {
const isCapstone = node.capstone;
const isRepeatable = node.repeatable;
const purchased = node.purchased ?? 0;
const rowClass = node.unlocked
? isCapstone
? "gp-row gp-row--unlocked border-amber-400/40!"
: "gp-row gp-row--unlocked"
: isExcluded
? "gp-row gp-row--locked opacity-30!"
: canBuy
? isCapstone
? "gp-row gp-row--evolution border-amber-400/30!"
: "gp-row gp-row--evolution"
: "gp-row gp-row--locked";
const cost = isRepeatable && node.unlocked
? getRepeatableCost(node)
: isRepeatable
? node.cost
: node.cost;
return (
<div className={rowClass}>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1">
{isCapstone && <span className="text-amber-400 text-[0.6rem]"></span>}
<span className="gp-value text-[0.7rem]!">{node.name}</span>
{isRepeatable && node.unlocked && (
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
)}
{node.exclusive_with && !node.unlocked && !isExcluded && (
<span className="gp-label text-[0.55rem]!">OU</span>
)}
</div>
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
</div>
{node.unlocked && !isRepeatable ? (
<span className="gp-label gp-accent-green">OK</span>
) : node.unlocked && isRepeatable ? (
<button
disabled={!canBuy}
onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{formatNumber(cost)}
</button>
) : isExcluded ? (
<span className="gp-label text-[0.55rem]!">verrouille</span>
) : (
<button
disabled={!canBuy}
onClick={onBuy}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{formatNumber(cost)}
</button>
)}
</div>
);
}
function BranchColumn({ branch }: { branch: Branch }) {
const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode);
const nodes = state.evolutionTree.filter((n) => n.branch === branch);
const config = BRANCH_CONFIG[branch];
return (
<div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
<span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
{nodes.map((node) => {
const isExcluded = node.exclusive_with
? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
: false;
return (
<NodeRow
key={node.id}
node={node}
canBuy={canBuyEvolutionNode(state, node.id)}
isExcluded={isExcluded}
onBuy={() => buyNode(node.id)}
/>
);
})}
</div>
);
}
function ConvergenceSection() {
const state = useGameStore((s) => s.state);
const buyNode = useGameStore((s) => s.buyNode);
const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode);
const conv = state.evolutionTree.find((n) => n.id === "convergence");
if (!conv) return null;
const canBuy = canBuyEvolutionNode(state, "convergence");
const canUpgrade = canUpgradeConvergence(state);
const tier = conv.tier ?? 1;
const maxTier = conv.maxTier ?? 2;
const tierName = tier >= 2 ? "Omega" : "Alpha";
return (
<div className="gp border-t-2 border-purple-500/30">
<span className="gp-title text-center gp-accent-purple">
Convergence {conv.unlocked ? tierName : ""}
</span>
{conv.unlocked ? (
<div className="flex flex-col gap-1">
<div className="gp-row gp-row--unlocked border-purple-400/30!">
<div className="flex flex-col">
<span className="gp-value text-[0.7rem]!">
{tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier})
</span>
<span className="gp-label">
{tier >= 2
? "+10% tous effets + -20% cout post-capstones"
: "+10% a tous les effets de l'arbre"
}
</span>
</div>
<span className="gp-label gp-accent-green">OK</span>
</div>
{tier < maxTier && (
<button
disabled={!canUpgrade}
onClick={upgradeConv}
className={`gp-btn ${canUpgrade ? "gp-btn--buy" : "gp-btn--disabled"} w-full`}
>
{canUpgrade
? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)`
: `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`
}
</button>
)}
</div>
) : (
<div className="gp-row gp-row--locked">
<div className="flex flex-col">
<span className="gp-value text-[0.7rem]!">Convergence Alpha</span>
<span className="gp-label">+10% a tous les effets de l'arbre</span>
<span className="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
</div>
<button
disabled={!canBuy}
onClick={() => buyNode("convergence")}
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
>
{conv.cost}
</button>
</div>
)}
</div>
);
}
export function EvolutionTree() {
const state = useGameStore((s) => s.state);
const resetTree = useGameStore((s) => s.resetTree);
const { prestigeCount, ancestralDna, evolutionTree } = state;
if (prestigeCount < 1) return null;
const spentDna = getSpentDna(evolutionTree);
const hasUnlocked = spentDna > 0;
const resetCost = getTreeResetCost(state);
const canReset = canResetTree(state);
const handleReset = () => {
if (!canReset) return;
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
const confirmed = window.confirm(
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
`Tous les noeuds seront verrouilles.\n\n` +
`Confirmer ?`
);
if (confirmed) resetTree();
};
return (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center px-1">
<span className="gp-title">Evolution</span>
<div className="flex items-center gap-2">
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
{hasUnlocked && (
<button
onClick={handleReset}
disabled={!canReset}
className={`gp-btn text-[0.55rem]! ${
canReset
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
: "gp-btn--disabled"
}`}
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
>
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
</button>
)}
</div>
</div>
<div className="flex gap-1.5">
<BranchColumn branch="ponte" />
<BranchColumn branch="marais" />
<BranchColumn branch="adaptation" />
</div>
<ConvergenceSection />
</div>
);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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.generatorCostWithTree);
return (
<div className="gp">
<div className="flex justify-between items-center">
<span className="gp-title" title="Achète des générateurs pour produire des têtards automatiquement">Générateurs</span>
<span className="gp-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 className="flex flex-col min-w-0">
<div className="flex items-center gap-1">
<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>
);
}

View File

@@ -1,37 +0,0 @@
// MilestoneBar.tsx — Progression vers le prochain prestige
import { useGameStore } from "../store/useGameStore";
import { formatNumber, } from "../utils/formatNumber";
import { getPrestigeThreshold } from "../core/economy";
export function MilestoneBar() {
const state = useGameStore((s) => s.state);
const resources = state.resources;
const threshold = getPrestigeThreshold(state);
const progress = Math.min(resources / threshold, 1);
const progressPercent = (progress * 100).toFixed(1);
const remaining = Math.max(threshold - resources, 0);
return (
<div className="gp gap-1">
<div className="flex justify-between">
<span className="gp-label">Prochaine Génération</span>
<span className="gp-label">
{formatNumber(resources)} / {formatNumber(threshold)}
</span>
</div>
<div className="gp-progress">
<div
className="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="gp-label text-right">
{remaining > 0
? `${formatNumber(remaining)} restants`
: "Nouvelle Génération disponible !"}
</span>
</div>
);
}

View File

@@ -1,89 +0,0 @@
// MilestonesPanel.tsx — Paliers de prestige (Sprint 3)
// Progress bar vers le prochain milestone, claim button, preview reward
import { useGameStore } from "../store/useGameStore";
import { getClaimableMilestones, getNextMilestone } from "../core/economy";
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
export function MilestonesPanel() {
const state = useGameStore((s) => s.state);
const claim = useGameStore((s) => s.claimMilestone);
if (state.prestigeCount < 1) return null;
const claimable = getClaimableMilestones(state);
const nextMilestone = getNextMilestone(state);
const totalClaimed = state.claimedMilestones.length;
return (
<div className="gp">
<div className="flex justify-between items-center">
<span className="gp-title">Milestones</span>
<span className="gp-label">{totalClaimed}/{PRESTIGE_MILESTONES.length}</span>
</div>
{/* Claimable milestones */}
{claimable.length > 0 && (
<div className="flex flex-col gap-1.5">
{claimable.map((m) => (
<div key={m.id} className="gp-row gp-row--evolution border-purple-400/30!">
<div className="flex flex-col min-w-0">
<span className="gp-value text-[0.7rem]!">{m.name}</span>
<span className="gp-label">{m.reward.label}</span>
</div>
<button
onClick={() => claim(m.id)}
className="gp-btn gp-btn--buy"
>
Claim
</button>
</div>
))}
</div>
)}
{/* Progress vers le prochain milestone */}
{nextMilestone && (
<div className="flex flex-col gap-1">
<div className="flex justify-between">
<span className="gp-label">Prochain : {nextMilestone.name}</span>
<span className="gp-label">
{state.prestigeCount}/{nextMilestone.threshold}
</span>
</div>
<div className="gp-progress">
<div
className="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400"
style={{
width: `${Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}%`,
}}
/>
</div>
<span className="gp-label">{nextMilestone.reward.label}</span>
</div>
)}
{/* Tous les milestones réclamés */}
{!nextMilestone && claimable.length === 0 && (
<span className="gp-label text-center gp-accent-purple">
Tous les milestones reclames !
</span>
)}
{/* Liste compacte des milestones passés */}
{totalClaimed > 0 && claimable.length === 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{PRESTIGE_MILESTONES.filter((m) => state.claimedMilestones.includes(m.id)).map((m) => (
<span
key={m.id}
className="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
title={`${m.name}${m.description}`}
>
{m.threshold}
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -1,65 +0,0 @@
// OfflineReport.tsx — Écran "Pendant ton absence..." affiché au retour offline
import { useGameStore } from "../store/useGameStore";
import { formatNumber } from "../utils/formatNumber";
function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60_000);
if (minutes < 60) return `${minutes}min`;
const hours = Math.floor(minutes / 60);
const remainMinutes = minutes % 60;
return remainMinutes > 0 ? `${hours}h${remainMinutes}min` : `${hours}h`;
}
export function OfflineReport() {
const report = useGameStore((s) => s.offlineReport);
const dismiss = useGameStore((s) => s.dismissOfflineReport);
if (!report || !report.wasOffline) return null;
const effPercent = Math.round(report.efficiency * 100);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="gp max-w-sm w-full mx-4 text-center">
<span className="gp-title text-lg!">Pendant ton absence...</span>
<div className="flex flex-col gap-3 mt-2">
<div className="flex justify-between">
<span className="gp-label">Durée</span>
<span className="gp-value">{formatDuration(report.duration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">Efficacité marais</span>
<span className={`gp-value ${effPercent > 50 ? "gp-accent-green" : "gp-accent-amber"}`}>
{effPercent}%
</span>
</div>
<div className="gp-sep" />
<div className="flex justify-between items-center">
<span className="gp-label">Têtards récoltés</span>
<span className="gp-value gp-accent-green text-lg!">
+{formatNumber(report.gains)}
</span>
</div>
{report.efficiency < 0.5 && (
<p className="gp-label text-center">
Le marais s'endort sans toi... Joue activement pour maximiser ta production !
</p>
)}
</div>
<button
onClick={dismiss}
className="gp-btn gp-btn--buy w-full mt-3 py-2!"
>
Retour au marais
</button>
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
// PrestigePanel.tsx — Nouvelle Génération (prestige)
import { useGameStore } from "../store/useGameStore";
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
export function PrestigePanel() {
const state = useGameStore((s) => s.state);
const canPrestige = useGameStore((s) => s.canPrestige);
const openPrestigeScreen = useGameStore((s) => s.openPrestigeScreen);
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state);
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 className="flex flex-col gap-1.5">
<span className="gp-value gp-accent-purple">
+{dnaPreview} ADN · +0.1x mult
</span>
<button onClick={openPrestigeScreen} className="gp-btn gp-btn--prestige">
Nouvelle Generation
</button>
</div>
) : (
<span className="gp-label">Atteins {formatNumber(threshold)} tetards pour prestige</span>
)}
</div>
);
}

View File

@@ -1,182 +0,0 @@
// PrestigeScreen.tsx — Écran de prestige fullscreen (Sprint 3)
// Preview ADN, stats de run, comparaison meilleure run, confirmation
import { useGameStore } from "../store/useGameStore";
import {
computePrestigeDna,
getPrestigeDnaBonus,
getPrestigeThreshold,
} from "../core/economy";
import { formatNumber } from "../utils/formatNumber";
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}
export function PrestigeScreen() {
const show = useGameStore((s) => s.showPrestigeScreen);
const close = useGameStore((s) => s.closePrestigeScreen);
const prestige = useGameStore((s) => s.prestige);
const state = useGameStore((s) => s.state);
if (!show) return null;
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
const dnaPreview = Math.floor(baseDna * (1 + dnaBonus));
const threshold = getPrestigeThreshold(state);
const canPrestige = state.lifetimeTadpoles >= threshold;
// Run stats
const now = Date.now();
const runDuration = now - state.runStats.startedAt;
const bestRun = state.runStats.bestRun;
// Comparison with best run
const isBestAdn = !bestRun || dnaPreview > bestRun.adn;
const isBestTadpoles = !bestRun || state.lifetimeTadpoles > bestRun.tadpoles;
const handlePrestige = () => {
if (canPrestige) prestige();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm">
<div className="gp max-w-md w-full mx-4">
{/* Header */}
<div className="text-center">
<span className="gp-title text-lg!">Nouvelle Generation</span>
<p className="gp-label mt-1">
Generation #{state.prestigeCount + 1}
</p>
</div>
<div className="gp-sep" />
{/* ADN Preview */}
<div className="flex flex-col items-center gap-1 py-2">
<span className="gp-label">ADN Ancestral</span>
<span className="text-3xl font-extrabold" style={{ color: "#a78bfa", fontFamily: "var(--font)" }}>
+{formatNumber(dnaPreview)}
</span>
{dnaBonus > 0 && (
<span className="gp-label">
(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)
</span>
)}
<span className="gp-label mt-1">
Total apres : {formatNumber(state.ancestralDna + dnaPreview)} ADN
</span>
</div>
<div className="gp-sep" />
{/* Run Stats */}
<div className="flex flex-col gap-2">
<span className="gp-zone-label">Stats de la run</span>
<div className="flex justify-between">
<span className="gp-label">Duree</span>
<span className="gp-value">{formatDuration(runDuration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">Tetards produits</span>
<span className={`gp-value ${isBestTadpoles ? "gp-accent-green" : ""}`}>
{formatNumber(state.lifetimeTadpoles)}
{isBestTadpoles && bestRun && " ★"}
</span>
</div>
<div className="flex justify-between">
<span className="gp-label">ADN cette run</span>
<span className={`gp-value ${isBestAdn ? "gp-accent-green" : ""}`}>
{formatNumber(dnaPreview)}
{isBestAdn && bestRun && " ★"}
</span>
</div>
{bestRun && (
<div className="flex justify-between">
<span className="gp-label">Vitesse vs meilleure</span>
<span className={`gp-value ${
runDuration < bestRun.duration ? "gp-accent-green" : "gp-accent-amber"
}`}>
{runDuration < bestRun.duration
? `${Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide`
: runDuration > bestRun.duration
? `${Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent`
: "identique"
}
</span>
</div>
)}
</div>
{bestRun && (
<>
<div className="gp-sep" />
<div className="flex flex-col gap-1">
<span className="gp-zone-label">Meilleure run</span>
<div className="flex justify-between">
<span className="gp-label">Duree</span>
<span className="gp-value">{formatDuration(bestRun.duration)}</span>
</div>
<div className="flex justify-between">
<span className="gp-label">ADN</span>
<span className="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
</div>
</div>
</>
)}
<div className="gp-sep" />
{/* Reset info */}
<div className="text-center">
<p className="gp-label">
Tetards et generateurs remis a zero.
</p>
<p className="gp-label">
Arbre d'Evolution et cosmetiques conserves.
</p>
<p className="gp-label mt-1">
+1 reset d'arbre gratuit offert.
</p>
</div>
{/* Actions */}
<div className="flex gap-2 mt-1">
<button
onClick={close}
className="gp-btn flex-1 py-2!"
style={{ background: "rgba(255,255,255,0.08)", color: "rgba(255,255,255,0.7)" }}
>
Annuler
</button>
{canPrestige ? (
<button
onClick={handlePrestige}
className="gp-btn gp-btn--prestige flex-1 py-2!"
>
Nouvelle Generation
</button>
) : (
<button
className="gp-btn gp-btn--disabled flex-1 py-2!"
disabled
>
{formatNumber(threshold - state.lifetimeTadpoles)} tetards manquants
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
// TadpoleSprite.tsx — Sprite têtard avec overlays cosmétiques équipés
import { useGameStore } from "../store/useGameStore";
import { COSMETICS, type CosmeticSlot } from "../core/cosmetics";
const SLOT_ORDER: CosmeticSlot[] = ["body", "tail", "eyes", "hat", "accessory"];
export function TadpoleSprite() {
const equipped = useGameStore((s) => s.state.cosmeticEquipped);
const overlays = SLOT_ORDER
.map((slot) => {
const cosId = equipped[slot];
if (!cosId) return null;
return COSMETICS.find((c) => c.id === cosId);
})
.filter(Boolean);
return (
<div className="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px]">
{/* Base sprite */}
<img
src="/svg/tadpole.svg"
alt="Têtard"
className="w-full h-full object-contain transition-transform duration-100"
draggable={false}
/>
{/* Cosmetic overlays */}
{overlays.map((cos) => (
<img
key={cos!.id}
src={cos!.svg}
alt={cos!.name}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
draggable={false}
/>
))}
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { NavLink as Link } from "react-router-dom";
import PropTypes from "prop-types";
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;

View File

@@ -1,15 +0,0 @@
import PropTypes from "prop-types";
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>
);
}

View File

@@ -1,15 +0,0 @@
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>
);
}

View File

@@ -1,57 +0,0 @@
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 daccueil"
/>
<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>
);
}

View File

@@ -1,119 +0,0 @@
import { NavLink as Link } from "react-router-dom";
import PropTypes from "prop-types";
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: [],
};

View File

@@ -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 };

View File

@@ -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": []
}

View File

@@ -1,14 +0,0 @@
[
{
"id": "1",
"linkname": "Jeu",
"linkurl": "/jeu",
"btn": false
},
{
"id": "3",
"linkname": "Succès",
"linkurl": "/achievements",
"btn": false
}
]

View File

@@ -1,148 +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";
import { migrateSave } from "../core/migrateSave";
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) {
const migrated = migrateSave(data.gameState);
onLoad(migrated);
lastSaveRef.current = data.lastSave;
console.info("[SaveSync] Loaded save from server — server is authority (v%d)", migrated.saveVersion);
} 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)) {
const migrated = migrateSave(data.gameState);
onLoad(migrated);
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 };
}

View File

@@ -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
View 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();
}

View 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>

View 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>

View 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>

View 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>

View 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}

View 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}

View 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">&copy; {new Date().getFullYear()} Clickerz — Tetard Universe</p>
</footer>

View 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>

View 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>

View 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>

View 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>

View 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}

View 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>

View 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}

View 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>

View 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}

View 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}

View 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>

View 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}

View File

@@ -86,11 +86,39 @@ export interface RunStats {
} | 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;
@@ -186,8 +214,9 @@ export function computePrestigeDna(lifetimeTadpoles: number, prestigeCount: numb
// 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 && !state.claimedMilestones.includes(m.id)
(m) => state.prestigeCount >= m.threshold && !claimed.includes(m.id)
);
}
@@ -201,11 +230,12 @@ export function claimMilestone(state: GameState, milestoneId: string): GameState
const milestone = PRESTIGE_MILESTONES.find((m) => m.id === milestoneId);
if (!milestone) return null;
if (state.prestigeCount < milestone.threshold) return null;
if (state.claimedMilestones.includes(milestoneId)) return null;
const claimed = state.claimedMilestones ?? [];
if (claimed.includes(milestoneId)) return null;
let newState = {
...state,
claimedMilestones: [...state.claimedMilestones, milestoneId],
claimedMilestones: [...claimed, milestoneId],
};
// Appliquer la récompense
@@ -224,13 +254,13 @@ export function claimMilestone(state: GameState, milestoneId: string): GameState
// Bonus gameplay cumulés depuis les milestones réclamés
export function getMilestoneStartNid(state: GameState): number {
const claimed = state.claimedMilestones;
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;
const claimed = state.claimedMilestones ?? [];
if (claimed.includes("milestone_15")) return 0.05; // +5% offline cap
return 0;
}
@@ -573,20 +603,19 @@ export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
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 {
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
const base = state.generators.reduce(
(sum, gen) => {
const boost = gen.id === "nid" ? nidBoost : 1;
return sum + gen.baseProduction * gen.owned * boost;
},
0
);
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
return state.generators.reduce((sum, gen) => sum + generatorEffectiveProduction(gen, state), 0);
}
// Lazy calculation : ressources accumulées depuis lastTick
@@ -610,10 +639,71 @@ export function applyIdleGains(state: GameState, now: number): GameState {
};
}
// Gain de base par clic (sans RNG — pour affichage tooltip)
// 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);
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
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 {
@@ -660,29 +750,70 @@ export function applyClick(state: GameState, rng: number = Math.random()): Click
}
// Achat d'un générateur (retourne null si fonds insuffisants)
export function buyGenerator(state: GameState, genId: string): GameState | null {
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;
const gen = state.generators[genIndex];
const cost = generatorCost(gen, state.evolutionTree);
if (state.resources < cost) 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, owned: gen.owned + 1 };
updatedGenerators[genIndex] = gen;
return {
...state,
resources: state.resources - cost,
generators: updatedGenerators,
};
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);
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
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 {
@@ -736,6 +867,8 @@ export function applyPrestige(state: GameState): GameState {
},
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
};
}
@@ -754,6 +887,7 @@ export const DEFAULT_STATE: GameState = {
resources: 0,
clickMultiplier: 1,
generators: DEFAULT_GENERATORS,
clickUpgrades: DEFAULT_CLICK_UPGRADES,
lastTick: Date.now(),
lastOnline: Date.now(),
prestigeCount: 0,

View File

@@ -3,8 +3,8 @@
// Chaque sprint ajoute un step (v2→v3, etc.)
import { CURRENT_SAVE_VERSION } from "./balance";
import type { GameState } from "./economy";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
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.
@@ -23,6 +23,20 @@ export function migrateSave(raw: Record<string, unknown>): GameState {
// 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;
}
@@ -140,3 +154,25 @@ function mergeGenerators(
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 };
});
}

View File

@@ -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;

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -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
View 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);
}

View 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;
}

View 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,
};

View 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();

View 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;

View File

@@ -1,67 +0,0 @@
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import "./index.css";
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>
);

View File

@@ -1,25 +0,0 @@
import { Link } from "react-router-dom";
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>
);
}

View File

@@ -1,44 +0,0 @@
import { useGameStore } from "../store/useGameStore";
import { ACHIEVEMENTS } from "../data/achievements";
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;

View File

@@ -1,81 +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";
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>
);
}

View File

@@ -1,99 +0,0 @@
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;

View File

@@ -1,168 +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 { TadpoleSprite } from "../components/TadpoleSprite";
import { CosmeticsPanel } from "../components/CosmeticsPanel";
import { PrestigeScreen } from "../components/PrestigeScreen";
import { MilestonesPanel } from "../components/MilestonesPanel";
import { ACHIEVEMENTS } from "../data/achievements";
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 lastClickGain = useGameStore((s) => s.lastClickGain);
const lastClickDouble = useGameStore((s) => s.lastClickDouble);
const lastClickCrit = useGameStore((s) => s.lastClickCrit);
const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => {
const particle = document.createElement("span");
particle.className = "click-particle";
const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : "";
particle.textContent = `${prefix}+${formatNumber(gain)}`;
if (isCrit) particle.style.color = "#f59e0b";
else if (isDouble) particle.style.color = "#a78bfa";
particle.style.left = `${clientX}px`;
particle.style.top = `${clientY}px`;
document.body.appendChild(particle);
setTimeout(() => {
if (particle.parentNode) particle.parentNode.removeChild(particle);
}, 800);
}, []);
const handleIncrement = useCallback((e) => {
click();
// Read latest click result from store after click
const s = useGameStore.getState();
createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit);
}, [click, createParticle]);
// 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 className="text-center text-slate-400 mt-[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>
<PrestigeScreen />
{/* Clicker area — centre */}
<div className="click-zone" onClick={handleIncrement}>
<TadpoleSprite />
<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 />
<MilestonesPanel />
<EvolutionTree />
<CosmeticsPanel />
<a href="/achievements" className="achieve-badge">
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
</a>
</aside>
</main>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -1,50 +0,0 @@
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;

View File

@@ -1,48 +0,0 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { buildAuthUrl, saveVerifier } from "../lib/oauth";
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 className="flex flex-col gap-2 mt-4">
{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>
);
}

View File

@@ -1,213 +0,0 @@
import { useEffect, useState } from "react";
import { useAuth } from "../context/AuthContext";
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`,
}),
});
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 max-w-[500px]">
<h1>Paramètres</h1>
{error && (
<p className="text-red-500 text-[13px] mb-4">{error}</p>
)}
{/* Profile info */}
{profile && (
<div className="mb-6 text-left">
<p className="text-sm text-gray-400 my-1">
<strong>Pseudo :</strong> {profile.nickname}
</p>
<p className="text-sm text-gray-400 my-1">
<strong>Email :</strong> {profile.email || "—"}
</p>
</div>
)}
{/* Linked providers */}
<h2 className="text-lg mb-3">Comptes liés</h2>
<div className="flex flex-col gap-2">
{PROVIDERS.map((provider) => {
const linked = linkedNames.has(provider);
const isLoading = actionLoading === provider;
return (
<div
key={provider}
className={`flex items-center justify-between px-3 py-2 rounded-lg border ${
linked
? "bg-[#1a2a1a] border-[#2a4a2a]"
: "bg-[#1a1a2a] border-[#2a2a4a]"
}`}
>
<span className="text-sm">
{EMOJIS[provider]} {provider.charAt(0).toUpperCase() + provider.slice(1)}
{linked && (
<span className="text-green-400 text-xs ml-2"> lié</span>
)}
</span>
{linked ? (
<button
className="btn-return text-xs! py-1! px-2.5!"
disabled={!canUnlink || isLoading}
onClick={() => handleUnlink(provider)}
type="button"
style={{ opacity: canUnlink ? 1 : 0.4 }}
>
{isLoading ? "..." : "Délier"}
</button>
) : (
<button
className="btn-return text-xs! py-1! px-2.5!"
disabled={isLoading}
onClick={() => handleLink(provider)}
type="button"
>
{isLoading ? "..." : "Lier"}
</button>
)}
</div>
);
})}
</div>
{/* Logout */}
<button
className="btn-return mt-6 w-full!"
onClick={logout}
type="button"
>
Déconnexion
</button>
</div>
</section>
);
}

View 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>

View 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 />

View File

@@ -0,0 +1,2 @@
export const prerender = false;
export const ssr = false;

View 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>

View 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>

View 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>

View 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>

View 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>

View 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}

View 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>

View 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>

View File

@@ -0,0 +1,172 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fly, fade, scale } from 'svelte/transition';
import { quintOut, backOut } from 'svelte/easing';
import { authStore } from '$lib/stores/auth.svelte';
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const PROVIDERS = [
{ id: 'discord', emoji: '🎮', label: 'Discord' },
{ id: 'github', emoji: '🐙', label: 'GitHub' },
{ id: 'google', emoji: '🌐', label: 'Google' },
{ id: 'twitch', emoji: '🎬', label: 'Twitch' },
];
function getOAuthToken() { return localStorage.getItem('clkz_oauth_token'); }
async function oauthFetch(path: string, options: RequestInit = {}) {
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'); }
if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.message || `HTTP ${res.status}`); }
return res.json();
}
let profile = $state<any>(null);
let linkedProviders = $state<any[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let actionLoading = $state<string | null>(null);
async function fetchProfile() {
loading = true; error = null;
try {
const data = await oauthFetch('/user/profile');
profile = data.data.user;
linkedProviders = data.data.linkedProviders;
} catch (e: any) { error = e.message; }
finally { loading = false; }
}
async function handleLink(provider: string) {
actionLoading = provider; error = null;
try {
const data = await oauthFetch(`/oauth/${provider}/link`, {
method: 'POST',
body: JSON.stringify({ returnUrl: `${window.location.origin}/settings` }),
});
window.location.href = data.data.authUrl;
} catch (e: any) { error = e.message; actionLoading = null; }
}
async function handleUnlink(provider: string) {
if (!confirm(`Delier ${provider} ?`)) return;
actionLoading = provider; error = null;
try { await oauthFetch(`/oauth/${provider}/unlink`, { method: 'DELETE' }); await fetchProfile(); }
catch (e: any) { error = e.message; }
finally { actionLoading = null; }
}
onMount(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('linked')) window.history.replaceState({}, '', '/settings');
if (params.get('error')) { error = params.get('error'); window.history.replaceState({}, '', '/settings'); }
if (authStore.user) fetchProfile();
});
</script>
<svelte:head>
<title>Parametres — Clickerz</title>
</svelte:head>
<section>
<div class="containererror max-w-[500px]">
{#if !authStore.user}
<p class="message" in:fade>Connecte-toi pour acceder aux parametres.</p>
{:else if loading}
<div class="flex flex-col items-center gap-3" in:fade>
<div class="w-10 h-10 border-3 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin"></div>
<p class="message">Chargement du profil...</p>
</div>
{:else}
<h1 in:fly={{ y: -15, duration: 400, easing: quintOut }}>Parametres</h1>
{#if error}
<p class="text-red-500 text-sm mb-4" in:fly={{ x: -10, duration: 200 }} role="alert">{error}</p>
{/if}
<!-- Profile card -->
{#if profile}
<div
class="rounded-xl p-4 mb-6 text-left"
style="background: rgba(0,0,0,0.04); border: 1px solid rgba(0,0,0,0.06);"
in:fly={{ y: 15, delay: 100, duration: 300, easing: quintOut }}
>
<p class="text-sm my-1" style="color: var(--color-grey); font-family: var(--font);">
<strong>Pseudo :</strong> {profile.nickname}
</p>
<p class="text-sm my-1" style="color: var(--color-grey); font-family: var(--font); opacity: 0.7;">
<strong>Email :</strong> {profile.email || '—'}
</p>
</div>
{/if}
<!-- Linked providers -->
<h2
class="text-lg mb-3"
style="font-family: var(--font); color: var(--color-grey);"
in:fly={{ y: 10, delay: 200, duration: 300, easing: quintOut }}
>
Comptes lies
</h2>
<div class="flex flex-col gap-2">
{#each PROVIDERS as provider, i}
{@const linked = linkedProviders.some((p: any) => p.provider === provider.id)}
{@const isLoading = actionLoading === provider.id}
{@const canUnlink = linkedProviders.length > 1}
<div
class="flex items-center justify-between px-4 py-3 rounded-xl border transition-all duration-200"
style="
background: {linked ? 'rgba(16,185,129,0.05)' : 'rgba(0,0,0,0.03)'};
border-color: {linked ? 'rgba(16,185,129,0.2)' : 'rgba(0,0,0,0.06)'};
"
in:fly={{ y: 15, delay: 250 + i * 60, duration: 300, easing: quintOut }}
>
<span class="text-sm" style="font-family: var(--font); color: var(--color-grey);">
{provider.emoji} {provider.label}
{#if linked}
<span class="text-emerald-600 text-xs ml-2 font-medium">✓ lie</span>
{/if}
</span>
{#if linked}
<button
class="btn-return text-xs! py-1! px-3!"
disabled={!canUnlink || isLoading}
onclick={() => handleUnlink(provider.id)}
style="opacity: {canUnlink ? 1 : 0.4};"
type="button"
>
{isLoading ? '...' : 'Delier'}
</button>
{:else}
<button
class="btn-return text-xs! py-1! px-3!"
disabled={isLoading}
onclick={() => handleLink(provider.id)}
type="button"
>
{isLoading ? '...' : 'Lier'}
</button>
{/if}
</div>
{/each}
</div>
<!-- Logout -->
<button
class="btn-return mt-8 w-full! py-2.5!"
onclick={() => authStore.logout()}
type="button"
in:fly={{ y: 15, delay: 500, duration: 300, easing: quintOut }}
>
Deconnexion
</button>
{/if}
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More