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)
This commit is contained in:
2026-03-28 20:03:21 +01:00
parent 3de0492631
commit f6bff6e389
125 changed files with 5323 additions and 10373 deletions

View File

@@ -42,11 +42,16 @@ jobs:
npm ci npm ci
npm run build npm run build
- name: Run frontend tests
working-directory: Frontend
run: npx vitest run
- name: Deploy frontend - name: Deploy frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: | run: |
mkdir -p /var/www/clickerz/frontend/dist mkdir -p /var/www/clickerz/frontend/dist
rsync -a --delete Frontend/dist/ /var/www/clickerz/frontend/dist/ rsync -a --delete Frontend/dist/ /var/www/clickerz/frontend/dist/
echo "✅ Frontend Svelte deployed"
# ── Smoke test ─────────────────────────────────────────────────────────── # ── Smoke test ───────────────────────────────────────────────────────────
- name: Smoke test API - name: Smoke test API

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 node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files # Output
.vscode/* .output
!.vscode/extensions.json .vercel
.idea .netlify
.wrangler
/.svelte-kit
/build
/dist
# OS
.DS_Store .DS_Store
*.suo Thumbs.db
*.ntvs*
*.njsproj # Env
*.sln .env
*.sw? .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>

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

File diff suppressed because it is too large Load Diff

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

@@ -1,35 +1,27 @@
{ {
"name": "template", "name": "clickerz-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "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" "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": { "devDependencies": {
"@types/react": "^18.3.28", "@sveltejs/adapter-static": "^3.0.10",
"@types/react-dom": "^18.2.15", "@sveltejs/kit": "^2.50.2",
"@vitejs/plugin-react": "^4.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"eslint": "^8.53.0", "@tailwindcss/vite": "^4.2.2",
"eslint-plugin-react": "^7.33.2", "svelte": "^5.54.0",
"eslint-plugin-react-hooks": "^4.6.0", "svelte-check": "^4.4.2",
"eslint-plugin-react-refresh": "^0.4.4", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^5.0.0", "vite": "^7.3.1",
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
} }

View File

@@ -1,35 +0,0 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import Navbar from "./components/navbar";
import Footer from "./components/footer";
import { GameTick } from "./components/GameTick";
import { GameSync } from "./components/GameSync";
import { OfflineReport } from "./components/OfflineReport";
import { ToastContainer } from "./components/ToastContainer";
import navData from "./data/NavBarData.json";
function App() {
const [toggleRain, setToggleRain] = useState(false);
return (
<>
<GameTick />
<GameSync />
<OfflineReport />
<ToastContainer />
<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 { describe, it, expect } from "vitest";
import { postCapstoneCost, treeResetCost } from "../core/balance"; import { postCapstoneCost, treeResetCost } from "../lib/core/balance";
describe("postCapstoneCost", () => { describe("postCapstoneCost", () => {
it("first purchase = base cost (no multiplier)", () => { it("first purchase = base cost (no multiplier)", () => {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { migrateSave } from "../core/migrateSave"; import { migrateSave } from "../lib/core/migrateSave";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../core/economy"; import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../lib/core/economy";
import { CURRENT_SAVE_VERSION } from "../core/balance"; import { CURRENT_SAVE_VERSION } from "../lib/core/balance";
// Minimal Sprint 2 save (v1 — no saveVersion field) // Minimal Sprint 2 save (v1 — no saveVersion field)
function makeV1Save(overrides: Record<string, unknown> = {}) { function makeV1Save(overrides: Record<string, unknown> = {}) {

View File

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

View File

@@ -2,7 +2,7 @@
// Ported from backend saveControllers.validateGameState for unit testing // Ported from backend saveControllers.validateGameState for unit testing
import { describe, it, expect } from "vitest"; 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 // Reproduce the validation logic client-side for testing
const MAX_PRODUCTION_PER_SECOND = 750_000; const MAX_PRODUCTION_PER_SECOND = 750_000;

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

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

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,289 +0,0 @@
// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
import { useState } from "react";
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>
);
}
const BRANCHES: Branch[] = ["ponte", "marais", "adaptation"];
export function EvolutionTree() {
const state = useGameStore((s) => s.state);
const resetTree = useGameStore((s) => s.resetTree);
const { prestigeCount, ancestralDna, evolutionTree } = state;
const [activeBranch, setActiveBranch] = useState<Branch>("ponte");
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>
{/* Branch tabs */}
<div className="flex gap-1">
{BRANCHES.map((branch) => {
const config = BRANCH_CONFIG[branch];
const isActive = activeBranch === branch;
return (
<button
key={branch}
onClick={() => setActiveBranch(branch)}
className={`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>
);
})}
</div>
{/* Active branch content */}
<BranchColumn branch={activeBranch} />
<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,90 +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 claimed = state.claimedMilestones ?? [];
const totalClaimed = claimed.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) => claimed.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,56 +0,0 @@
// ToastContainer.tsx — Stack de toasts en bas à droite
import { useToastStore } from "../store/useToastStore";
import type { ToastVariant } from "../store/useToastStore";
const VARIANT_STYLES: Record<ToastVariant, string> = {
success: "border-emerald-500/40 bg-emerald-500/10",
info: "border-blue-400/40 bg-blue-400/10",
reward: "border-amber-400/40 bg-amber-400/10",
warning: "border-red-400/40 bg-red-400/10",
};
const VARIANT_ICONS: Record<ToastVariant, string> = {
success: "✓",
info: "",
reward: "★",
warning: "⚠",
};
const VARIANT_ICON_COLORS: Record<ToastVariant, string> = {
success: "text-emerald-400",
info: "text-blue-400",
reward: "text-amber-400",
warning: "text-red-400",
};
export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
const remove = useToastStore((s) => s.removeToast);
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
{toasts.map((t) => (
<div
key={t.id}
onClick={() => remove(t.id)}
className={`
gp cursor-pointer border
${VARIANT_STYLES[t.variant]}
animate-[slide-in_0.3s_ease-out]
`}
style={{ backdropFilter: "blur(12px)" }}
>
<div className="flex items-center gap-2">
<span className={`text-sm font-bold ${VARIANT_ICON_COLORS[t.variant]}`}>
{VARIANT_ICONS[t.variant]}
</span>
<span className="gp-value text-[0.75rem]!">{t.message}</span>
</div>
</div>
))}
</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,20 +0,0 @@
[
{
"id": "1",
"linkname": "Jeu",
"linkurl": "/jeu",
"btn": false
},
{
"id": "3",
"linkname": "Succès",
"linkurl": "/achievements",
"btn": false
},
{
"id": "4",
"linkname": "Guide",
"linkurl": "/guide",
"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,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,29 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
</script>
<div class="gp">
<div class="grid grid-cols-5 gap-0.5 px-1">
<div class="gp-stat" title="Production automatique par seconde">
<span class="gp-label">Prod/s</span>
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(gameStore.productionPerSecond)}</span>
</div>
<div class="gp-stat" title="Tetards gagnes par clic">
<span class="gp-label">/clic</span>
<span class="gp-value text-[0.8rem]!">{formatNumber(gameStore.getClickGain())}</span>
</div>
<div class="gp-stat" title="Multiplicateur global (prestige)">
<span class="gp-label">Mult</span>
<span class="gp-value text-[0.8rem]!">x{gameStore.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]!">{gameStore.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]!">{gameStore.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 { gameStore } 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(gameStore.state.cosmeticInventory);
let equipped = $derived(gameStore.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 ? gameStore.unequipCosmetic(slot) : gameStore.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 { gameStore } 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(gameStore.state.evolutionTree.filter((n) => n.branch === activeBranch));
let spentDna = $derived(getSpentDna(gameStore.state.evolutionTree));
let hasUnlocked = $derived(spentDna > 0);
let resetCost = $derived(getTreeResetCost(gameStore.state));
let canReset = $derived(canResetTree(gameStore.state));
let conv = $derived(gameStore.state.evolutionTree.find((n) => n.id === 'convergence'));
let canBuyConv = $derived(canBuyEvolutionNode(gameStore.state, 'convergence'));
let canUpgradeConv = $derived(canUpgradeConvergence(gameStore.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) gameStore.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 gameStore.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(gameStore.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 ? (gameStore.state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
{@const canBuy = canBuyEvolutionNode(gameStore.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={() => gameStore.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={() => gameStore.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={() => gameStore.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 { gameStore } from '$lib/stores/game.svelte';
import {
loadFromServer,
startAutoSave,
stopAutoSave,
setupVisibilitySync,
} from '$lib/save-sync';
onMount(async () => {
// Init auth
await authStore.init();
// Load save or init guest
if (authStore.user) {
const loaded = await loadFromServer();
if (!loaded && !gameStore.ready) {
gameStore.initGuest();
}
startAutoSave();
setupVisibilitySync();
} else {
gameStore.initGuest();
}
});
</script>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { gameStore } from '$lib/stores/game.svelte';
let interval: ReturnType<typeof setInterval> | undefined;
onMount(() => {
interval = setInterval(() => gameStore.tick(), 1000);
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
</script>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import CollapsiblePanel from './CollapsiblePanel.svelte';
</script>
<CollapsiblePanel
title="Generateurs"
badge="{formatNumber(gameStore.productionPerSecond)}/s"
accentClass=""
>
{#each gameStore.state.generators as gen, i}
{@const cost = gameStore.generatorCostWithTree(gen)}
{@const canAfford = gameStore.state.resources >= cost}
{@const currentProd = gen.baseProduction * gen.owned}
<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">
<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>
<span class="gp-label">
+{gen.baseProduction}/s
{#if gen.owned > 0}
<span class="gp-accent-green"> · {formatNumber(currentProd)}/s</span>
{/if}
</span>
</div>
<button
onclick={() => gameStore.buy(gen.id)}
disabled={!canAfford}
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{formatNumber(cost)}
</button>
</div>
{/each}
</CollapsiblePanel>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { gameStore } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import { getPrestigeThreshold } from '$lib/core/economy';
let threshold = $derived(getPrestigeThreshold(gameStore.state));
let progress = $derived(Math.min(gameStore.state.resources / threshold, 1));
let progressPercent = $derived((progress * 100).toFixed(1));
let remaining = $derived(Math.max(threshold - gameStore.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(gameStore.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 { gameStore } 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(gameStore.state));
let nextMilestone = $derived(getNextMilestone(gameStore.state));
let claimed = $derived(gameStore.state.claimedMilestones ?? []);
let totalClaimed = $derived(claimed.length);
</script>
{#if gameStore.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={() => gameStore.claimMilestone(m.id)} class="gp-btn gp-btn--buy">
Claim
</button>
</div>
{/each}
</div>
{/if}
{#if nextMilestone}
{@const progressPct = Math.min((gameStore.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">{gameStore.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 { gameStore } 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' && gameStore.offlineReport) gameStore.dismissOfflineReport(); }} />
{#if gameStore.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={() => gameStore.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(gameStore.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(gameStore.offlineReport.gains)} tetards
</p>
</div>
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
Efficacite : {Math.round(gameStore.offlineReport.efficiency * 100)}%
</p>
<button
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
onclick={() => gameStore.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 { gameStore } 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(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
let threshold = $derived(getPrestigeThreshold(gameStore.state));
let progress = $derived(Math.min(gameStore.state.lifetimeTadpoles / threshold * 100, 100));
</script>
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
{#if gameStore.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={() => gameStore.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 { gameStore } 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(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
let threshold = $derived(getPrestigeThreshold(gameStore.state));
let canPrestige = $derived(gameStore.state.lifetimeTadpoles >= threshold);
let runDuration = $derived(Date.now() - gameStore.state.runStats.startedAt);
let bestRun = $derived(gameStore.state.runStats.bestRun);
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
let isBestTadpoles = $derived(!bestRun || gameStore.state.lifetimeTadpoles > bestRun.tadpoles);
function handlePrestige() {
if (canPrestige) gameStore.prestige();
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.showPrestigeScreen) gameStore.closePrestige(); }} />
{#if gameStore.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 #{gameStore.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(gameStore.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(gameStore.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={() => gameStore.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 - gameStore.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 { gameStore } 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 = gameStore.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

@@ -1,5 +1,5 @@
// achievements.ts — Milestones Clickerz basés sur le GameState réel // 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 { export interface Achievement {
id: string; 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,122 @@
// save-sync.ts — Auto-save game state to backend every 30s
// Server = authority. NEVER save before server state is loaded (ready guard).
import { gameStore } 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;
export async function saveToServer() {
if (!authStore.user || !gameStore.ready) return;
const result = await apiRequest('/save', {
method: 'POST',
body: JSON.stringify({
gameState: gameStore.state,
playTimeSeconds: gameStore.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);
gameStore.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 && gameStore.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);
gameStore.loadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Reloaded from server on focus');
}
}
}, 500);
});
window.addEventListener('blur', () => {
if (authStore.user && gameStore.ready) saveToServer();
});
window.addEventListener('beforeunload', () => {
if (!authStore.user || !gameStore.ready) return;
const payload = JSON.stringify({
gameState: gameStore.state,
playTimeSeconds: gameStore.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,309 @@
// game.svelte.ts — Game store (Svelte 5 runes)
// Server = authority. localStorage = fallback guest only.
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,
} 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;
// --- Offline report ---
export interface OfflineReport {
wasOffline: boolean;
duration: number;
gains: number;
efficiency: number;
}
// --- Reactive state (Svelte 5 runes) ---
let state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
let playSeconds = $state(0);
let ready = $state(false);
let offlineReport = $state<OfflineReport | null>(null);
let showPrestigeScreen = $state(false);
let lastClickGain = $state(0);
let lastClickDouble = $state(false);
let lastClickCrit = $state(false);
let canPrestige = $state(false);
let productionPerSecond = $state(0);
// --- Local storage ---
function loadLocalState(): GameState {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
const parsed = JSON.parse(raw);
const saved = migrateSave(parsed);
return applyIdleGains(saved, Date.now());
} catch {
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
}
}
function saveLocal(s: GameState): void {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
}
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
const elapsed = now - saved.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) {
const hydrated = applyIdleGains(saved, now);
return { state: { ...hydrated, lastOnline: now }, report: null };
}
const gains = computeOfflineGains(saved, now);
const pps = totalProductionPerSecond(saved);
const fullGains = pps * (elapsed / 1000);
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
const hydrated: GameState = {
...saved,
resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now,
lastOnline: now,
};
return {
state: hydrated,
report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency },
};
}
// --- Derived ---
function updateDerived() {
canPrestige = canPrestigeCheck(state);
productionPerSecond = totalProductionPerSecond(state);
}
// --- Actions ---
function tick() {
if (!ready) return;
const now = Date.now();
const updated = applyIdleGains(state, now);
updated.lastOnline = now;
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
// Check cosmetic unlocks every 5s
if (playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) {
const newCos = addToInventory(cosState, newUnlocks);
updated.cosmeticInventory = newCos.inventory;
newUnlocks.forEach(() => toast('Nouveau cosmetique debloque !', 'reward'));
}
}
saveLocal(updated);
state = updated;
playSeconds += 1;
updateDerived();
}
function click() {
if (!ready) return;
const result = applyClick(applyIdleGains(state, Date.now()));
saveLocal(result.state);
state = result.state;
lastClickGain = result.gain;
lastClickDouble = result.isDouble;
lastClickCrit = result.isCrit;
updateDerived();
}
function buy(genId: string) {
if (!ready) return;
const withIdle = applyIdleGains(state, Date.now());
const updated = buyGenerator(withIdle, genId);
if (!updated) return;
saveLocal(updated);
state = updated;
updateDerived();
}
function buyNode(nodeId: string) {
if (!ready) return;
const updated = buyEvolutionNode(state, nodeId);
if (!updated) return;
const node = updated.evolutionTree.find((n) => n.id === nodeId);
saveLocal(updated);
if (node?.capstone) {
toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
}
state = updated;
updateDerived();
}
function prestige() {
if (!ready) return;
if (!canPrestigeCheck(state)) return;
const updated = applyPrestige(state);
saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
state = updated;
showPrestigeScreen = false;
updateDerived();
}
function equipCosmetic(cosmeticId: string) {
if (!ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ...state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
state = newState;
}
function unequipCosmetic(slot: CosmeticSlot) {
if (!ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot);
const newState = { ...state, cosmeticEquipped: updated.equipped };
saveLocal(newState);
state = newState;
}
function doResetTree() {
if (!ready) return;
if (!canResetTree(state)) return;
const updated = resetEvolutionTree(state);
saveLocal(updated);
state = updated;
updateDerived();
}
function doUpgradeConvergence() {
if (!ready) return;
const updated = upgradeConvergence(state);
if (!updated) return;
saveLocal(updated);
state = updated;
updateDerived();
}
function doClaimMilestone(milestoneId: string) {
if (!ready) return;
const updated = claimMilestoneFn(state, milestoneId);
if (!updated) return;
saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000);
state = updated;
}
function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh);
state = fresh;
playSeconds = 0;
ready = true;
offlineReport = null;
canPrestige = false;
productionPerSecond = 0;
}
function loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = hydrateWithOffline(migrated, Date.now());
saveLocal(result.state);
state = result.state;
ready = true;
offlineReport = result.report;
updateDerived();
}
function initGuest() {
const local = loadLocalState();
const result = hydrateWithOffline(local, Date.now());
saveLocal(result.state);
state = result.state;
ready = true;
offlineReport = result.report;
updateDerived();
}
function dismissOfflineReport() {
offlineReport = null;
}
function openPrestige() {
showPrestigeScreen = true;
}
function closePrestige() {
showPrestigeScreen = false;
}
// --- Public API (single object export for ergonomic access) ---
export const gameStore = {
get state() { return state; },
get playSeconds() { return playSeconds; },
get ready() { return ready; },
get offlineReport() { return offlineReport; },
get showPrestigeScreen() { return showPrestigeScreen; },
get lastClickGain() { return lastClickGain; },
get lastClickDouble() { return lastClickDouble; },
get lastClickCrit() { return lastClickCrit; },
get canPrestige() { return canPrestige; },
get productionPerSecond() { return productionPerSecond; },
tick,
click,
buy,
buyNode,
prestige,
equipCosmetic,
unequipCosmetic,
resetTree: doResetTree,
upgradeConvergence: doUpgradeConvergence,
claimMilestone: doClaimMilestone,
reset,
loadFromServer,
initGuest,
dismissOfflineReport,
openPrestige,
closePrestige,
generatorCost: genCost,
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, state.evolutionTree),
getClickGain: () => getClickGain(state),
};

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,72 +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";
import Guide from "./pages/Guide";
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: "/guide",
element: <Guide />,
},
{
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,105 +0,0 @@
// Guide.tsx — Guide joueur in-game
export default function Guide() {
return (
<div className="container" style={{ color: "var(--color-grey)" }}>
<h1>Guide du Gardien</h1>
<div className="content">
<h2 className="subtitle">Le Marais</h2>
<p className="paragraphe">
Tu es le <strong>Gardien du Marais</strong>. Les tetards naissent sous tes clics,
grandissent grace a tes generateurs, et evoluent a chaque nouvelle generation.
</p>
</div>
<div className="content">
<h2 className="subtitle">Boucle de jeu</h2>
<p className="paragraphe">
<strong>1. Clique</strong> pour pondre des tetards. Achete des <strong>generateurs</strong> (Nid, Mare, Marecage...)
qui produisent des tetards automatiquement.
</p>
<p className="paragraphe">
<strong>2. Prestige</strong> quand tu atteins 1M de tetards. Tu perds tes tetards et generateurs,
mais tu gagnes de l'<strong>ADN Ancestral</strong> et un multiplicateur permanent.
Chaque generation est plus rapide que la precedente.
</p>
<p className="paragraphe">
<strong>3. Arbre d'Evolution</strong> depense ton ADN dans 3 branches :
</p>
<ul className="paragraphe" style={{ marginLeft: "1.5rem" }}>
<li><strong>Ponte</strong> booste tes clics, double ponte, critiques</li>
<li><strong>Marais</strong> booste la production des generateurs</li>
<li><strong>Adaptation</strong> bonus offline, ADN bonus, seuil prestige reduit</li>
</ul>
<p className="paragraphe">
Chaque branche a un <strong>capstone</strong> (noeud final puissant) et des
<strong> post-capstones</strong> achetables a l'infini pour une progression endless.
</p>
</div>
<div className="content">
<h2 className="subtitle">Capstones</h2>
<ul className="paragraphe" style={{ marginLeft: "1.5rem" }}>
<li><strong>Ponte Automatique</strong> — auto-click 1/s qui scale avec les upgrades</li>
<li><strong>Symbiose Totale</strong> — chaque type de generateur booste les autres</li>
<li><strong>Memoire du Marais</strong> — offline cap passe a 75%, duree 8h</li>
</ul>
</div>
<div className="content">
<h2 className="subtitle">Convergence</h2>
<p className="paragraphe">
Quand tu as debloque un capstone + des noeuds d'une 2e branche, tu peux acheter
<strong> Convergence Alpha</strong> (+10% a tous les effets).
Avec 2 capstones, elle evolue en <strong>Convergence Omega</strong> (-20% cout post-capstones).
</p>
</div>
<div className="content">
<h2 className="subtitle">Reset d'arbre</h2>
<p className="paragraphe">
Tu peux reinitialiser ton arbre pour tester d'autres builds.
<strong> 1 reset gratuit par prestige</strong>, puis 5 ADN par reset supplementaire.
L'ADN investi est entierement rembourse.
</p>
</div>
<div className="content">
<h2 className="subtitle">Milestones</h2>
<p className="paragraphe">
8 paliers de prestige (de 1 a 100) qui debloquent des <strong>cosmetiques exclusifs</strong> et
des <strong>bonus gameplay legers</strong> :
</p>
<ul className="paragraphe" style={{ marginLeft: "1.5rem" }}>
<li>1 prestige — Ruban queue</li>
<li>3 prestiges — Titre "Gardien Recurrent"</li>
<li>5 prestiges — 1 Nid gratuit au depart</li>
<li>10 prestiges — Couronne doree</li>
<li>15 prestiges — +5% offline permanent</li>
<li>25 prestiges — Cape d'algues ancestrales</li>
<li>50 prestiges Queue enflamee + particules</li>
<li>100 prestiges Skin Tetard Primordial</li>
</ul>
</div>
<div className="content">
<h2 className="subtitle">Cosmetiques</h2>
<p className="paragraphe">
Les cosmetiques sont purement visuels <strong>zero pay-to-win</strong>.
Debloque-les via les achievements et les milestones prestige.
5 slots : chapeau, yeux, corps, queue, accessoire.
</p>
</div>
<div className="content">
<h2 className="subtitle">Offline</h2>
<p className="paragraphe">
Quand tu fermes le jeu, le marais continue de produire.
Efficacite : 100% les 15 premieres minutes, puis degressive jusqu'a 0% a 2h.
Les noeuds d'arbre et milestones peuvent augmenter le cap offline.
</p>
</div>
</div>
);
}

View File

@@ -1,171 +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} succes
</a>
<a href="/guide" className="achieve-badge" style={{ borderColor: "rgba(139, 92, 246, 0.2)", background: "rgba(139, 92, 246, 0.08)", color: "#a78bfa" }}>
Guide du Gardien
</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 { gameStore } 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(gameStore.state)));
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(gameStore.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,148 @@
<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** (Nid, Mare, Marecage...) qui produisent des tetards automatiquement.',
'**2. Prestige** quand tu atteins 1M de tetards. Tu perds tes tetards et generateurs, mais tu gagnes de l\'**ADN Ancestral** et un multiplicateur permanent.',
'**3. Arbre d\'Evolution** — depense ton ADN dans 3 branches : Ponte (clics), Marais (production), Adaptation (offline/ADN).',
],
},
{
icon: '★',
title: 'Capstones',
content: [
'**Ponte Automatique** — auto-click 1/s qui scale avec les upgrades',
'**Symbiose Totale** — chaque type de generateur booste les autres',
'**Memoire du Marais** — offline cap passe a 75%, duree 8h',
],
},
{
icon: '🧬',
title: 'Convergence',
content: [
'Avec un capstone + des noeuds d\'une 2e branche → **Convergence Alpha** (+10% tous effets).',
'Avec 2 capstones → evolue en **Convergence Omega** (-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,151 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, elasticOut } from 'svelte/easing';
import { gameStore } 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 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(gameStore.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) {
gameStore.click();
clickParticles?.spawn(e.clientX, e.clientY, gameStore.lastClickGain, gameStore.lastClickDouble, gameStore.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 !gameStore.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(gameStore.state.resources)}
</div>
</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'}
<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