Compare commits
24 Commits
ed8cf87d4e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c9ed1187 | |||
| 7a8f4f325c | |||
| f9dd4c3ca4 | |||
| 45b89ebae1 | |||
| 9caa6691fe | |||
| c549ec259c | |||
| 7c651ded4e | |||
| f4bc25b3b1 | |||
| 25768e3665 | |||
| 120f4bedca | |||
| 38e63fdf22 | |||
| 9d27cb6648 | |||
| 39921aa8fc | |||
| 1488962537 | |||
| 67931eeadb | |||
| cce7fa3190 | |||
| 10ff2d32f5 | |||
| ce38975c10 | |||
| f6bff6e389 | |||
| 3de0492631 | |||
| a665fdf2f4 | |||
| 450d559216 | |||
| 1ca88df3ed | |||
| 4df6451dac |
@@ -42,16 +42,26 @@ jobs:
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: Frontend
|
||||
run: npx vitest run
|
||||
|
||||
- name: Deploy frontend
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
mkdir -p /var/www/clickerz/frontend/dist
|
||||
rsync -a --delete Frontend/dist/ /var/www/clickerz/frontend/dist/
|
||||
echo "✅ Frontend Svelte deployed"
|
||||
|
||||
# ── Smoke test ───────────────────────────────────────────────────────────
|
||||
- name: Smoke test API
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
sleep 3
|
||||
curl -sf http://localhost:3520/api/auth/me 2>&1 | grep -q '401\|session\|Not authenticated'
|
||||
echo "✅ API responds OK"
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3520/api/auth/me)
|
||||
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ API responds OK (HTTP $HTTP_CODE)"
|
||||
else
|
||||
echo "❌ API unreachable (HTTP $HTTP_CODE)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
43
Frontend/.gitignore
vendored
Executable file → Normal file
43
Frontend/.gitignore
vendored
Executable file → Normal file
@@ -1,25 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
/dist
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
1
Frontend/.npmrc
Normal file
1
Frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
1
Frontend/.nvmrc
Normal file
1
Frontend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
3
Frontend/.vscode/extensions.json
vendored
Normal file
3
Frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
43
Frontend/README.md
Executable file → Normal file
43
Frontend/README.md
Executable file → Normal file
@@ -1,11 +1,42 @@
|
||||
# Clickerz — Tetard Universe
|
||||
# sv
|
||||
|
||||
Idle clicker game. Fais éclore des têtards, construis ton empire et domine le marais.
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
### Stack
|
||||
## Creating a project
|
||||
|
||||
React · Vite · SCSS · Tailwind · Zustand
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
### Credits
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
Développé par [Kevin T](https://github.com/tetardtek) · Powered by Brain
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.13.0 create --template minimal --types ts --no-install Frontend-svelte
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="./svg/tadpole.svg"
|
||||
/>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta
|
||||
name="googlebot"
|
||||
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
||||
/>
|
||||
<meta
|
||||
name="bingbot"
|
||||
content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"
|
||||
/>
|
||||
<link rel="canonical" href="https://clickerz.tetardtek.com" />
|
||||
<meta property="og:url" content="https://clickerz.tetardtek.com" />
|
||||
<meta property="og:site_name" content="Clickerz" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Clickerz — Tetard Universe" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
<meta property="og:image:width" content="584" />
|
||||
<meta property="og:image:height" content="384" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Clickerz — Tetard Universe" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9057
Frontend/package-lock.json
generated
Executable file → Normal file
9057
Frontend/package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
58
Frontend/package.json
Executable file → Normal file
58
Frontend/package.json
Executable file → Normal file
@@ -1,35 +1,27 @@
|
||||
{
|
||||
"name": "template",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"react-router-dom": "^6.19.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
"name": "clickerz-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"svelte": "^5.54.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import Navbar from "./components/navbar";
|
||||
import Footer from "./components/footer";
|
||||
import { GameTick } from "./components/GameTick";
|
||||
import { GameSync } from "./components/GameSync";
|
||||
import { OfflineReport } from "./components/OfflineReport";
|
||||
|
||||
import navData from "./data/NavBarData.json";
|
||||
|
||||
function App() {
|
||||
const [toggleRain, setToggleRain] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameTick />
|
||||
<GameSync />
|
||||
<OfflineReport />
|
||||
<Navbar
|
||||
navData={navData}
|
||||
toggleRain={toggleRain}
|
||||
setToggleRain={setToggleRain}
|
||||
/>
|
||||
<main>
|
||||
<Outlet context={[toggleRain, setToggleRain]} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { postCapstoneCost, treeResetCost } from "../core/balance";
|
||||
import { postCapstoneCost, treeResetCost } from "../lib/core/balance";
|
||||
|
||||
describe("postCapstoneCost", () => {
|
||||
it("first purchase = base cost (no multiplier)", () => {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
unequipSlot,
|
||||
addToInventory,
|
||||
DEFAULT_COSMETIC_STATE,
|
||||
} from "../core/cosmetics";
|
||||
import { DEFAULT_STATE } from "../core/economy";
|
||||
} from "../lib/core/cosmetics";
|
||||
import { DEFAULT_STATE } from "../lib/core/economy";
|
||||
|
||||
describe("Cosmetics system", () => {
|
||||
describe("COSMETICS catalog", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
DEFAULT_STATE,
|
||||
DEFAULT_GENERATORS,
|
||||
DEFAULT_EVOLUTION_TREE,
|
||||
} from "../core/economy";
|
||||
} from "../lib/core/economy";
|
||||
|
||||
// --- PrestigePanel visibility ---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { migrateSave } from "../core/migrateSave";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../core/economy";
|
||||
import { CURRENT_SAVE_VERSION } from "../core/balance";
|
||||
import { migrateSave } from "../lib/core/migrateSave";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "../lib/core/economy";
|
||||
import { CURRENT_SAVE_VERSION } from "../lib/core/balance";
|
||||
|
||||
// Minimal Sprint 2 save (v1 — no saveVersion field)
|
||||
function makeV1Save(overrides: Record<string, unknown> = {}) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
claimMilestone,
|
||||
getMilestoneStartNid,
|
||||
getMilestoneOfflineBonus,
|
||||
} from "../core/economy";
|
||||
} from "../lib/core/economy";
|
||||
|
||||
describe("Prestige Milestones", () => {
|
||||
it("no claimable milestones at 0 prestiges", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Ported from backend saveControllers.validateGameState for unit testing
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DEFAULT_STATE } from "../core/economy";
|
||||
import { DEFAULT_STATE } from "../lib/core/economy";
|
||||
|
||||
// Reproduce the validation logic client-side for testing
|
||||
const MAX_PRODUCTION_PER_SECOND = 750_000;
|
||||
|
||||
449
Frontend/src/index.css → Frontend/src/app.css
Executable file → Normal file
449
Frontend/src/index.css → Frontend/src/app.css
Executable file → Normal file
@@ -1,8 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ── Tailwind v4 theme — tokens du jeu ── */
|
||||
/* -- Tailwind v4 theme -- tokens du jeu -- */
|
||||
@theme {
|
||||
/* Base colors */
|
||||
--color-blue-light: #dcecf3;
|
||||
--color-purple-light: #e4e3f3;
|
||||
--color-red-light: #c33636;
|
||||
@@ -10,7 +9,6 @@
|
||||
--color-grey: #202020;
|
||||
--color-grey-hover: #606060;
|
||||
|
||||
/* Game panel tokens */
|
||||
--color-gp-bg: rgba(17, 17, 17, 0.75);
|
||||
--color-gp-bg-hover: rgba(17, 17, 17, 0.85);
|
||||
--color-gp-border: rgba(255, 255, 255, 0.08);
|
||||
@@ -27,17 +25,14 @@
|
||||
--color-gp-btn-disabled: rgba(255, 255, 255, 0.08);
|
||||
--color-gp-btn-text-disabled: rgba(255, 255, 255, 0.3);
|
||||
|
||||
/* Spacing / sizing tokens */
|
||||
--radius-gp: 0.75rem;
|
||||
--spacing-gp: 0.75rem;
|
||||
--spacing-gp-gap: 0.5rem;
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-gp-title: 0.8rem;
|
||||
--font-size-gp-text: 0.75rem;
|
||||
--font-size-gp-sm: 0.65rem;
|
||||
|
||||
/* Animation */
|
||||
--animate-gp-pulse: gp-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -46,7 +41,7 @@
|
||||
50% { box-shadow: 0 0 0 6px rgba(124, 58, 237, 0); }
|
||||
}
|
||||
|
||||
/* ── Global reset & base ── */
|
||||
/* -- Global reset & base -- */
|
||||
@layer base {
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -60,15 +55,18 @@
|
||||
--bg-color: var(--color-blue-light);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
a { text-decoration: none; }
|
||||
|
||||
/* a11y — focus-visible ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-gp-accent-green);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 92vh;
|
||||
margin-top: 80px;
|
||||
padding: 0 0 2rem;
|
||||
background-color: var(--bg-color);
|
||||
/* Skip keyboard focus ring on mouse clicks */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -77,7 +75,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Zone system (biomes) ── */
|
||||
/* -- Zone system (biomes) -- */
|
||||
@layer components {
|
||||
.zone {
|
||||
width: 100%;
|
||||
@@ -107,7 +105,7 @@
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* ── Game panels design system ── */
|
||||
/* -- Game panels design system -- */
|
||||
|
||||
.gp {
|
||||
display: flex;
|
||||
@@ -147,7 +145,6 @@
|
||||
.gp-accent-purple { color: var(--color-gp-accent-purple); }
|
||||
.gp-accent-amber { color: var(--color-gp-accent-amber); }
|
||||
|
||||
/* Row item (générateur, noeud évolution) */
|
||||
.gp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -163,9 +160,7 @@
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
background: var(--color-gp-accent-green-bg);
|
||||
}
|
||||
.gp-row--active:hover {
|
||||
background: rgba(16, 185, 129, 0.18);
|
||||
}
|
||||
.gp-row--active:hover { background: rgba(16, 185, 129, 0.18); }
|
||||
|
||||
.gp-row--locked {
|
||||
border-color: var(--color-gp-border);
|
||||
@@ -183,7 +178,6 @@
|
||||
background: var(--color-gp-accent-green-bg);
|
||||
}
|
||||
|
||||
/* Bouton achat */
|
||||
.gp-btn {
|
||||
font-family: var(--font);
|
||||
font-size: var(--font-size-gp-sm);
|
||||
@@ -200,9 +194,7 @@
|
||||
background: var(--color-gp-btn);
|
||||
color: white;
|
||||
}
|
||||
.gp-btn--buy:hover {
|
||||
background: var(--color-gp-btn-hover);
|
||||
}
|
||||
.gp-btn--buy:hover { background: var(--color-gp-btn-hover); }
|
||||
|
||||
.gp-btn--disabled {
|
||||
background: var(--color-gp-btn-disabled);
|
||||
@@ -217,11 +209,8 @@
|
||||
font-size: var(--font-size-gp-text);
|
||||
animation: var(--animate-gp-pulse);
|
||||
}
|
||||
.gp-btn--prestige:hover {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.gp-btn--prestige:hover { background: #8b5cf6; }
|
||||
|
||||
/* Header cockpit (stats résumé) */
|
||||
.gp-cockpit-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -236,7 +225,6 @@
|
||||
gap: 0.05rem;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.gp-progress {
|
||||
height: 0.35rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
@@ -250,14 +238,12 @@
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* Section separator */
|
||||
.gp-sep {
|
||||
height: 1px;
|
||||
background: var(--color-gp-border);
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
/* Zone titles in sidebar */
|
||||
.gp-zone-label {
|
||||
font-family: var(--font);
|
||||
font-size: var(--font-size-gp-sm);
|
||||
@@ -268,7 +254,7 @@
|
||||
padding-left: 0.2rem;
|
||||
}
|
||||
|
||||
/* ── Home / Game view ── */
|
||||
/* -- Home / Game view -- */
|
||||
|
||||
.click-zone {
|
||||
display: flex;
|
||||
@@ -281,9 +267,7 @@
|
||||
flex: 1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.click-zone {
|
||||
padding-right: 22rem;
|
||||
}
|
||||
.click-zone { padding-right: 22rem; }
|
||||
}
|
||||
|
||||
.click-zone:active img {
|
||||
@@ -301,9 +285,7 @@
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.click-zone-counter {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.click-zone-counter { font-size: 2.5rem; }
|
||||
}
|
||||
|
||||
.achieve-badge {
|
||||
@@ -320,9 +302,7 @@
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.achieve-badge:hover {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
.achieve-badge:hover { background: rgba(16, 185, 129, 0.2); }
|
||||
|
||||
.click-particle {
|
||||
position: fixed;
|
||||
@@ -366,21 +346,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float-up {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1.2);
|
||||
}
|
||||
60% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-80px) scale(1.5);
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
/* ── Navbar ── */
|
||||
@keyframes float-up {
|
||||
0% { opacity: 1; transform: translateY(0) scale(1.2); }
|
||||
60% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-80px) scale(1.5); }
|
||||
}
|
||||
|
||||
/* -- Navbar -- */
|
||||
@layer components {
|
||||
.header-main {
|
||||
display: flex;
|
||||
@@ -391,15 +368,11 @@
|
||||
padding: 0 2rem;
|
||||
top: 0;
|
||||
background-color: var(--bg-color);
|
||||
background-blend-mode: darken;
|
||||
background-size: cover;
|
||||
z-index: 99;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (max-width: 999px) {
|
||||
.header-main {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
.header-main { padding: 0 0.4rem; }
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -407,16 +380,13 @@
|
||||
content: url(/svg/tadpole.svg);
|
||||
transition: 0.2s;
|
||||
}
|
||||
.logo:hover {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.logo:hover { transform: scale(0.9); }
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -428,9 +398,7 @@
|
||||
list-style-type: none;
|
||||
}
|
||||
@media (max-width: 999px) {
|
||||
.nav-list {
|
||||
display: none;
|
||||
}
|
||||
.nav-list { display: none; }
|
||||
}
|
||||
|
||||
.nav-list li {
|
||||
@@ -449,41 +417,7 @@
|
||||
font-weight: 500;
|
||||
padding: 30px 0;
|
||||
}
|
||||
.mainLink:hover {
|
||||
color: var(--color-red-light);
|
||||
}
|
||||
|
||||
.dropLink {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
font-weight: 400;
|
||||
}
|
||||
.dropLink:hover {
|
||||
color: var(--color-red-light);
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: var(--color-grey);
|
||||
transform: translateY(30px);
|
||||
min-width: 160px;
|
||||
box-shadow: 0 8px 16px rgba(10, 10, 10, 0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-content a {
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
.dropdown-content a:hover {
|
||||
background-color: var(--color-grey-hover);
|
||||
}
|
||||
.dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
.mainLink:hover { color: var(--color-red-light); }
|
||||
|
||||
.auth-nav {
|
||||
display: flex;
|
||||
@@ -511,116 +445,9 @@
|
||||
background: var(--color-grey);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Burger menu (mobile) ── */
|
||||
@media (min-width: 1000px) {
|
||||
.menuToggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 999px) {
|
||||
.menuToggle {
|
||||
float: left;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
top: 2px;
|
||||
left: -10px;
|
||||
z-index: 99;
|
||||
user-select: none;
|
||||
}
|
||||
.menuToggle a {
|
||||
text-decoration: none;
|
||||
color: var(--color-grey);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.menuToggle a:hover {
|
||||
color: var(--color-red-light);
|
||||
}
|
||||
.menuToggle input {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: -5px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.menuToggle span {
|
||||
display: block;
|
||||
width: 33px;
|
||||
height: 4px;
|
||||
margin-bottom: 5px;
|
||||
position: relative;
|
||||
background: var(--color-grey);
|
||||
border-radius: 3px;
|
||||
z-index: 1;
|
||||
transform-origin: 4px 0;
|
||||
transition: transform 0.2s cubic-bezier(0.77, 0.2, 0.05, 1),
|
||||
background 0.2s cubic-bezier(0.77, 0.2, 0.05, 1), opacity 0.55s ease;
|
||||
}
|
||||
.menuToggle span:first-child {
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
.menuToggle span:nth-last-child(2) {
|
||||
transform-origin: 0% 100%;
|
||||
}
|
||||
.menuToggle input:checked ~ span {
|
||||
opacity: 1;
|
||||
transform: rotate(45deg) translate(-2px, -1px);
|
||||
background: white;
|
||||
}
|
||||
.menuToggle input:checked ~ span:nth-last-child(3) {
|
||||
opacity: 0;
|
||||
transform: rotate(0deg) scale(0.2, 0.2);
|
||||
}
|
||||
.menuToggle input:checked ~ span:nth-last-child(2) {
|
||||
transform: rotate(-45deg) translate(0, -1px);
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
height: 110vh;
|
||||
margin: -100px 0 0 -231px;
|
||||
padding: 1.2rem;
|
||||
padding-top: 100px;
|
||||
background: var(--color-grey);
|
||||
list-style-type: none;
|
||||
transform-origin: 0% 0%;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
.menu li {
|
||||
padding: 10px 0;
|
||||
font-size: 1.2rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
.menuToggle input:checked ~ ul {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
.sousmenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 1.2rem;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-family: var(--font);
|
||||
font-weight: 500;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
/* -- Buttons -- */
|
||||
@layer components {
|
||||
.primary-button {
|
||||
display: flex;
|
||||
@@ -638,9 +465,7 @@
|
||||
transition: transform 0.1s ease-in-out;
|
||||
border: none;
|
||||
}
|
||||
.primary-button:hover {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.primary-button:hover { transform: scale(0.95); }
|
||||
|
||||
.secondary-button {
|
||||
display: flex;
|
||||
@@ -664,7 +489,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
/* -- Footer -- */
|
||||
@layer components {
|
||||
.footer {
|
||||
display: flex;
|
||||
@@ -681,7 +506,6 @@
|
||||
}
|
||||
.footer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 90%;
|
||||
@@ -696,62 +520,7 @@
|
||||
height: 100px;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
.footer-logo:hover {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.footer .section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
.section-title {
|
||||
font-family: var(--font);
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-grey);
|
||||
text-decoration-line: underline;
|
||||
text-underline-offset: 0.5rem;
|
||||
}
|
||||
.section-text {
|
||||
max-width: 26ch;
|
||||
font-family: var(--font);
|
||||
font-size: 1rem;
|
||||
color: var(--color-grey);
|
||||
}
|
||||
.section-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
}
|
||||
.section-list .section-item,
|
||||
.section-list a {
|
||||
width: fit-content;
|
||||
font-family: var(--font);
|
||||
font-size: 1rem;
|
||||
color: var(--color-grey);
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
.section-list .section-item:hover,
|
||||
.section-list a:hover {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.spacing {
|
||||
min-width: 150px;
|
||||
width: 10%;
|
||||
}
|
||||
.footer-github {
|
||||
font-family: var(--font);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-grey);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
.footer-github:hover {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.footer-logo:hover { transform: scale(0.9); }
|
||||
.copyright {
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
@@ -759,9 +528,10 @@
|
||||
color: var(--color-grey);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pages layout (error, legal, settings, login) ── */
|
||||
|
||||
/* -- Pages layout -- */
|
||||
@layer components {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -775,7 +545,6 @@
|
||||
color: var(--color-grey);
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
}
|
||||
.container h2 {
|
||||
font-family: var(--font);
|
||||
@@ -783,82 +552,8 @@
|
||||
font-weight: 600;
|
||||
color: var(--color-grey);
|
||||
}
|
||||
.container .subtitle {
|
||||
font-family: var(--font);
|
||||
color: var(--color-grey);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.container .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.container .paragraphe {
|
||||
font-family: var(--font);
|
||||
color: var(--color-grey);
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: 0.5rem;
|
||||
list-style: inside;
|
||||
}
|
||||
.container .info {
|
||||
font-family: var(--font);
|
||||
color: var(--color-grey);
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 90vh;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.containererror {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.containererror h1 {
|
||||
font-family: var(--font);
|
||||
color: var(--color-grey);
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
}
|
||||
.message {
|
||||
font-family: var(--font);
|
||||
color: var(--color-grey);
|
||||
font-size: 1rem;
|
||||
font-weight: 300;
|
||||
text-align: center;
|
||||
}
|
||||
.btn-return {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--color-grey);
|
||||
border: none;
|
||||
border-radius: 0.6rem;
|
||||
font-family: var(--font);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-return:hover {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* ── Achievements ── */
|
||||
|
||||
/* -- Achievements -- */
|
||||
.fullachieve {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -885,13 +580,6 @@
|
||||
opacity: 0.7;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.achievementscontainer {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.achievementscardcontainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -899,6 +587,7 @@
|
||||
min-height: 200px;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.achieve-card {
|
||||
display: flex;
|
||||
@@ -910,9 +599,7 @@
|
||||
max-width: 380px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.achieve-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.achieve-card:hover { transform: translateY(-2px); }
|
||||
.achieve-unlocked {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
@@ -922,46 +609,8 @@
|
||||
border: 1px solid rgba(107, 114, 128, 0.15);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.achieve-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
width: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
.achieve-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.achieve-name {
|
||||
font-family: var(--font);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-grey);
|
||||
}
|
||||
.achieve-desc {
|
||||
font-family: var(--font);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-grey);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Legal / Cookie pages ── */
|
||||
|
||||
.mentionslegales {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: 1280px;
|
||||
font-family: var(--font);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
padding: 15rem 1rem 4rem;
|
||||
}
|
||||
.mentionslegales h2 {
|
||||
font-family: var(--font);
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-grey);
|
||||
}
|
||||
.achieve-icon { font-size: 2rem; flex-shrink: 0; width: 3rem; text-align: center; }
|
||||
.achieve-info { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.achieve-name { font-family: var(--font); font-size: 1rem; font-weight: 600; color: var(--color-grey); }
|
||||
.achieve-desc { font-family: var(--font); font-size: 0.85rem; color: var(--color-grey); opacity: 0.7; }
|
||||
}
|
||||
13
Frontend/src/app.d.ts
vendored
Normal file
13
Frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
15
Frontend/src/app.html
Normal file
15
Frontend/src/app.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/svg/tadpole.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,39 +0,0 @@
|
||||
// CockpitHeader.tsx — Dashboard résumé toujours visible en haut du cockpit
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
import { getClickGain } from "../core/economy";
|
||||
|
||||
export function CockpitHeader() {
|
||||
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
||||
const state = useGameStore((s) => s.state);
|
||||
const { prestigeMultiplier, ancestralDna, prestigeCount } = state;
|
||||
const clickGain = getClickGain(state);
|
||||
|
||||
return (
|
||||
<div className="gp gp-cockpit-header">
|
||||
<div className="gp-stat" title="Têtards générés automatiquement chaque seconde">
|
||||
<span className="gp-label">Prod/s</span>
|
||||
<span className="gp-value gp-accent-green">
|
||||
{formatNumber(productionPerSecond)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="Têtards gagnés à chaque clic sur le têtard">
|
||||
<span className="gp-label">/clic</span>
|
||||
<span className="gp-value">{formatNumber(clickGain)}</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="Multiplicateur global — augmente avec le prestige">
|
||||
<span className="gp-label">Mult</span>
|
||||
<span className="gp-value">x{prestigeMultiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="ADN Ancestral — monnaie de l'Arbre d'Évolution (gagné au prestige)">
|
||||
<span className="gp-label">ADN</span>
|
||||
<span className="gp-value gp-accent-purple">{ancestralDna}</span>
|
||||
</div>
|
||||
<div className="gp-stat" title="Nombre de prestiges effectués (Nouvelles Générations)">
|
||||
<span className="gp-label">Gén.</span>
|
||||
<span className="gp-value">{prestigeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
|
||||
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import {
|
||||
canBuyEvolutionNode,
|
||||
getSpentDna,
|
||||
getTreeResetCost,
|
||||
canResetTree,
|
||||
getRepeatableCost,
|
||||
canUpgradeConvergence,
|
||||
} from "../core/economy";
|
||||
import type { EvolutionNode, Branch } from "../core/economy";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} tetards au depart`,
|
||||
unlock_generator: () => `Lac Mystique des le debut`,
|
||||
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
|
||||
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
|
||||
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
|
||||
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
|
||||
generator_boost: (v) => `x${v} Nid`,
|
||||
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
|
||||
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
|
||||
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
|
||||
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
|
||||
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
|
||||
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
|
||||
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
|
||||
};
|
||||
|
||||
const BRANCH_CONFIG: Record<Branch | "cross", { label: string; color: string; accent: string }> = {
|
||||
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
|
||||
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
|
||||
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
|
||||
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
|
||||
};
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
canBuy,
|
||||
isExcluded,
|
||||
onBuy,
|
||||
}: {
|
||||
node: EvolutionNode;
|
||||
canBuy: boolean;
|
||||
isExcluded: boolean;
|
||||
onBuy: () => void;
|
||||
}) {
|
||||
const isCapstone = node.capstone;
|
||||
const isRepeatable = node.repeatable;
|
||||
const purchased = node.purchased ?? 0;
|
||||
|
||||
const rowClass = node.unlocked
|
||||
? isCapstone
|
||||
? "gp-row gp-row--unlocked border-amber-400/40!"
|
||||
: "gp-row gp-row--unlocked"
|
||||
: isExcluded
|
||||
? "gp-row gp-row--locked opacity-30!"
|
||||
: canBuy
|
||||
? isCapstone
|
||||
? "gp-row gp-row--evolution border-amber-400/30!"
|
||||
: "gp-row gp-row--evolution"
|
||||
: "gp-row gp-row--locked";
|
||||
|
||||
const cost = isRepeatable && node.unlocked
|
||||
? getRepeatableCost(node)
|
||||
: isRepeatable
|
||||
? node.cost
|
||||
: node.cost;
|
||||
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isCapstone && <span className="text-amber-400 text-[0.6rem]">★</span>}
|
||||
<span className="gp-value text-[0.7rem]!">{node.name}</span>
|
||||
{isRepeatable && node.unlocked && (
|
||||
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
|
||||
)}
|
||||
{node.exclusive_with && !node.unlocked && !isExcluded && (
|
||||
<span className="gp-label text-[0.55rem]!">OU</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||
</div>
|
||||
{node.unlocked && !isRepeatable ? (
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
) : node.unlocked && isRepeatable ? (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
) : isExcluded ? (
|
||||
<span className="gp-label text-[0.55rem]!">verrouille</span>
|
||||
) : (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchColumn({ branch }: { branch: Branch }) {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const buyNode = useGameStore((s) => s.buyNode);
|
||||
const nodes = state.evolutionTree.filter((n) => n.branch === branch);
|
||||
const config = BRANCH_CONFIG[branch];
|
||||
|
||||
return (
|
||||
<div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
|
||||
<span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
|
||||
{nodes.map((node) => {
|
||||
const isExcluded = node.exclusive_with
|
||||
? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
|
||||
: false;
|
||||
return (
|
||||
<NodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
canBuy={canBuyEvolutionNode(state, node.id)}
|
||||
isExcluded={isExcluded}
|
||||
onBuy={() => buyNode(node.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConvergenceSection() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const buyNode = useGameStore((s) => s.buyNode);
|
||||
const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode);
|
||||
const conv = state.evolutionTree.find((n) => n.id === "convergence");
|
||||
|
||||
if (!conv) return null;
|
||||
|
||||
const canBuy = canBuyEvolutionNode(state, "convergence");
|
||||
const canUpgrade = canUpgradeConvergence(state);
|
||||
const tier = conv.tier ?? 1;
|
||||
const maxTier = conv.maxTier ?? 2;
|
||||
const tierName = tier >= 2 ? "Omega" : "Alpha";
|
||||
|
||||
return (
|
||||
<div className="gp border-t-2 border-purple-500/30">
|
||||
<span className="gp-title text-center gp-accent-purple">
|
||||
Convergence {conv.unlocked ? tierName : ""}
|
||||
</span>
|
||||
{conv.unlocked ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="gp-row gp-row--unlocked border-purple-400/30!">
|
||||
<div className="flex flex-col">
|
||||
<span className="gp-value text-[0.7rem]!">
|
||||
{tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier})
|
||||
</span>
|
||||
<span className="gp-label">
|
||||
{tier >= 2
|
||||
? "+10% tous effets + -20% cout post-capstones"
|
||||
: "+10% a tous les effets de l'arbre"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
</div>
|
||||
{tier < maxTier && (
|
||||
<button
|
||||
disabled={!canUpgrade}
|
||||
onClick={upgradeConv}
|
||||
className={`gp-btn ${canUpgrade ? "gp-btn--buy" : "gp-btn--disabled"} w-full`}
|
||||
>
|
||||
{canUpgrade
|
||||
? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)`
|
||||
: `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gp-row gp-row--locked">
|
||||
<div className="flex flex-col">
|
||||
<span className="gp-value text-[0.7rem]!">Convergence Alpha</span>
|
||||
<span className="gp-label">+10% a tous les effets de l'arbre</span>
|
||||
<span className="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
|
||||
</div>
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={() => buyNode("convergence")}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{conv.cost}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EvolutionTree() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const resetTree = useGameStore((s) => s.resetTree);
|
||||
const { prestigeCount, ancestralDna, evolutionTree } = state;
|
||||
|
||||
if (prestigeCount < 1) return null;
|
||||
|
||||
const spentDna = getSpentDna(evolutionTree);
|
||||
const hasUnlocked = spentDna > 0;
|
||||
const resetCost = getTreeResetCost(state);
|
||||
const canReset = canResetTree(state);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!canReset) return;
|
||||
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
|
||||
const confirmed = window.confirm(
|
||||
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
|
||||
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
|
||||
`Tous les noeuds seront verrouilles.\n\n` +
|
||||
`Confirmer ?`
|
||||
);
|
||||
if (confirmed) resetTree();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<span className="gp-title">Evolution</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
|
||||
{hasUnlocked && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={!canReset}
|
||||
className={`gp-btn text-[0.55rem]! ${
|
||||
canReset
|
||||
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
|
||||
: "gp-btn--disabled"
|
||||
}`}
|
||||
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
|
||||
>
|
||||
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<BranchColumn branch="ponte" />
|
||||
<BranchColumn branch="marais" />
|
||||
<BranchColumn branch="adaptation" />
|
||||
</div>
|
||||
<ConvergenceSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// GameSync.tsx — Bridge useSaveSync ↔ Zustand store
|
||||
// Serveur = autorité. Attend la save serveur avant de rendre le jeu jouable.
|
||||
// Guest mode (pas connecté) : init depuis localStorage immédiatement.
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useSaveSync } from "../hooks/useSaveSync";
|
||||
|
||||
export function GameSync() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const ready = useGameStore((s) => s.ready);
|
||||
const loadFromServer = useGameStore((s) => s.loadFromServer);
|
||||
const initGuest = useGameStore((s) => s.initGuest);
|
||||
const playSeconds = useGameStore((s) => s.playSeconds);
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const initDone = useRef(false);
|
||||
|
||||
const getGameState = useCallback(() => state, [state]);
|
||||
|
||||
const { serverLoaded } = useSaveSync({
|
||||
getGameState,
|
||||
onLoad: loadFromServer,
|
||||
playTimeSeconds: playSeconds,
|
||||
});
|
||||
|
||||
// Once auth resolves: if no user or no server save → init guest
|
||||
useEffect(() => {
|
||||
if (authLoading || initDone.current || ready) return;
|
||||
|
||||
// Not logged in → guest mode immediately
|
||||
if (!user) {
|
||||
initDone.current = true;
|
||||
initGuest();
|
||||
return;
|
||||
}
|
||||
|
||||
// Logged in but server save loaded (or confirmed empty) → useSaveSync handles it
|
||||
// If serverLoaded is true and store isn't ready yet, it means server had no save
|
||||
if (serverLoaded && !ready) {
|
||||
initDone.current = true;
|
||||
initGuest(); // use localStorage as starting point, server will save it on next sync
|
||||
}
|
||||
}, [authLoading, user, serverLoaded, ready, initGuest]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// GameTick.tsx — Lance le tick Zustand toutes les secondes
|
||||
// À monter une seule fois dans l'arbre React (dans App)
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
|
||||
export function GameTick() {
|
||||
const tick = useGameStore((s) => s.tick);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [tick]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// GeneratorShop.tsx — Boutique de générateurs (economy.ts)
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
export function GeneratorShop() {
|
||||
const generators = useGameStore((s) => s.state.generators);
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
const productionPerSecond = useGameStore((s) => s.productionPerSecond);
|
||||
const buy = useGameStore((s) => s.buy);
|
||||
const generatorCost = useGameStore((s) => s.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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// MilestonesPanel.tsx — Paliers de prestige (Sprint 3)
|
||||
// Progress bar vers le prochain milestone, claim button, preview reward
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { getClaimableMilestones, getNextMilestone } from "../core/economy";
|
||||
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
|
||||
|
||||
export function MilestonesPanel() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const claim = useGameStore((s) => s.claimMilestone);
|
||||
|
||||
if (state.prestigeCount < 1) return null;
|
||||
|
||||
const claimable = getClaimableMilestones(state);
|
||||
const nextMilestone = getNextMilestone(state);
|
||||
const totalClaimed = state.claimedMilestones.length;
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="gp-title">Milestones</span>
|
||||
<span className="gp-label">{totalClaimed}/{PRESTIGE_MILESTONES.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Claimable milestones */}
|
||||
{claimable.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{claimable.map((m) => (
|
||||
<div key={m.id} className="gp-row gp-row--evolution border-purple-400/30!">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="gp-value text-[0.7rem]!">{m.name}</span>
|
||||
<span className="gp-label">{m.reward.label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => claim(m.id)}
|
||||
className="gp-btn gp-btn--buy"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress vers le prochain milestone */}
|
||||
{nextMilestone && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="gp-label">Prochain : {nextMilestone.name}</span>
|
||||
<span className="gp-label">
|
||||
{state.prestigeCount}/{nextMilestone.threshold}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gp-progress">
|
||||
<div
|
||||
className="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400"
|
||||
style={{
|
||||
width: `${Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="gp-label">{nextMilestone.reward.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tous les milestones réclamés */}
|
||||
{!nextMilestone && claimable.length === 0 && (
|
||||
<span className="gp-label text-center gp-accent-purple">
|
||||
Tous les milestones reclames !
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Liste compacte des milestones passés */}
|
||||
{totalClaimed > 0 && claimable.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{PRESTIGE_MILESTONES.filter((m) => state.claimedMilestones.includes(m.id)).map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
|
||||
title={`${m.name} — ${m.description}`}
|
||||
>
|
||||
{m.threshold}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 d’accueil"
|
||||
/>
|
||||
<div className="section">
|
||||
<p className="section-title">A Propos</p>
|
||||
<p className="section-text">
|
||||
Clickerz — fais éclore des têtards, construis ton empire et domine
|
||||
le marais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<p className="section-title">Légal</p>
|
||||
<ul className="section-list">
|
||||
<li className="section-item">
|
||||
<Link
|
||||
to="/mentionslegales"
|
||||
title="Aller à la page Mentions Légales"
|
||||
>
|
||||
Mentions Légales
|
||||
</Link>
|
||||
</li>
|
||||
<li className="section-item">
|
||||
<Link to="/cookies" title="Aller à la page Cookies">
|
||||
Cookies
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="spacing" />
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<a
|
||||
href="https://github.com/tetardtek"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer-github"
|
||||
title="GitHub — Kevin T"
|
||||
>
|
||||
Kevin T
|
||||
</a>
|
||||
<p className="copyright">
|
||||
© 2026 Tetardtek · Powered by Brain
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,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: [],
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { apiFetch } from "../lib/api";
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const data = await apiFetch("/auth/me");
|
||||
setUser(data);
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onExpired = () => setUser(null);
|
||||
window.addEventListener("auth:expired", onExpired);
|
||||
return () => window.removeEventListener("auth:expired", onExpired);
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiFetch("/auth/logout", { method: "POST" });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const editUser = async (updatedFields) => {
|
||||
const data = await apiFetch(`/users/${user.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updatedFields),
|
||||
});
|
||||
setUser((prev) => ({ ...prev, ...data.user }));
|
||||
return "User updated successfully";
|
||||
};
|
||||
|
||||
const authContextValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
loading,
|
||||
logout,
|
||||
refresh,
|
||||
editUser,
|
||||
setUser: (newUser) => setUser(newUser),
|
||||
}),
|
||||
[user, loading]
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
AuthProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { AuthProvider, useAuth };
|
||||
@@ -1,229 +0,0 @@
|
||||
{
|
||||
"v": "5.4.4",
|
||||
"fr": 29.9700012207031,
|
||||
"ip": 0,
|
||||
"op": 120.0000048877,
|
||||
"w": 1080,
|
||||
"h": 620,
|
||||
"nm": "Comp 2",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Shape Layer 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [539.5, 310, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 81],
|
||||
[0, 0],
|
||||
[-77, 0],
|
||||
[0, -39],
|
||||
[0, 0],
|
||||
[17, -20],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, -81],
|
||||
[0, 0],
|
||||
[77, 0],
|
||||
[0, 39],
|
||||
[0, 0],
|
||||
[-17, 20],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-539.25, 234],
|
||||
[-244, 234],
|
||||
[-244, 126],
|
||||
[-385, 126],
|
||||
[-385, 62],
|
||||
[-263, -238],
|
||||
[-178, -238],
|
||||
[-178, 54],
|
||||
[-150, 54],
|
||||
[-150, 120],
|
||||
[-176, 120],
|
||||
[-176, 234],
|
||||
[-59, 234],
|
||||
[-101, 143],
|
||||
[-101, -160],
|
||||
[-9, -248],
|
||||
[100, -165],
|
||||
[101, 157],
|
||||
[80, 220],
|
||||
[50, 240],
|
||||
[288, 240],
|
||||
[287, 123],
|
||||
[148, 123],
|
||||
[148, 56],
|
||||
[268, -239],
|
||||
[357, -239],
|
||||
[357, 51],
|
||||
[380, 51],
|
||||
[380, 122],
|
||||
[359, 122],
|
||||
[359, 241.75],
|
||||
[540.5, 242]
|
||||
],
|
||||
"c": false
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "Path 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.1, 0.1, 0.1, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 5, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "Stroke 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 2 },
|
||||
"a": { "a": 0, "k": [0, 0], "ix": 1 },
|
||||
"s": { "a": 0, "k": [100, 100], "ix": 3 },
|
||||
"r": { "a": 0, "k": 0, "ix": 6 },
|
||||
"o": { "a": 0, "k": 100, "ix": 7 },
|
||||
"sk": { "a": 0, "k": 0, "ix": 4 },
|
||||
"sa": { "a": 0, "k": 0, "ix": 5 },
|
||||
"nm": "Transform"
|
||||
}
|
||||
],
|
||||
"nm": "Shape 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tm",
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.667], "y": [0.992] },
|
||||
"o": { "x": [0.534], "y": [0.224] },
|
||||
"t": 47,
|
||||
"s": [0],
|
||||
"e": [100]
|
||||
},
|
||||
{ "t": 95.0000038694293 }
|
||||
],
|
||||
"ix": 1
|
||||
},
|
||||
"e": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.667], "y": [0.96] },
|
||||
"o": { "x": [0.677], "y": [0.024] },
|
||||
"t": 15,
|
||||
"s": [0],
|
||||
"e": [100]
|
||||
},
|
||||
{ "t": 82.0000033399285 }
|
||||
],
|
||||
"ix": 2
|
||||
},
|
||||
"o": { "a": 0, "k": 0, "ix": 3 },
|
||||
"m": 1,
|
||||
"ix": 2,
|
||||
"nm": "Trim Paths 1",
|
||||
"mn": "ADBE Vector Filter - Trim",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 120.0000048877,
|
||||
"st": 0,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"linkname": "Jeu",
|
||||
"linkurl": "/jeu",
|
||||
"btn": false
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"linkname": "Succès",
|
||||
"linkurl": "/achievements",
|
||||
"btn": false
|
||||
}
|
||||
]
|
||||
@@ -1,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 };
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Centralized API client — cookie-based auth with 401 auto-refresh
|
||||
|
||||
const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
let refreshPromise = null;
|
||||
|
||||
async function tryRefresh() {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 401 && path !== '/auth/refresh') {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (retry.ok) {
|
||||
if (retry.status === 204) return null;
|
||||
return retry.json();
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new Event('auth:expired'));
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(body.message || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
58
Frontend/src/lib/api.ts
Normal file
58
Frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Centralized API client — cookie-based auth with 401 auto-refresh
|
||||
|
||||
const BASE = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (res.status === 401 && path !== '/auth/refresh') {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) {
|
||||
const retry = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (retry.ok) {
|
||||
if (retry.status === 204) return null;
|
||||
return retry.json();
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event('auth:expired'));
|
||||
}
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(body.message || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
121
Frontend/src/lib/components/ClickPanel.svelte
Normal file
121
Frontend/src/lib/components/ClickPanel.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { getClickBreakdown, clickUpgradeCost } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let b = $derived(getClickBreakdown(game.state));
|
||||
let expected = $derived(b.total * (1 + b.doubleChance + b.critChance * 9));
|
||||
|
||||
const CLICKS_PER_SEC = 5;
|
||||
let manualProd = $derived(expected * CLICKS_PER_SEC);
|
||||
let totalWithClicks = $derived(game.productionPerSecond + b.effectivePerSec + manualProd);
|
||||
let clickShare = $derived(totalWithClicks > 0 ? ((b.effectivePerSec + manualProd) / totalWithClicks * 100) : 0);
|
||||
|
||||
// Generator names for display
|
||||
const GEN_NAMES: Record<string, string> = {
|
||||
nid: 'Nid', mare: 'Mare', marecage: 'Marecage', etang: 'Etang', lac: 'Lac',
|
||||
};
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel title="Ponte (clic)" badge="{formatNumber(b.total)}" accentClass="gp-accent-amber" defaultOpen={false}>
|
||||
<!-- Gain par clic -->
|
||||
<div class="gp-row gp-row--active">
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="gp-value">Gain par clic</span>
|
||||
<span class="gp-label">
|
||||
base {b.base} × x{b.prestigeMult.toFixed(1)} × x{b.treeMult.toFixed(0)} × x{b.genBonus.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-value gp-accent-amber text-lg!">{formatNumber(b.total)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Click contribution -->
|
||||
<div class="gp-row" style="border-color: rgba(251,191,36,0.15); background: rgba(251,191,36,0.04);">
|
||||
<div class="flex flex-col flex-1">
|
||||
<span class="gp-value text-[0.7rem]!">Contribution clics</span>
|
||||
<span class="gp-label">
|
||||
~{CLICKS_PER_SEC} clics/s → {formatNumber(manualProd)}/s
|
||||
{#if b.autoClicksPerSec > 0} + auto {formatNumber(b.effectivePerSec)}/s{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label gp-accent-amber">{clickShare.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
<!-- Click upgrades shop -->
|
||||
{#if (game.state.clickUpgrades ?? []).length > 0}
|
||||
<span class="gp-zone-label mt-1">Ameliorations de ponte</span>
|
||||
{#each game.state.clickUpgrades ?? [] as upgrade}
|
||||
{@const gen = game.state.generators.find((g) => g.id === upgrade.generatorId)}
|
||||
{@const hasGen = gen && gen.owned > 0}
|
||||
{@const cost = clickUpgradeCost(upgrade)}
|
||||
{@const canAfford = hasGen && game.state.resources >= cost}
|
||||
<div class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="gp-value text-[0.7rem]!">{upgrade.name}</span>
|
||||
{#if upgrade.level > 0}
|
||||
<span
|
||||
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
|
||||
style="background: rgba(251,191,36,0.15); color: var(--color-gp-accent-amber);"
|
||||
>
|
||||
nv.{upgrade.level}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="gp-label">
|
||||
{#if hasGen}
|
||||
+{upgrade.baseClickPower}/clic par niveau ({GEN_NAMES[upgrade.generatorId]})
|
||||
{:else}
|
||||
Necessite un {GEN_NAMES[upgrade.generatorId]}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if hasGen}
|
||||
<button
|
||||
onclick={() => game.buyClickUpgrade(upgrade.id)}
|
||||
disabled={!canAfford}
|
||||
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="gp-label">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Double/Crit/Auto -->
|
||||
<span class="gp-zone-label mt-1">Bonus (arbre)</span>
|
||||
|
||||
<div class="gp-row {b.doubleChance > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Double Ponte</span>
|
||||
<span class="gp-label">
|
||||
{b.doubleChance > 0 ? `${(b.doubleChance * 100).toFixed(0)}% chance de doubler` : 'Branche Ponte — 5 ADN'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label {b.doubleChance > 0 ? 'gp-accent-purple' : ''}">{b.doubleChance > 0 ? `${(b.doubleChance * 100).toFixed(0)}%` : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-row {b.critChance > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Ponte Critique</span>
|
||||
<span class="gp-label">
|
||||
{b.critChance > 0 ? `${(b.critChance * 100).toFixed(0)}% chance de x10` : 'Branche Ponte — 20 ADN'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label {b.critChance > 0 ? 'gp-accent-amber' : ''}">{b.critChance > 0 ? `${(b.critChance * 100).toFixed(0)}%` : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-row {b.autoClicksPerSec > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Auto-Ponte</span>
|
||||
<span class="gp-label">
|
||||
{b.autoClicksPerSec > 0 ? `${b.autoClicksPerSec.toFixed(1)} clics/s → ${formatNumber(b.effectivePerSec)}/s` : 'Capstone Ponte — 200 ADN'}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label {b.autoClicksPerSec > 0 ? 'gp-accent-green' : ''}">{b.autoClicksPerSec > 0 ? `${formatNumber(b.effectivePerSec)}/s` : '—'}</span>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
50
Frontend/src/lib/components/ClickParticles.svelte
Normal file
50
Frontend/src/lib/components/ClickParticles.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
interface Particle {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
gain: number;
|
||||
isDouble: boolean;
|
||||
isCrit: boolean;
|
||||
}
|
||||
|
||||
let particles = $state<Particle[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
export function spawn(x: number, y: number, gain: number, isDouble: boolean, isCrit: boolean) {
|
||||
const id = nextId++;
|
||||
// Random horizontal spread
|
||||
const offsetX = (Math.random() - 0.5) * 40;
|
||||
particles = [...particles, { id, x: x + offsetX, y, gain, isDouble, isCrit }];
|
||||
setTimeout(() => {
|
||||
particles = particles.filter(p => p.id !== id);
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 pointer-events-none z-[100]">
|
||||
{#each particles as p (p.id)}
|
||||
{@const prefix = p.isCrit ? 'CRIT ' : p.isDouble ? 'x2 ' : ''}
|
||||
{@const color = p.isCrit ? '#f59e0b' : p.isDouble ? '#a78bfa' : '#34d399'}
|
||||
{@const size = p.isCrit ? '2rem' : p.isDouble ? '1.8rem' : '1.6rem'}
|
||||
<span
|
||||
class="absolute font-extrabold"
|
||||
style="
|
||||
left: {p.x}px;
|
||||
top: {p.y}px;
|
||||
color: {color};
|
||||
font-size: {size};
|
||||
font-family: var(--font);
|
||||
text-shadow: 0 0 10px {color}60, 0 2px 6px rgba(0,0,0,0.7);
|
||||
"
|
||||
in:fly={{ y: 0, duration: 50 }}
|
||||
out:fly={{ y: -90, duration: 900, easing: quintOut, opacity: 0 }}
|
||||
>
|
||||
{prefix}+{formatNumber(p.gain)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
34
Frontend/src/lib/components/CockpitHeader.svelte
Normal file
34
Frontend/src/lib/components/CockpitHeader.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { getClickBreakdown } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
let cb = $derived(getClickBreakdown(game.state));
|
||||
// Expected value per click = total × (1 + doubleChance + critChance × 9)
|
||||
let expectedPerClick = $derived(cb.total * (1 + cb.doubleChance + cb.critChance * 9));
|
||||
</script>
|
||||
|
||||
<div class="gp">
|
||||
<div class="grid grid-cols-5 gap-0.5 px-1">
|
||||
<div class="gp-stat" title="Production passive par seconde (generateurs)">
|
||||
<span class="gp-label">Passif</span>
|
||||
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(game.productionPerSecond)}/s</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Valeur attendue par clic (double + crit inclus)">
|
||||
<span class="gp-label">Clic</span>
|
||||
<span class="gp-value gp-accent-amber text-[0.8rem]!">{formatNumber(expectedPerClick)}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Multiplicateur global (prestige)">
|
||||
<span class="gp-label">Mult</span>
|
||||
<span class="gp-value text-[0.8rem]!">x{game.state.prestigeMultiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="ADN Ancestral">
|
||||
<span class="gp-label">ADN</span>
|
||||
<span class="gp-value gp-accent-purple text-[0.8rem]!">{game.state.ancestralDna}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Nombre de prestiges">
|
||||
<span class="gp-label">Gen.</span>
|
||||
<span class="gp-value text-[0.8rem]!">{game.state.prestigeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
51
Frontend/src/lib/components/CollapsiblePanel.svelte
Normal file
51
Frontend/src/lib/components/CollapsiblePanel.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
badge?: string;
|
||||
accentClass?: string;
|
||||
defaultOpen?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { title, badge = '', accentClass = '', defaultOpen = true, children }: Props = $props();
|
||||
|
||||
let open = $state(defaultOpen);
|
||||
</script>
|
||||
|
||||
<div class="gp overflow-hidden">
|
||||
<button
|
||||
class="flex items-center justify-between w-full cursor-pointer group"
|
||||
onclick={() => open = !open}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform duration-200 text-white/50 group-hover:text-white/80"
|
||||
class:rotate-90={open}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span class="gp-title {accentClass}">{title}</span>
|
||||
</div>
|
||||
{#if badge}
|
||||
<span class="gp-label">{badge}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 250, easing: quintOut }}>
|
||||
<div class="flex flex-col gap-[var(--spacing-gp-gap)] pt-1">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
54
Frontend/src/lib/components/CosmeticsPanel.svelte
Normal file
54
Frontend/src/lib/components/CosmeticsPanel.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
const SLOT_LABELS: Record<CosmeticSlot, string> = {
|
||||
hat: 'Tete', eyes: 'Yeux', body: 'Corps', tail: 'Queue', accessory: 'Aura',
|
||||
};
|
||||
const SLOT_ICONS: Record<CosmeticSlot, string> = {
|
||||
hat: '👑', eyes: '👁', body: '🛡', tail: '🦎', accessory: '✨',
|
||||
};
|
||||
const SLOT_ORDER: CosmeticSlot[] = ['hat', 'eyes', 'body', 'tail', 'accessory'];
|
||||
|
||||
let inventory = $derived(game.state.cosmeticInventory);
|
||||
let equipped = $derived(game.state.cosmeticEquipped);
|
||||
let ownedCosmetics = $derived(COSMETICS.filter((c) => inventory.includes(c.id)));
|
||||
</script>
|
||||
|
||||
{#if inventory.length > 0}
|
||||
<CollapsiblePanel
|
||||
title="Cosmetiques"
|
||||
badge="{inventory.length}/{COSMETICS.length}"
|
||||
defaultOpen={false}
|
||||
>
|
||||
{#each SLOT_ORDER as slot, si}
|
||||
{@const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot)}
|
||||
{#if slotCosmetics.length > 0}
|
||||
<div
|
||||
class="flex flex-col gap-0.5"
|
||||
in:fly={{ y: 15, delay: si * 60, duration: 250, easing: quintOut }}
|
||||
>
|
||||
<span class="gp-zone-label">{SLOT_ICONS[slot]} {SLOT_LABELS[slot]}</span>
|
||||
{#each slotCosmetics as cos}
|
||||
{@const isEquipped = equipped[slot] === cos.id}
|
||||
<div class="gp-row {isEquipped ? 'gp-row--unlocked' : 'gp-row--active'}">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="gp-value text-[0.7rem]!">{cos.name}</span>
|
||||
<span class="gp-label">{cos.description}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => isEquipped ? game.unequipCosmetic(slot) : game.equipCosmetic(cos.id)}
|
||||
class="gp-btn {isEquipped ? 'gp-btn--disabled' : 'gp-btn--buy'}"
|
||||
>
|
||||
{isEquipped ? 'Retirer' : 'Equiper'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</CollapsiblePanel>
|
||||
{/if}
|
||||
193
Frontend/src/lib/components/EvolutionTree.svelte
Normal file
193
Frontend/src/lib/components/EvolutionTree.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import {
|
||||
canBuyEvolutionNode,
|
||||
getSpentDna,
|
||||
getTreeResetCost,
|
||||
canResetTree,
|
||||
getRepeatableCost,
|
||||
canUpgradeConvergence,
|
||||
type EvolutionNode,
|
||||
type Branch,
|
||||
} from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} tetards au depart`,
|
||||
unlock_generator: () => `Lac Mystique des le debut`,
|
||||
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
|
||||
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
|
||||
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
|
||||
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
|
||||
generator_boost: (v) => `x${v} Nid`,
|
||||
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
|
||||
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
|
||||
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
|
||||
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
|
||||
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
|
||||
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
|
||||
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
|
||||
};
|
||||
|
||||
const BRANCH_CONFIG: Record<string, { label: string; color: string; accent: string }> = {
|
||||
ponte: { label: 'Ponte', color: 'border-emerald-500/30', accent: 'gp-accent-green' },
|
||||
marais: { label: 'Marais', color: 'border-blue-500/30', accent: 'text-blue-400' },
|
||||
adaptation: { label: 'Adaptation', color: 'border-amber-500/30', accent: 'gp-accent-amber' },
|
||||
cross: { label: 'Convergence', color: 'border-purple-500/30', accent: 'gp-accent-purple' },
|
||||
};
|
||||
|
||||
const BRANCHES: Branch[] = ['ponte', 'marais', 'adaptation'];
|
||||
|
||||
let activeBranch = $state<Branch>('ponte');
|
||||
|
||||
let branchConfig = $derived(BRANCH_CONFIG[activeBranch]);
|
||||
let branchNodes = $derived(game.state.evolutionTree.filter((n) => n.branch === activeBranch));
|
||||
let spentDna = $derived(getSpentDna(game.state.evolutionTree));
|
||||
let hasUnlocked = $derived(spentDna > 0);
|
||||
let resetCost = $derived(getTreeResetCost(game.state));
|
||||
let canReset = $derived(canResetTree(game.state));
|
||||
let conv = $derived(game.state.evolutionTree.find((n) => n.id === 'convergence'));
|
||||
let canBuyConv = $derived(canBuyEvolutionNode(game.state, 'convergence'));
|
||||
let canUpgradeConv = $derived(canUpgradeConvergence(game.state));
|
||||
|
||||
function handleReset() {
|
||||
if (!canReset) return;
|
||||
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : ' (gratuit)';
|
||||
const confirmed = window.confirm(
|
||||
`Reinitialiser l'Arbre d'Evolution ?\n\nTu recuperes ${spentDna} ADN Ancestral.${costLabel}\nTous les noeuds seront verrouilles.\n\nConfirmer ?`
|
||||
);
|
||||
if (confirmed) game.resetTree();
|
||||
}
|
||||
|
||||
function getNodeRowClass(node: EvolutionNode, isExcluded: boolean, canBuy: boolean): string {
|
||||
if (node.unlocked) return node.capstone ? 'gp-row gp-row--unlocked border-amber-400/40!' : 'gp-row gp-row--unlocked';
|
||||
if (isExcluded) return 'gp-row gp-row--locked opacity-30!';
|
||||
if (canBuy) return node.capstone ? 'gp-row gp-row--evolution border-amber-400/30!' : 'gp-row gp-row--evolution';
|
||||
return 'gp-row gp-row--locked';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if game.state.prestigeCount >= 1}
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="gp-title">Evolution</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="gp-value gp-accent-amber">{formatNumber(game.state.ancestralDna)} ADN</span>
|
||||
{#if hasUnlocked}
|
||||
<button
|
||||
onclick={handleReset}
|
||||
disabled={!canReset}
|
||||
class="gp-btn text-[0.55rem]! {canReset ? 'gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!' : 'gp-btn--disabled'}"
|
||||
title="Recuperer {spentDna} ADN{resetCost > 0 ? ` (coute ${resetCost})` : ' (gratuit)'}"
|
||||
>
|
||||
Reset{resetCost > 0 ? ` (${resetCost})` : ''}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch tabs -->
|
||||
<div class="flex gap-1">
|
||||
{#each BRANCHES as branch}
|
||||
{@const config = BRANCH_CONFIG[branch]}
|
||||
{@const isActive = activeBranch === branch}
|
||||
<button
|
||||
onclick={() => activeBranch = branch}
|
||||
class="gp-btn flex-1 py-1.5! text-[0.7rem]! font-bold! uppercase! tracking-wider! {isActive ? `gp-btn--buy ${config.accent}` : 'gp-btn--disabled'}"
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Active branch -->
|
||||
<div class="gp flex-1 min-w-0 border-t-2 {branchConfig.color}">
|
||||
<span class="gp-title text-center {branchConfig.accent}">{branchConfig.label}</span>
|
||||
{#each branchNodes as node}
|
||||
{@const isExcluded = node.exclusive_with ? (game.state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
|
||||
{@const canBuy = canBuyEvolutionNode(game.state, node.id)}
|
||||
{@const cost = node.repeatable && node.unlocked ? getRepeatableCost(node) : node.cost}
|
||||
<div class={getNodeRowClass(node, isExcluded, canBuy)}>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
{#if node.capstone}<span class="text-amber-400 text-[0.6rem]">★</span>{/if}
|
||||
<span class="gp-value text-[0.7rem]!">{node.name}</span>
|
||||
{#if node.repeatable && node.unlocked}
|
||||
<span class="gp-label text-[0.55rem]!">x{node.purchased ?? 0}</span>
|
||||
{/if}
|
||||
{#if node.exclusive_with && !node.unlocked && !isExcluded}
|
||||
<span class="gp-label text-[0.55rem]!">OU</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||
</div>
|
||||
{#if node.unlocked && !node.repeatable}
|
||||
<span class="gp-label gp-accent-green">OK</span>
|
||||
{:else if isExcluded}
|
||||
<span class="gp-label text-[0.55rem]!">verrouille</span>
|
||||
{:else}
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onclick={() => game.buyNode(node.id)}
|
||||
class="gp-btn {canBuy ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Convergence -->
|
||||
{#if conv}
|
||||
<div class="gp border-t-2 border-purple-500/30">
|
||||
<span class="gp-title text-center gp-accent-purple">
|
||||
Convergence {conv.unlocked ? ((conv.tier ?? 1) >= 2 ? 'Omega' : 'Alpha') : ''}
|
||||
</span>
|
||||
{#if conv.unlocked}
|
||||
{@const tier = conv.tier ?? 1}
|
||||
{@const maxTier = conv.maxTier ?? 2}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="gp-row gp-row--unlocked border-purple-400/30!">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">{tier >= 2 ? 'Omega' : 'Alpha'} (tier {tier}/{maxTier})</span>
|
||||
<span class="gp-label">
|
||||
{tier >= 2 ? '+10% tous effets + -20% cout post-capstones' : "+10% a tous les effets de l'arbre"}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label gp-accent-green">OK</span>
|
||||
</div>
|
||||
{#if tier < maxTier}
|
||||
<button
|
||||
disabled={!canUpgradeConv}
|
||||
onclick={() => game.upgradeConvergence()}
|
||||
class="gp-btn {canUpgradeConv ? 'gp-btn--buy' : 'gp-btn--disabled'} w-full"
|
||||
>
|
||||
{canUpgradeConv ? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)` : `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gp-row gp-row--locked">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Convergence Alpha</span>
|
||||
<span class="gp-label">+10% a tous les effets de l'arbre</span>
|
||||
<span class="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
|
||||
</div>
|
||||
<button
|
||||
disabled={!canBuyConv}
|
||||
onclick={() => game.buyNode('convergence')}
|
||||
class="gp-btn {canBuyConv ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{conv.cost}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
8
Frontend/src/lib/components/Footer.svelte
Normal file
8
Frontend/src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,8 @@
|
||||
<footer class="footer">
|
||||
<div class="footer-container">
|
||||
<a href="/" aria-label="Accueil Clickerz">
|
||||
<div class="footer-logo"></div>
|
||||
</a>
|
||||
</div>
|
||||
<p class="copyright">© {new Date().getFullYear()} Clickerz — Tetard Universe</p>
|
||||
</footer>
|
||||
28
Frontend/src/lib/components/GameSync.svelte
Normal file
28
Frontend/src/lib/components/GameSync.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import {
|
||||
loadFromServer,
|
||||
startAutoSave,
|
||||
stopAutoSave,
|
||||
setupVisibilitySync,
|
||||
} from '$lib/save-sync.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
// Init auth
|
||||
await authStore.init();
|
||||
|
||||
// Load save or init guest
|
||||
if (authStore.user) {
|
||||
const loaded = await loadFromServer();
|
||||
if (!loaded && !game.ready) {
|
||||
game.initGuest();
|
||||
}
|
||||
startAutoSave();
|
||||
setupVisibilitySync();
|
||||
} else {
|
||||
game.initGuest();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
14
Frontend/src/lib/components/GameTick.svelte
Normal file
14
Frontend/src/lib/components/GameTick.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => game.tick(), 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
91
Frontend/src/lib/components/GeneratorShop.svelte
Normal file
91
Frontend/src/lib/components/GeneratorShop.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { generatorEffectiveProduction, maxAffordable, bulkCost } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
type BuyMode = 1 | 5 | 10 | 'max';
|
||||
let buyMode = $state<BuyMode>(1);
|
||||
|
||||
const MODES: BuyMode[] = [1, 5, 10, 'max'];
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel
|
||||
title="Generateurs"
|
||||
badge="{formatNumber(game.productionPerSecond)}/s"
|
||||
accentClass=""
|
||||
>
|
||||
<!-- Buy mode selector -->
|
||||
<div class="flex gap-0.5 p-0.5 rounded-lg" style="background: rgba(255,255,255,0.04);">
|
||||
{#each MODES as mode}
|
||||
<button
|
||||
class="flex-1 py-1 rounded-md text-[0.65rem] font-semibold transition-all duration-150"
|
||||
style="font-family: var(--font); {buyMode === mode
|
||||
? 'background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.95);'
|
||||
: 'background: transparent; color: rgba(255,255,255,0.4);'
|
||||
}"
|
||||
onclick={() => buyMode = mode}
|
||||
>
|
||||
{mode === 'max' ? 'MAX' : `x${mode}`}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each game.state.generators as gen, i}
|
||||
{@const qty = buyMode === 'max' ? maxAffordable(game.state, gen.id) : buyMode}
|
||||
{@const cost = qty <= 1 ? game.generatorCostWithTree(gen) : bulkCost(game.state, gen.id, qty)}
|
||||
{@const canAfford = game.state.resources >= cost && qty > 0}
|
||||
{@const effectiveProd = generatorEffectiveProduction(gen, game.state)}
|
||||
{@const nextGain = generatorEffectiveProduction({ ...gen, owned: qty || 1 }, game.state)}
|
||||
{@const share = game.productionPerSecond > 0 ? (effectiveProd / game.productionPerSecond * 100) : 0}
|
||||
<div
|
||||
class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}"
|
||||
in:scale={{ delay: i * 30, duration: 200, start: 0.95, easing: quintOut }}
|
||||
>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="gp-value">{gen.name}</span>
|
||||
{#if gen.owned > 0}
|
||||
<span
|
||||
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
|
||||
style="background: rgba(16,185,129,0.15); color: var(--color-gp-accent-green);"
|
||||
>
|
||||
x{gen.owned}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if gen.owned > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="gp-label gp-accent-green">{formatNumber(effectiveProd)}/s</span>
|
||||
<span class="gp-label">·</span>
|
||||
<span class="gp-label">{share.toFixed(0)}%</span>
|
||||
<span class="gp-label">·</span>
|
||||
<span class="gp-label gp-accent-amber">+{formatNumber(nextGain)}/s</span>
|
||||
</div>
|
||||
<div class="h-[2px] rounded-full mt-0.5" style="background: rgba(255,255,255,0.06);">
|
||||
<div class="h-full rounded-full" style="width: {Math.min(share, 100)}%; background: var(--color-gp-accent-green); opacity: 0.5;"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="gp-label">
|
||||
<span class="gp-accent-amber">+{formatNumber(nextGain)}/s</span> par unite
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => game.buy(gen.id, qty || 1)}
|
||||
disabled={!canAfford}
|
||||
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{#if buyMode === 'max' && qty > 1}
|
||||
{formatNumber(cost)} (x{qty})
|
||||
{:else if buyMode !== 1 && buyMode !== 'max'}
|
||||
{formatNumber(cost)} (x{buyMode})
|
||||
{:else}
|
||||
{formatNumber(cost)}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</CollapsiblePanel>
|
||||
23
Frontend/src/lib/components/MilestoneBar.svelte
Normal file
23
Frontend/src/lib/components/MilestoneBar.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import { getPrestigeThreshold } from '$lib/core/economy';
|
||||
|
||||
let threshold = $derived(getPrestigeThreshold(game.state));
|
||||
let progress = $derived(Math.min(game.state.resources / threshold, 1));
|
||||
let progressPercent = $derived((progress * 100).toFixed(1));
|
||||
let remaining = $derived(Math.max(threshold - game.state.resources, 0));
|
||||
</script>
|
||||
|
||||
<div class="gp gap-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Prochaine Generation</span>
|
||||
<span class="gp-label">{formatNumber(game.state.resources)} / {formatNumber(threshold)}</span>
|
||||
</div>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<span class="gp-label text-right">
|
||||
{remaining > 0 ? `${formatNumber(remaining)} restants` : 'Nouvelle Generation disponible !'}
|
||||
</span>
|
||||
</div>
|
||||
72
Frontend/src/lib/components/MilestonesPanel.svelte
Normal file
72
Frontend/src/lib/components/MilestonesPanel.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy';
|
||||
import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let claimable = $derived(getClaimableMilestones(game.state));
|
||||
let nextMilestone = $derived(getNextMilestone(game.state));
|
||||
let claimed = $derived(game.state.claimedMilestones ?? []);
|
||||
let totalClaimed = $derived(claimed.length);
|
||||
</script>
|
||||
|
||||
{#if game.state.prestigeCount >= 1}
|
||||
<CollapsiblePanel
|
||||
title="Milestones"
|
||||
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
|
||||
accentClass="gp-accent-amber"
|
||||
>
|
||||
{#if claimable.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each claimable as m, i}
|
||||
<div
|
||||
class="gp-row gp-row--evolution border-purple-400/30!"
|
||||
in:fly={{ y: 20, delay: i * 80, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="gp-value text-[0.7rem]!">{m.name}</span>
|
||||
<span class="gp-label">{m.reward.label}</span>
|
||||
</div>
|
||||
<button onclick={() => game.claimMilestone(m.id)} class="gp-btn gp-btn--buy">
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if nextMilestone}
|
||||
{@const progressPct = Math.min((game.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Prochain : {nextMilestone.name}</span>
|
||||
<span class="gp-label">{game.state.prestigeCount}/{nextMilestone.threshold}</span>
|
||||
</div>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400" style="width: {progressPct}%"></div>
|
||||
</div>
|
||||
<span class="gp-label">{nextMilestone.reward.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !nextMilestone && claimable.length === 0}
|
||||
<span class="gp-label text-center gp-accent-purple">Tous les milestones reclames !</span>
|
||||
{/if}
|
||||
|
||||
{#if totalClaimed > 0 && claimable.length === 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each PRESTIGE_MILESTONES.filter((m) => claimed.includes(m.id)) as m, i}
|
||||
<span
|
||||
class="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
|
||||
title="{m.name} — {m.description}"
|
||||
in:scale={{ delay: i * 40, duration: 200 }}
|
||||
>
|
||||
{m.threshold}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsiblePanel>
|
||||
{/if}
|
||||
43
Frontend/src/lib/components/Navbar.svelte
Normal file
43
Frontend/src/lib/components/Navbar.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const navLinks = [
|
||||
{ name: 'Jeu', url: '/jeu' },
|
||||
{ name: 'Succes', url: '/achievements' },
|
||||
{ name: 'Guide', url: '/guide' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<header class="header-main" role="banner">
|
||||
<a href="/" aria-label="Accueil Clickerz">
|
||||
<img class="logo" alt="Clickerz" />
|
||||
</a>
|
||||
<nav class="navbar" aria-label="Navigation principale">
|
||||
<ul class="nav-list" role="list">
|
||||
{#each navLinks as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
class="mainLink"
|
||||
aria-current={page.url.pathname === link.url ? 'page' : undefined}
|
||||
style={page.url.pathname === link.url ? 'color: var(--color-red-light); font-weight: 600;' : ''}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div class="auth-nav">
|
||||
{#if authStore.loading}
|
||||
<span class="auth-nickname" aria-live="polite">...</span>
|
||||
{:else if authStore.user}
|
||||
<span class="auth-nickname">{authStore.user.nickname}</span>
|
||||
<a href="/settings" class="auth-btn">Profil</a>
|
||||
<button class="auth-btn" onclick={() => authStore.logout()} type="button">Deconnexion</button>
|
||||
{:else}
|
||||
<a href="/login" class="auth-btn">Connexion</a>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
61
Frontend/src/lib/components/OfflineReport.svelte
Normal file
61
Frontend/src/lib/components/OfflineReport.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { fade, fly, scale } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}h${minutes % 60}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && game.offlineReport) game.dismissOfflineReport(); }} />
|
||||
|
||||
{#if game.offlineReport}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);"
|
||||
transition:fade={{ duration: 250 }}
|
||||
onclick={() => game.dismissOfflineReport()}
|
||||
>
|
||||
<div
|
||||
class="gp max-w-sm w-full mx-4 text-center"
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
in:scale={{ duration: 400, start: 0.8, easing: backOut }}
|
||||
out:scale={{ duration: 200 }}
|
||||
>
|
||||
<div in:fly={{ y: -15, delay: 100, duration: 350, easing: quintOut }}>
|
||||
<h2 class="gp-title text-lg!">Retour au Marais</h2>
|
||||
<p class="gp-label mt-2">
|
||||
Absent pendant <span class="gp-accent-green">{formatDuration(game.offlineReport.duration)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}>
|
||||
<p
|
||||
class="gp-value text-3xl! mt-4 mb-2 gp-accent-green"
|
||||
style="text-shadow: 0 0 15px rgba(52,211,153,0.3);"
|
||||
>
|
||||
+{formatNumber(game.offlineReport.gains)} tetards
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
|
||||
Efficacite : {Math.round(game.offlineReport.efficiency * 100)}%
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
|
||||
onclick={() => game.dismissOfflineReport()}
|
||||
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
|
||||
>
|
||||
Continuer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
35
Frontend/src/lib/components/PrestigePanel.svelte
Normal file
35
Frontend/src/lib/components/PrestigePanel.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let baseDna = $derived(computePrestigeDna(game.state.lifetimeTadpoles, game.state.prestigeCount));
|
||||
let dnaBonus = $derived(getPrestigeDnaBonus(game.state.evolutionTree));
|
||||
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
|
||||
let threshold = $derived(getPrestigeThreshold(game.state));
|
||||
let progress = $derived(Math.min(game.state.lifetimeTadpoles / threshold * 100, 100));
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
|
||||
{#if game.canPrestige}
|
||||
<div class="flex flex-col gap-2" in:scale={{ duration: 300, start: 0.9, easing: quintOut }}>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="gp-value gp-accent-purple">+{dnaPreview} ADN</span>
|
||||
<span class="gp-label">+0.1x mult</span>
|
||||
</div>
|
||||
<button onclick={() => game.openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="gp-label">Atteins {formatNumber(threshold)} tetards</span>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progress.toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsiblePanel>
|
||||
162
Frontend/src/lib/components/PrestigeScreen.svelte
Normal file
162
Frontend/src/lib/components/PrestigeScreen.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, backOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
let baseDna = $derived(computePrestigeDna(game.state.lifetimeTadpoles, game.state.prestigeCount));
|
||||
let dnaBonus = $derived(getPrestigeDnaBonus(game.state.evolutionTree));
|
||||
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
|
||||
let threshold = $derived(getPrestigeThreshold(game.state));
|
||||
let canPrestige = $derived(game.state.lifetimeTadpoles >= threshold);
|
||||
let runDuration = $derived(Date.now() - game.state.runStats.startedAt);
|
||||
let bestRun = $derived(game.state.runStats.bestRun);
|
||||
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
|
||||
let isBestTadpoles = $derived(!bestRun || game.state.lifetimeTadpoles > bestRun.tadpoles);
|
||||
|
||||
function handlePrestige() {
|
||||
if (canPrestige) game.prestige();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && game.showPrestigeScreen) game.closePrestige(); }} />
|
||||
|
||||
{#if game.showPrestigeScreen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.85); backdrop-filter: blur(8px);"
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Modal card -->
|
||||
<div
|
||||
class="gp max-w-md w-full mx-4"
|
||||
in:scale={{ duration: 400, start: 0.85, easing: backOut }}
|
||||
out:scale={{ duration: 200, start: 0.95 }}
|
||||
>
|
||||
<!-- Header with generation number -->
|
||||
<div class="text-center" in:fly={{ y: -20, delay: 100, duration: 400, easing: quintOut }}>
|
||||
<span class="gp-title text-lg!">Nouvelle Generation</span>
|
||||
<p class="gp-label mt-1">Generation #{game.state.prestigeCount + 1}</p>
|
||||
</div>
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- ADN Preview — the hero number -->
|
||||
<div
|
||||
class="flex flex-col items-center gap-1 py-3"
|
||||
in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}
|
||||
>
|
||||
<span class="gp-label">ADN Ancestral</span>
|
||||
<span
|
||||
class="text-4xl font-extrabold"
|
||||
style="color: #a78bfa; font-family: var(--font); text-shadow: 0 0 20px rgba(167,139,250,0.4);"
|
||||
>
|
||||
+{formatNumber(dnaPreview)}
|
||||
</span>
|
||||
{#if dnaBonus > 0}
|
||||
<span class="gp-label">(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)</span>
|
||||
{/if}
|
||||
<span class="gp-label mt-1">Total apres : {formatNumber(game.state.ancestralDna + dnaPreview)} ADN</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- Run Stats -->
|
||||
<div class="flex flex-col gap-2" in:fly={{ y: 20, delay: 300, duration: 400, easing: quintOut }}>
|
||||
<span class="gp-zone-label">Stats de la run</span>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Duree</span>
|
||||
<span class="gp-value">{formatDuration(runDuration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Tetards produits</span>
|
||||
<span class="gp-value {isBestTadpoles ? 'gp-accent-green' : ''}">
|
||||
{formatNumber(game.state.lifetimeTadpoles)}
|
||||
{#if isBestTadpoles && bestRun} ★{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">ADN cette run</span>
|
||||
<span class="gp-value {isBestAdn ? 'gp-accent-green' : ''}">
|
||||
{formatNumber(dnaPreview)}
|
||||
{#if isBestAdn && bestRun} ★{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if bestRun}
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Vitesse vs meilleure</span>
|
||||
<span class="gp-value {runDuration < bestRun.duration ? 'gp-accent-green' : 'gp-accent-amber'}">
|
||||
{#if runDuration < bestRun.duration}
|
||||
{Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide
|
||||
{:else if runDuration > bestRun.duration}
|
||||
{Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent
|
||||
{:else}
|
||||
identique
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if bestRun}
|
||||
<div class="gp-sep"></div>
|
||||
<div class="flex flex-col gap-1" in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}>
|
||||
<span class="gp-zone-label">Meilleure run</span>
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Duree</span>
|
||||
<span class="gp-value">{formatDuration(bestRun.duration)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">ADN</span>
|
||||
<span class="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- Reset info -->
|
||||
<div class="text-center" in:fade={{ delay: 350, duration: 300 }}>
|
||||
<p class="gp-label">Tetards et generateurs remis a zero.</p>
|
||||
<p class="gp-label">Arbre d'Evolution et cosmetiques conserves.</p>
|
||||
<p class="gp-label mt-1 gp-accent-green">+1 reset d'arbre gratuit offert.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-1" in:fly={{ y: 20, delay: 450, duration: 300, easing: quintOut }}>
|
||||
<button
|
||||
onclick={() => game.closePrestige()}
|
||||
class="gp-btn flex-1 py-2.5! text-[0.8rem]!"
|
||||
style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6);"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
{#if canPrestige}
|
||||
<button onclick={handlePrestige} class="gp-btn gp-btn--prestige flex-1 py-2.5! text-[0.8rem]!">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
{:else}
|
||||
<button class="gp-btn gp-btn--disabled flex-1 py-2.5!" disabled>
|
||||
{formatNumber(threshold - game.state.lifetimeTadpoles)} manquants
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
59
Frontend/src/lib/components/SidebarTabs.svelte
Normal file
59
Frontend/src/lib/components/SidebarTabs.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab?: string;
|
||||
children: Snippet<[string]>;
|
||||
}
|
||||
|
||||
let { tabs, activeTab = tabs[0]?.id ?? '', children }: Props = $props();
|
||||
|
||||
let current = $state(activeTab);
|
||||
let direction = $state(1);
|
||||
|
||||
function switchTab(tabId: string) {
|
||||
const oldIdx = tabs.findIndex(t => t.id === current);
|
||||
const newIdx = tabs.findIndex(t => t.id === tabId);
|
||||
direction = newIdx > oldIdx ? 1 : -1;
|
||||
current = tabId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-0.5 p-0.5 rounded-lg" style="background: rgba(255,255,255,0.04);">
|
||||
{#each tabs as tab}
|
||||
{@const isActive = current === tab.id}
|
||||
<button
|
||||
onclick={() => switchTab(tab.id)}
|
||||
class="flex-1 flex items-center justify-center gap-1 py-2 px-1 rounded-md text-[0.7rem] font-semibold uppercase tracking-wider transition-all duration-200"
|
||||
style={isActive
|
||||
? 'background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.95); box-shadow: 0 1px 3px rgba(0,0,0,0.3);'
|
||||
: 'background: transparent; color: rgba(255,255,255,0.4);'
|
||||
}
|
||||
style:font-family="var(--font)"
|
||||
>
|
||||
<span class="text-sm">{tab.icon}</span>
|
||||
<span class="hidden sm:inline">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Tab content with directional fly -->
|
||||
{#key current}
|
||||
<div
|
||||
in:fly={{ x: 30 * direction, duration: 200, easing: quintOut }}
|
||||
out:fly={{ x: -30 * direction, duration: 150, easing: quintOut }}
|
||||
class="flex flex-col gap-[var(--spacing-gp-gap)]"
|
||||
>
|
||||
{@render children(current)}
|
||||
</div>
|
||||
{/key}
|
||||
66
Frontend/src/lib/components/TadpoleSprite.svelte
Normal file
66
Frontend/src/lib/components/TadpoleSprite.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
|
||||
|
||||
const SLOT_ORDER: CosmeticSlot[] = ['body', 'tail', 'eyes', 'hat', 'accessory'];
|
||||
|
||||
let overlays = $derived(
|
||||
SLOT_ORDER
|
||||
.map((slot) => {
|
||||
const cosId = game.state.cosmeticEquipped[slot];
|
||||
if (!cosId) return null;
|
||||
return COSMETICS.find((c) => c.id === cosId) ?? null;
|
||||
})
|
||||
.filter((c) => c !== null)
|
||||
);
|
||||
|
||||
// Click bounce animation
|
||||
let bouncing = $state(false);
|
||||
|
||||
export function bounce() {
|
||||
bouncing = true;
|
||||
setTimeout(() => bouncing = false, 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px] transition-transform duration-100"
|
||||
class:scale-[0.92]={bouncing}
|
||||
class:rotate-[3deg]={bouncing}
|
||||
style="filter: drop-shadow(0 0 20px rgba(52,211,153,0.15));"
|
||||
>
|
||||
<!-- Base sprite -->
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Tetard"
|
||||
class="w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- Cosmetic overlays -->
|
||||
{#each overlays as cos}
|
||||
<img
|
||||
src={cos.svg}
|
||||
alt={cos.name}
|
||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Glow ring on click -->
|
||||
{#if bouncing}
|
||||
<div
|
||||
class="absolute inset-0 rounded-full"
|
||||
style="
|
||||
background: radial-gradient(circle, rgba(52,211,153,0.15) 0%, transparent 70%);
|
||||
animation: click-ring 0.3s ease-out;
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes click-ring {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
36
Frontend/src/lib/components/ToastContainer.svelte
Normal file
36
Frontend/src/lib/components/ToastContainer.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
import { getToasts } from '$lib/stores/toast.svelte';
|
||||
|
||||
const variantStyles: Record<string, { color: string; border: string; bg: string }> = {
|
||||
success: { color: '#34d399', border: 'rgba(52,211,153,0.3)', bg: 'rgba(16,185,129,0.08)' },
|
||||
info: { color: '#60a5fa', border: 'rgba(96,165,250,0.3)', bg: 'rgba(59,130,246,0.08)' },
|
||||
reward: { color: '#fbbf24', border: 'rgba(251,191,36,0.3)', bg: 'rgba(245,158,11,0.08)' },
|
||||
warning: { color: '#f87171', border: 'rgba(248,113,113,0.3)', bg: 'rgba(239,68,68,0.08)' },
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if getToasts().length > 0}
|
||||
<div class="fixed top-24 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
{#each getToasts() as t (t.id)}
|
||||
{@const style = variantStyles[t.variant] || variantStyles.info}
|
||||
<div
|
||||
class="pointer-events-auto px-4 py-2.5 rounded-xl font-semibold text-sm shadow-2xl"
|
||||
style="
|
||||
background: rgba(17,17,17,0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid {style.border};
|
||||
color: {style.color};
|
||||
font-family: var(--font);
|
||||
box-shadow: 0 0 20px {style.bg}, 0 8px 32px rgba(0,0,0,0.4);
|
||||
"
|
||||
in:fly={{ x: 80, duration: 350, easing: backOut }}
|
||||
out:scale={{ duration: 200, start: 0.95, opacity: 0 }}
|
||||
role="alert"
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -86,11 +86,39 @@ export interface RunStats {
|
||||
} | null;
|
||||
}
|
||||
|
||||
// --- Click Upgrades (achetables en têtards, liés aux générateurs) ---
|
||||
|
||||
export interface ClickUpgrade {
|
||||
id: string;
|
||||
name: string;
|
||||
generatorId: string; // lié à quel générateur
|
||||
baseClickPower: number; // bonus clic par niveau
|
||||
baseCost: number; // coût de base
|
||||
level: number; // niveaux achetés
|
||||
}
|
||||
|
||||
export const DEFAULT_CLICK_UPGRADES: ClickUpgrade[] = [
|
||||
{ id: "nid_douillet", name: "Nid Douillet", generatorId: "nid", baseClickPower: 1, baseCost: 50, level: 0 },
|
||||
{ id: "eau_fertile", name: "Eau Fertile", generatorId: "mare", baseClickPower: 3, baseCost: 500, level: 0 },
|
||||
{ id: "spores_actives", name: "Spores Actives", generatorId: "marecage", baseClickPower: 8, baseCost: 5_000, level: 0 },
|
||||
{ id: "courant_vital", name: "Courant Vital", generatorId: "etang", baseClickPower: 20, baseCost: 50_000, level: 0 },
|
||||
{ id: "source_ancestrale", name: "Source Ancestrale", generatorId: "lac", baseClickPower: 50, baseCost: 500_000, level: 0 },
|
||||
];
|
||||
|
||||
export function clickUpgradeCost(upgrade: ClickUpgrade): number {
|
||||
return Math.floor(upgrade.baseCost * Math.pow(1.2, upgrade.level));
|
||||
}
|
||||
|
||||
export function totalClickUpgradePower(clickUpgrades: ClickUpgrade[]): number {
|
||||
return clickUpgrades.reduce((sum, u) => sum + u.baseClickPower * u.level, 0);
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
saveVersion: number;
|
||||
resources: number;
|
||||
clickMultiplier: number;
|
||||
generators: Generator[];
|
||||
clickUpgrades: ClickUpgrade[];
|
||||
lastTick: number; // timestamp ms — lazy calc reference
|
||||
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
|
||||
prestigeCount: number;
|
||||
@@ -186,8 +214,9 @@ export function computePrestigeDna(lifetimeTadpoles: number, prestigeCount: numb
|
||||
|
||||
// Milestones disponibles mais pas encore réclamés
|
||||
export function getClaimableMilestones(state: GameState): PrestigeMilestone[] {
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
return PRESTIGE_MILESTONES.filter(
|
||||
(m) => state.prestigeCount >= m.threshold && !state.claimedMilestones.includes(m.id)
|
||||
(m) => state.prestigeCount >= m.threshold && !claimed.includes(m.id)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,11 +230,12 @@ export function claimMilestone(state: GameState, milestoneId: string): GameState
|
||||
const milestone = PRESTIGE_MILESTONES.find((m) => m.id === milestoneId);
|
||||
if (!milestone) return null;
|
||||
if (state.prestigeCount < milestone.threshold) return null;
|
||||
if (state.claimedMilestones.includes(milestoneId)) return null;
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes(milestoneId)) return null;
|
||||
|
||||
let newState = {
|
||||
...state,
|
||||
claimedMilestones: [...state.claimedMilestones, milestoneId],
|
||||
claimedMilestones: [...claimed, milestoneId],
|
||||
};
|
||||
|
||||
// Appliquer la récompense
|
||||
@@ -224,13 +254,13 @@ export function claimMilestone(state: GameState, milestoneId: string): GameState
|
||||
|
||||
// Bonus gameplay cumulés depuis les milestones réclamés
|
||||
export function getMilestoneStartNid(state: GameState): number {
|
||||
const claimed = state.claimedMilestones;
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes("milestone_5")) return 1; // 1 Nid gratuit
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getMilestoneOfflineBonus(state: GameState): number {
|
||||
const claimed = state.claimedMilestones;
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes("milestone_15")) return 0.05; // +5% offline cap
|
||||
return 0;
|
||||
}
|
||||
@@ -573,20 +603,19 @@ export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
|
||||
return Math.max(1, Math.floor(base * (1 - reduction)));
|
||||
}
|
||||
|
||||
// Production effective d'un seul générateur (avec tous les bonus appliqués)
|
||||
export function generatorEffectiveProduction(gen: Generator, state: GameState): number {
|
||||
if (gen.owned === 0) return 0;
|
||||
const nidBoost = gen.id === "nid" ? getGeneratorBoostFromTree(state.evolutionTree) : 1;
|
||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||||
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
|
||||
return gen.baseProduction * gen.owned * nidBoost * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
|
||||
}
|
||||
|
||||
// Production totale par seconde de tous les générateurs
|
||||
export function totalProductionPerSecond(state: GameState): number {
|
||||
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
|
||||
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||||
const base = state.generators.reduce(
|
||||
(sum, gen) => {
|
||||
const boost = gen.id === "nid" ? nidBoost : 1;
|
||||
return sum + gen.baseProduction * gen.owned * boost;
|
||||
},
|
||||
0
|
||||
);
|
||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
|
||||
return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
|
||||
return state.generators.reduce((sum, gen) => sum + generatorEffectiveProduction(gen, state), 0);
|
||||
}
|
||||
|
||||
// Lazy calculation : ressources accumulées depuis lastTick
|
||||
@@ -610,10 +639,71 @@ export function applyIdleGains(state: GameState, now: number): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
// Gain de base par clic (sans RNG — pour affichage tooltip)
|
||||
// Bonus clic depuis les générateurs (diversité + quantité)
|
||||
export function getGeneratorClickBonus(generators: Generator[]): number {
|
||||
const typesOwned = generators.filter((g) => g.owned > 0).length;
|
||||
const totalOwned = generators.reduce((sum, g) => sum + g.owned, 0);
|
||||
return 1 + typesOwned * 2 + totalOwned * 0.05;
|
||||
}
|
||||
|
||||
// Gain par clic — base + upgrades, le tout multiplié par prestige × arbre × infra
|
||||
export function getClickGain(state: GameState): number {
|
||||
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
|
||||
const genBonus = getGeneratorClickBonus(state.generators);
|
||||
const upgradePower = totalClickUpgradePower(state.clickUpgrades ?? []);
|
||||
const base = state.clickMultiplier + upgradePower;
|
||||
return Math.floor(base * state.prestigeMultiplier * treeClickMult * genBonus);
|
||||
}
|
||||
|
||||
// Achat d'un click upgrade (coûte des têtards)
|
||||
export function buyClickUpgrade(state: GameState, upgradeId: string): GameState | null {
|
||||
const idx = (state.clickUpgrades ?? []).findIndex((u) => u.id === upgradeId);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const upgrade = state.clickUpgrades[idx];
|
||||
// Requires owning the linked generator
|
||||
const gen = state.generators.find((g) => g.id === upgrade.generatorId);
|
||||
if (!gen || gen.owned === 0) return null;
|
||||
|
||||
const cost = clickUpgradeCost(upgrade);
|
||||
if (state.resources < cost) return null;
|
||||
|
||||
const updatedUpgrades = [...state.clickUpgrades];
|
||||
updatedUpgrades[idx] = { ...upgrade, level: upgrade.level + 1 };
|
||||
|
||||
return { ...state, resources: state.resources - cost, clickUpgrades: updatedUpgrades };
|
||||
}
|
||||
|
||||
// Breakdown complet du clic (pour affichage cockpit)
|
||||
export interface ClickBreakdown {
|
||||
base: number;
|
||||
prestigeMult: number;
|
||||
treeMult: number;
|
||||
genBonus: number; // multiplicateur depuis generateurs (types + quantite)
|
||||
genTypes: number; // types possedes
|
||||
genTotal: number; // total unites possedees
|
||||
total: number; // gain par clic (floor)
|
||||
doubleChance: number;
|
||||
critChance: number;
|
||||
autoClicksPerSec: number;
|
||||
effectivePerSec: number;
|
||||
}
|
||||
|
||||
export function getClickBreakdown(state: GameState): ClickBreakdown {
|
||||
const upgradePower = totalClickUpgradePower(state.clickUpgrades ?? []);
|
||||
const base = state.clickMultiplier + upgradePower;
|
||||
const prestigeMult = state.prestigeMultiplier;
|
||||
const treeMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||
const genBonus = getGeneratorClickBonus(state.generators);
|
||||
const genTypes = state.generators.filter((g) => g.owned > 0).length;
|
||||
const genTotal = state.generators.reduce((sum, g) => sum + g.owned, 0);
|
||||
const total = Math.floor(base * prestigeMult * treeMult * genBonus);
|
||||
const doubleChance = getDoubleClickChance(state.evolutionTree);
|
||||
const critChance = getCritClickChance(state.evolutionTree);
|
||||
const autoClicksPerSec = getAutoClicksPerSecond(state.evolutionTree);
|
||||
const effectivePerSec = autoClicksPerSec * total;
|
||||
|
||||
return { base, prestigeMult, treeMult, genBonus, genTypes, genTotal, total, doubleChance, critChance, autoClicksPerSec, effectivePerSec };
|
||||
}
|
||||
|
||||
export interface ClickResult {
|
||||
@@ -660,29 +750,70 @@ export function applyClick(state: GameState, rng: number = Math.random()): Click
|
||||
}
|
||||
|
||||
// Achat d'un générateur (retourne null si fonds insuffisants)
|
||||
export function buyGenerator(state: GameState, genId: string): GameState | null {
|
||||
export function buyGenerator(state: GameState, genId: string, quantity = 1): GameState | null {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return null;
|
||||
|
||||
const gen = state.generators[genIndex];
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (state.resources < cost) return null;
|
||||
let gen = { ...state.generators[genIndex] };
|
||||
let resources = state.resources;
|
||||
|
||||
let bought = 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (resources < cost) break;
|
||||
resources -= cost;
|
||||
gen = { ...gen, owned: gen.owned + 1 };
|
||||
bought++;
|
||||
}
|
||||
|
||||
if (bought === 0) return null;
|
||||
|
||||
const updatedGenerators = [...state.generators];
|
||||
updatedGenerators[genIndex] = { ...gen, owned: gen.owned + 1 };
|
||||
updatedGenerators[genIndex] = gen;
|
||||
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources - cost,
|
||||
generators: updatedGenerators,
|
||||
};
|
||||
return { ...state, resources, generators: updatedGenerators };
|
||||
}
|
||||
|
||||
// Calcule combien d'unités on peut acheter avec les ressources actuelles
|
||||
export function maxAffordable(state: GameState, genId: string): number {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return 0;
|
||||
|
||||
let gen = { ...state.generators[genIndex] };
|
||||
let resources = state.resources;
|
||||
let count = 0;
|
||||
|
||||
while (true) {
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (resources < cost) break;
|
||||
resources -= cost;
|
||||
gen = { ...gen, owned: gen.owned + 1 };
|
||||
count++;
|
||||
if (count > 1000) break; // safety
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Cout total pour acheter N unités
|
||||
export function bulkCost(state: GameState, genId: string, quantity: number): number {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return Infinity;
|
||||
|
||||
let gen = { ...state.generators[genIndex] };
|
||||
let total = 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
total += generatorCost(gen, state.evolutionTree);
|
||||
gen = { ...gen, owned: gen.owned + 1 };
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Prestige : reset run, gain ADN, arbre persiste
|
||||
|
||||
export function getPrestigeThreshold(state: GameState): number {
|
||||
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
|
||||
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
|
||||
const scaling = Math.pow(1 + 0.1 * state.prestigeCount, 2);
|
||||
return Math.floor(BASE_PRESTIGE_THRESHOLD * scaling * (1 - reduction));
|
||||
}
|
||||
|
||||
export function canPrestige(state: GameState): boolean {
|
||||
@@ -736,6 +867,8 @@ export function applyPrestige(state: GameState): GameState {
|
||||
},
|
||||
freeResetAvailable: true, // 1 reset gratuit offert par prestige
|
||||
extraResetsUsed: 0,
|
||||
// Click upgrades reset au prestige (comme les générateurs)
|
||||
clickUpgrades: (state.clickUpgrades ?? DEFAULT_CLICK_UPGRADES).map((u) => ({ ...u, level: 0 })),
|
||||
// evolutionTree persiste — jamais reset
|
||||
};
|
||||
}
|
||||
@@ -754,6 +887,7 @@ export const DEFAULT_STATE: GameState = {
|
||||
resources: 0,
|
||||
clickMultiplier: 1,
|
||||
generators: DEFAULT_GENERATORS,
|
||||
clickUpgrades: DEFAULT_CLICK_UPGRADES,
|
||||
lastTick: Date.now(),
|
||||
lastOnline: Date.now(),
|
||||
prestigeCount: 0,
|
||||
@@ -3,8 +3,8 @@
|
||||
// Chaque sprint ajoute un step (v2→v3, etc.)
|
||||
|
||||
import { CURRENT_SAVE_VERSION } from "./balance";
|
||||
import type { GameState } from "./economy";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
|
||||
import type { GameState, ClickUpgrade } from "./economy";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS, DEFAULT_CLICK_UPGRADES } from "./economy";
|
||||
|
||||
/**
|
||||
* Détecte la version d'une save et applique les migrations nécessaires.
|
||||
@@ -23,6 +23,20 @@ export function migrateSave(raw: Record<string, unknown>): GameState {
|
||||
// Futures migrations :
|
||||
// if (version < 3) state = migrateV2toV3(state);
|
||||
|
||||
// Always rebuild tree & generators from defaults — the server/localStorage
|
||||
// may not store all fields (branch, cost, effect, baseProduction, etc.)
|
||||
state.evolutionTree = mergeEvolutionTree(
|
||||
state.evolutionTree as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
state.generators = mergeGenerators(
|
||||
state.generators as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
|
||||
// Click upgrades — merge with defaults (preserves levels, adds new upgrades)
|
||||
state.clickUpgrades = mergeClickUpgrades(
|
||||
state.clickUpgrades as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
|
||||
return state as unknown as GameState;
|
||||
}
|
||||
|
||||
@@ -140,3 +154,25 @@ function mergeGenerators(
|
||||
return { ...defaultGen };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge les click upgrades sauvegardés avec DEFAULT_CLICK_UPGRADES.
|
||||
* Conserve le level, met à jour les stats de base.
|
||||
*/
|
||||
function mergeClickUpgrades(
|
||||
saved: Array<Record<string, unknown>> | undefined
|
||||
): ClickUpgrade[] {
|
||||
if (!saved || !Array.isArray(saved)) {
|
||||
return DEFAULT_CLICK_UPGRADES.map((u) => ({ ...u }));
|
||||
}
|
||||
|
||||
const savedById = new Map(saved.map((u) => [u.id as string, u]));
|
||||
|
||||
return DEFAULT_CLICK_UPGRADES.map((def) => {
|
||||
const s = savedById.get(def.id);
|
||||
if (s) {
|
||||
return { ...def, level: typeof s.level === "number" ? s.level : 0 };
|
||||
}
|
||||
return { ...def };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// achievements.ts — Milestones Clickerz basés sur le GameState réel
|
||||
import { GameState } from "../core/economy";
|
||||
import type { GameState } from "../core/economy";
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
1
Frontend/src/lib/index.ts
Normal file
1
Frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,83 +0,0 @@
|
||||
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
|
||||
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
|
||||
|
||||
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
|
||||
|
||||
function base64UrlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier) {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function buildAuthUrl(redirectUri, provider, scope = 'openid profile email', clientId = OAUTH_CLIENT_ID) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
provider,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
|
||||
verifier,
|
||||
};
|
||||
}
|
||||
|
||||
export async function exchangeCode(code, verifier, redirectUri, clientId = OAUTH_CLIENT_ID) {
|
||||
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) throw new Error('No access_token in OAuth response');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveVerifier(verifier) {
|
||||
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
|
||||
}
|
||||
|
||||
export function loadVerifier() {
|
||||
return localStorage.getItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
|
||||
export function clearVerifier() {
|
||||
localStorage.removeItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
89
Frontend/src/lib/oauth.ts
Normal file
89
Frontend/src/lib/oauth.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
|
||||
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
|
||||
|
||||
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function buildAuthUrl(
|
||||
redirectUri: string,
|
||||
provider: string,
|
||||
scope = 'openid profile email',
|
||||
clientId = OAUTH_CLIENT_ID
|
||||
) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
provider,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return { url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`, verifier };
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
clientId = OAUTH_CLIENT_ID
|
||||
) {
|
||||
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) throw new Error('No access_token in OAuth response');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveVerifier(verifier: string) {
|
||||
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
|
||||
}
|
||||
|
||||
export function loadVerifier(): string | null {
|
||||
return localStorage.getItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
|
||||
export function clearVerifier() {
|
||||
localStorage.removeItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
127
Frontend/src/lib/save-sync.svelte.ts
Normal file
127
Frontend/src/lib/save-sync.svelte.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// save-sync.ts — Auto-save game state to backend every 30s
|
||||
// Server = authority. NEVER save before server state is loaded (ready guard).
|
||||
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { migrateSave } from '$lib/core/migrateSave';
|
||||
import type { GameState } from '$lib/core/economy';
|
||||
|
||||
const SAVE_INTERVAL_MS = 30_000;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[SaveSync] ${path} failed:`, res.status);
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
let lastSave: string | null = null;
|
||||
let loaded = false;
|
||||
let saveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Snapshot the $state proxy into a plain object for serialization
|
||||
function snapshotState(): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(game.state));
|
||||
}
|
||||
|
||||
export async function saveToServer() {
|
||||
if (!authStore.user || !game.ready) return;
|
||||
const result = await apiRequest('/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
gameState: snapshotState(),
|
||||
playTimeSeconds: game.playSeconds,
|
||||
}),
|
||||
});
|
||||
if (result?.lastSave) {
|
||||
lastSave = result.lastSave;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFromServer(): Promise<boolean> {
|
||||
if (loaded || !authStore.user) {
|
||||
if (!authStore.user) loaded = true;
|
||||
return false;
|
||||
}
|
||||
loaded = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest('/save');
|
||||
if (data?.gameState) {
|
||||
const migrated = migrateSave(data.gameState);
|
||||
game.loadFromServer(migrated);
|
||||
lastSave = data.lastSave;
|
||||
console.info('[SaveSync] Loaded save from server (v%d)', migrated.saveVersion);
|
||||
return true;
|
||||
}
|
||||
console.info('[SaveSync] No server save found');
|
||||
return false;
|
||||
} catch {
|
||||
console.warn('[SaveSync] Server unreachable');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoSave() {
|
||||
stopAutoSave();
|
||||
saveInterval = setInterval(() => {
|
||||
if (authStore.user && game.ready) saveToServer();
|
||||
}, SAVE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopAutoSave() {
|
||||
if (saveInterval) {
|
||||
clearInterval(saveInterval);
|
||||
saveInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupVisibilitySync() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
if (!authStore.user) return;
|
||||
setTimeout(async () => {
|
||||
const data = await apiRequest('/save');
|
||||
if (data?.gameState && data.lastSave) {
|
||||
if (!lastSave || new Date(data.lastSave) > new Date(lastSave)) {
|
||||
const migrated = migrateSave(data.gameState);
|
||||
game.loadFromServer(migrated);
|
||||
lastSave = data.lastSave;
|
||||
console.info('[SaveSync] Reloaded from server on focus');
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
if (authStore.user && game.ready) saveToServer();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (!authStore.user || !game.ready) return;
|
||||
const payload = JSON.stringify({
|
||||
gameState: snapshotState(),
|
||||
playTimeSeconds: game.playSeconds,
|
||||
});
|
||||
fetch(`${BACKEND_URL}/api/save`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
export function resetSaveSync() {
|
||||
loaded = false;
|
||||
lastSave = null;
|
||||
}
|
||||
64
Frontend/src/lib/stores/auth.svelte.ts
Normal file
64
Frontend/src/lib/stores/auth.svelte.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// auth.svelte.ts — Auth store (Svelte 5 runes)
|
||||
// Cookie-based auth with SuperOAuth PKCE
|
||||
|
||||
import { apiFetch } from '$lib/api';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar_url?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await apiFetch('/auth/me');
|
||||
user = data as User;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await refresh();
|
||||
loading = false;
|
||||
|
||||
// Listen for expired session
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('auth:expired', () => {
|
||||
user = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiFetch('/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
user = null;
|
||||
}
|
||||
|
||||
async function editUser(updatedFields: Record<string, unknown>) {
|
||||
if (!user) return;
|
||||
const data = await apiFetch(`/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updatedFields),
|
||||
});
|
||||
if (data?.user) {
|
||||
user = { ...user, ...data.user };
|
||||
}
|
||||
}
|
||||
|
||||
export const authStore = {
|
||||
get user() { return user; },
|
||||
get loading() { return loading; },
|
||||
init,
|
||||
refresh,
|
||||
logout,
|
||||
editUser,
|
||||
};
|
||||
228
Frontend/src/lib/stores/game.svelte.ts
Normal file
228
Frontend/src/lib/stores/game.svelte.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// game.svelte.ts — Game store (Svelte 5 class pattern)
|
||||
// Server = authority. localStorage = fallback guest only.
|
||||
// Pattern: singleton class with $state fields — the officially recommended
|
||||
// Svelte 5 approach for shared reactive state across components.
|
||||
|
||||
import {
|
||||
type GameState,
|
||||
DEFAULT_STATE,
|
||||
applyIdleGains,
|
||||
applyClick,
|
||||
getClickGain,
|
||||
getAutoClicksPerSecond,
|
||||
buyGenerator,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
canResetTree,
|
||||
upgradeConvergence,
|
||||
claimMilestone as claimMilestoneFn,
|
||||
applyPrestige,
|
||||
canPrestige as canPrestigeCheck,
|
||||
totalProductionPerSecond,
|
||||
generatorCost as genCost,
|
||||
computeOfflineGains,
|
||||
buyClickUpgrade as buyClickUpgradeFn,
|
||||
} from '$lib/core/economy';
|
||||
import { migrateSave } from '$lib/core/migrateSave';
|
||||
import { toast } from './toast.svelte';
|
||||
import {
|
||||
computeNewUnlocks,
|
||||
equipCosmetic as equipCosmeticFn,
|
||||
unequipSlot as unequipSlotFn,
|
||||
addToInventory,
|
||||
type CosmeticSlot,
|
||||
} from '$lib/core/cosmetics';
|
||||
|
||||
const SAVE_KEY = 'clickerz_state';
|
||||
const OFFLINE_THRESHOLD = 60_000;
|
||||
|
||||
export interface OfflineReport {
|
||||
wasOffline: boolean;
|
||||
duration: number;
|
||||
gains: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
class Game {
|
||||
// --- Reactive fields ---
|
||||
state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
|
||||
playSeconds = $state(0);
|
||||
ready = $state(false);
|
||||
offlineReport = $state<OfflineReport | null>(null);
|
||||
showPrestigeScreen = $state(false);
|
||||
lastClickGain = $state(0);
|
||||
lastClickDouble = $state(false);
|
||||
lastClickCrit = $state(false);
|
||||
|
||||
// --- Derived (computed live from state) ---
|
||||
get canPrestige() { return canPrestigeCheck(this.state); }
|
||||
get productionPerSecond() { return totalProductionPerSecond(this.state); }
|
||||
get clickGain() { return getClickGain(this.state); }
|
||||
|
||||
// --- Private helpers ---
|
||||
private loadLocalState(): GameState {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
return applyIdleGains(migrateSave(JSON.parse(raw)), Date.now());
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
private saveLocal(s: GameState) {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
|
||||
}
|
||||
|
||||
private hydrateWithOffline(saved: GameState, now: number) {
|
||||
const elapsed = now - saved.lastTick;
|
||||
if (elapsed <= OFFLINE_THRESHOLD) {
|
||||
return { state: { ...applyIdleGains(saved, now), lastOnline: now }, report: null };
|
||||
}
|
||||
const gains = computeOfflineGains(saved, now);
|
||||
const pps = totalProductionPerSecond(saved);
|
||||
const fullGains = pps * (elapsed / 1000);
|
||||
return {
|
||||
state: { ...saved, resources: saved.resources + gains, lifetimeTadpoles: saved.lifetimeTadpoles + gains, lastTick: now, lastOnline: now },
|
||||
report: { wasOffline: true, duration: elapsed, gains, efficiency: fullGains > 0 ? gains / fullGains : 0 } as OfflineReport,
|
||||
};
|
||||
}
|
||||
|
||||
private applyState(updated: GameState) {
|
||||
this.saveLocal(updated);
|
||||
Object.assign(this.state, updated);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
tick() {
|
||||
if (!this.ready) return;
|
||||
const now = Date.now();
|
||||
const updated = { ...applyIdleGains(this.state, now), lastOnline: now };
|
||||
|
||||
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
|
||||
if (autoClicks > 0) {
|
||||
const autoGain = getClickGain(updated) * autoClicks;
|
||||
updated.resources += autoGain;
|
||||
updated.lifetimeTadpoles += autoGain;
|
||||
}
|
||||
|
||||
if (this.playSeconds % 5 === 0) {
|
||||
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
|
||||
const newUnlocks = computeNewUnlocks(updated, cosState);
|
||||
if (newUnlocks.length > 0) {
|
||||
updated.cosmeticInventory = addToInventory(cosState, newUnlocks).inventory;
|
||||
newUnlocks.forEach(() => toast('Nouveau cosmetique debloque !', 'reward'));
|
||||
}
|
||||
}
|
||||
|
||||
this.applyState(updated);
|
||||
this.playSeconds += 1;
|
||||
}
|
||||
|
||||
click() {
|
||||
if (!this.ready) return;
|
||||
const result = applyClick(applyIdleGains(this.state, Date.now()));
|
||||
this.applyState(result.state);
|
||||
this.lastClickGain = result.gain;
|
||||
this.lastClickDouble = result.isDouble;
|
||||
this.lastClickCrit = result.isCrit;
|
||||
}
|
||||
|
||||
buy(genId: string, quantity = 1) {
|
||||
if (!this.ready) return;
|
||||
const updated = buyGenerator(applyIdleGains(this.state, Date.now()), genId, quantity);
|
||||
if (updated) this.applyState(updated);
|
||||
}
|
||||
|
||||
buyClickUpgrade(upgradeId: string) {
|
||||
if (!this.ready) return;
|
||||
const updated = buyClickUpgradeFn(this.state, upgradeId);
|
||||
if (updated) this.applyState(updated);
|
||||
}
|
||||
|
||||
buyNode(nodeId: string) {
|
||||
if (!this.ready) return;
|
||||
const updated = buyEvolutionNode(this.state, nodeId);
|
||||
if (!updated) return;
|
||||
const node = updated.evolutionTree.find((n) => n.id === nodeId);
|
||||
if (node?.capstone) toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
|
||||
this.applyState(updated);
|
||||
}
|
||||
|
||||
prestige() {
|
||||
if (!this.ready || !canPrestigeCheck(this.state)) return;
|
||||
const updated = applyPrestige(this.state);
|
||||
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
|
||||
this.applyState(updated);
|
||||
this.showPrestigeScreen = false;
|
||||
}
|
||||
|
||||
equipCosmetic(cosmeticId: string) {
|
||||
if (!this.ready) return;
|
||||
const cosState = { inventory: this.state.cosmeticInventory, equipped: this.state.cosmeticEquipped };
|
||||
this.state.cosmeticEquipped = equipCosmeticFn(cosState, cosmeticId).equipped;
|
||||
this.saveLocal(this.state);
|
||||
}
|
||||
|
||||
unequipCosmetic(slot: CosmeticSlot) {
|
||||
if (!this.ready) return;
|
||||
const cosState = { inventory: this.state.cosmeticInventory, equipped: this.state.cosmeticEquipped };
|
||||
this.state.cosmeticEquipped = unequipSlotFn(cosState, slot).equipped;
|
||||
this.saveLocal(this.state);
|
||||
}
|
||||
|
||||
resetTree() {
|
||||
if (!this.ready || !canResetTree(this.state)) return;
|
||||
this.applyState(resetEvolutionTree(this.state));
|
||||
}
|
||||
|
||||
upgradeConvergence() {
|
||||
if (!this.ready) return;
|
||||
const updated = upgradeConvergence(this.state);
|
||||
if (updated) this.applyState(updated);
|
||||
}
|
||||
|
||||
claimMilestone(milestoneId: string) {
|
||||
if (!this.ready) return;
|
||||
const updated = claimMilestoneFn(this.state, milestoneId);
|
||||
if (!updated) return;
|
||||
toast('Milestone debloque !', 'reward', 4000);
|
||||
this.applyState(updated);
|
||||
}
|
||||
|
||||
reset() {
|
||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
this.applyState(fresh);
|
||||
this.playSeconds = 0;
|
||||
this.ready = true;
|
||||
this.offlineReport = null;
|
||||
}
|
||||
|
||||
loadFromServer(serverState: GameState) {
|
||||
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
|
||||
const result = this.hydrateWithOffline(migrated, Date.now());
|
||||
this.applyState(result.state);
|
||||
this.ready = true;
|
||||
this.offlineReport = result.report;
|
||||
}
|
||||
|
||||
initGuest() {
|
||||
const local = this.loadLocalState();
|
||||
const result = this.hydrateWithOffline(local, Date.now());
|
||||
this.applyState(result.state);
|
||||
this.ready = true;
|
||||
this.offlineReport = result.report;
|
||||
}
|
||||
|
||||
dismissOfflineReport() { this.offlineReport = null; }
|
||||
openPrestige() { this.showPrestigeScreen = true; }
|
||||
closePrestige() { this.showPrestigeScreen = false; }
|
||||
|
||||
generatorCost = genCost;
|
||||
generatorCostWithTree(gen: Parameters<typeof genCost>[0]) {
|
||||
return genCost(gen, this.state.evolutionTree);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton — import { game } from '$lib/stores/game.svelte';
|
||||
export const game = new Game();
|
||||
33
Frontend/src/lib/stores/toast.svelte.ts
Normal file
33
Frontend/src/lib/stores/toast.svelte.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// toast.svelte.ts — Toast notification system (Svelte 5 runes)
|
||||
|
||||
export type ToastVariant = 'success' | 'info' | 'reward' | 'warning';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
export function getToasts() {
|
||||
return toasts;
|
||||
}
|
||||
|
||||
export function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { id, message, variant, duration }];
|
||||
setTimeout(() => {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
export function removeToast(id: number) {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
// Shorthand — usable from anywhere (stores, lib, components)
|
||||
export const toast = addToast;
|
||||
@@ -1,67 +0,0 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import Landing from "./pages/Landing";
|
||||
import Home from "./pages/Home";
|
||||
import ErrorPage from "./pages/404";
|
||||
import Login from "./pages/Login";
|
||||
import AuthCallback from "./pages/AuthCallback";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import Achievements from "./pages/Achievements";
|
||||
import Settings from "./pages/Settings";
|
||||
import Legal from "./pages/Legal";
|
||||
import Cookie from "./pages/Cookie";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <Landing />,
|
||||
},
|
||||
{
|
||||
path: "/jeu",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/achievements",
|
||||
element: <Achievements />,
|
||||
},
|
||||
{
|
||||
path: "/mentionslegales",
|
||||
element: <Legal />,
|
||||
},
|
||||
{
|
||||
path: "/cookies",
|
||||
element: <Cookie />,
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
element: <AuthCallback />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <ErrorPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
|
||||
root.render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
import { getClickGain } from "../core/economy";
|
||||
import { GeneratorShop } from "../components/GeneratorShop";
|
||||
import { PrestigePanel } from "../components/PrestigePanel";
|
||||
import { EvolutionTree } from "../components/EvolutionTree";
|
||||
import { MilestoneBar } from "../components/MilestoneBar";
|
||||
import { CockpitHeader } from "../components/CockpitHeader";
|
||||
import { TadpoleSprite } from "../components/TadpoleSprite";
|
||||
import { CosmeticsPanel } from "../components/CosmeticsPanel";
|
||||
import { PrestigeScreen } from "../components/PrestigeScreen";
|
||||
import { MilestonesPanel } from "../components/MilestonesPanel";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
|
||||
export default function Home() {
|
||||
const [toggleRain] = useOutletContext();
|
||||
const ready = useGameStore((s) => s.ready);
|
||||
const click = useGameStore((s) => s.click);
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
const state = useGameStore((s) => s.state);
|
||||
const clickGain = getClickGain(state);
|
||||
|
||||
const lastClickGain = useGameStore((s) => s.lastClickGain);
|
||||
const lastClickDouble = useGameStore((s) => s.lastClickDouble);
|
||||
const lastClickCrit = useGameStore((s) => s.lastClickCrit);
|
||||
|
||||
const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => {
|
||||
const particle = document.createElement("span");
|
||||
particle.className = "click-particle";
|
||||
const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : "";
|
||||
particle.textContent = `${prefix}+${formatNumber(gain)}`;
|
||||
if (isCrit) particle.style.color = "#f59e0b";
|
||||
else if (isDouble) particle.style.color = "#a78bfa";
|
||||
particle.style.left = `${clientX}px`;
|
||||
particle.style.top = `${clientY}px`;
|
||||
document.body.appendChild(particle);
|
||||
setTimeout(() => {
|
||||
if (particle.parentNode) particle.parentNode.removeChild(particle);
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
const handleIncrement = useCallback((e) => {
|
||||
click();
|
||||
// Read latest click result from store after click
|
||||
const s = useGameStore.getState();
|
||||
createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit);
|
||||
}, [click, createParticle]);
|
||||
|
||||
// Rain effect (ambiance)
|
||||
useEffect(() => {
|
||||
const rain = {
|
||||
wind: 0, maxXrange: 40, minXrange: 20, maxSpeed: 1, minSpeed: 3,
|
||||
color: "#8ecae6", char: "°", maxSize: 28, minSize: 8,
|
||||
flakes: [], WIDTH: -10, HEIGHT: 0, running: false,
|
||||
init(nb) {
|
||||
const frag = document.createDocumentFragment();
|
||||
this.getSize();
|
||||
this.running = true;
|
||||
for (let i = 0; i < nb; i++) {
|
||||
const flake = {
|
||||
x: this.random(this.WIDTH), y: -this.maxSize,
|
||||
xrange: this.minXrange + this.random(this.maxXrange - this.minXrange),
|
||||
yspeed: this.minSpeed + this.random(this.maxSpeed - this.minSpeed, 100),
|
||||
life: 0, size: this.minSize + this.random(this.maxSize - this.minSize),
|
||||
html: document.createElement("span"),
|
||||
};
|
||||
Object.assign(flake.html.style, {
|
||||
position: "absolute", top: `${flake.y}px`, left: `${flake.x}px`,
|
||||
fontSize: `${flake.size}px`, color: this.color, userSelect: "none", overflow: "hidden",
|
||||
});
|
||||
flake.html.appendChild(document.createTextNode(this.char));
|
||||
frag.appendChild(flake.html);
|
||||
this.flakes.push(flake);
|
||||
}
|
||||
document.body.appendChild(frag);
|
||||
this.animate();
|
||||
window.onresize = () => this.getSize();
|
||||
},
|
||||
animate() {
|
||||
if (!this.running) return;
|
||||
for (const flake of this.flakes) {
|
||||
const top = flake.y + flake.yspeed;
|
||||
const left = flake.x + Math.sin(flake.life) * flake.xrange + this.wind;
|
||||
if (top < this.HEIGHT - flake.size - 10 && left < this.WIDTH - flake.size && left > 0) {
|
||||
flake.html.style.top = `${top}px`;
|
||||
flake.html.style.left = `${left}px`;
|
||||
flake.y = top;
|
||||
flake.x += this.wind;
|
||||
flake.life += 0.01;
|
||||
} else {
|
||||
flake.html.style.top = `${-this.maxSize}px`;
|
||||
flake.x = this.random(this.WIDTH);
|
||||
flake.y = -this.maxSize;
|
||||
flake.html.style.left = `${flake.x}px`;
|
||||
flake.life = 0;
|
||||
}
|
||||
}
|
||||
setTimeout(() => this.animate(), 20);
|
||||
},
|
||||
stop() {
|
||||
this.running = false;
|
||||
for (const flake of this.flakes) {
|
||||
if (flake.html.parentNode) flake.html.parentNode.removeChild(flake.html);
|
||||
}
|
||||
this.flakes = [];
|
||||
},
|
||||
random(range, num = 1) {
|
||||
return Math.floor(Math.random() * (range + 1) * num) / num;
|
||||
},
|
||||
getSize() {
|
||||
this.WIDTH = document.body.clientWidth || window.innerWidth;
|
||||
this.HEIGHT = document.body.clientHeight || window.innerHeight;
|
||||
},
|
||||
};
|
||||
|
||||
if (toggleRain) rain.init(10);
|
||||
return () => rain.stop();
|
||||
}, [toggleRain]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<section className="game-container">
|
||||
<p className="text-center text-slate-400 mt-[20vh]">
|
||||
Chargement de ta progression...
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="zone" data-zone="swamp">
|
||||
<Helmet>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</Helmet>
|
||||
|
||||
<PrestigeScreen />
|
||||
|
||||
{/* Clicker area — centre */}
|
||||
<div className="click-zone" onClick={handleIncrement}>
|
||||
<TadpoleSprite />
|
||||
<div className="click-zone-counter">
|
||||
{formatNumber(resources)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cockpit — sidebar structurée en zones */}
|
||||
<aside className="game-sidebar">
|
||||
<CockpitHeader />
|
||||
<div className="gp-sep" />
|
||||
<MilestoneBar />
|
||||
<GeneratorShop />
|
||||
<div className="gp-sep" />
|
||||
<PrestigePanel />
|
||||
<MilestonesPanel />
|
||||
<EvolutionTree />
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" className="achieve-badge">
|
||||
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succès
|
||||
</a>
|
||||
</aside>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Clickerz — Clicker idle dans le Tetard Universe. Fais éclore des têtards, évolue et domine le marais !"
|
||||
/>
|
||||
</Helmet>
|
||||
<main className="zone mt-20 flex flex-col items-center justify-center gap-6" data-zone="landing">
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Têtard Clickerz"
|
||||
className="w-40 h-40 md:w-52 md:h-52 drop-shadow-lg animate-bounce"
|
||||
style={{ animationDuration: "3s" }}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3 text-center px-4">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-800 font-[var(--font)]">
|
||||
Clickerz
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-gray-600 font-[var(--font)] max-w-md">
|
||||
Fais éclore des têtards, construis ton empire et domine le marais.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/jeu"
|
||||
className="px-8 py-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-lg font-semibold font-[var(--font)] transition-all hover:scale-105 shadow-lg shadow-emerald-600/30"
|
||||
>
|
||||
Entrer dans le Marais
|
||||
</Link>
|
||||
|
||||
<p className="text-sm text-gray-500 font-[var(--font)]">
|
||||
Pas de compte requis — joue en mode invité
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
30
Frontend/src/routes/+error.svelte
Normal file
30
Frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>404 — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<div class="containererror">
|
||||
<div in:scale={{ duration: 500, start: 0.7, easing: backOut }}>
|
||||
<img src="/svg/tadpole.svg" alt="" class="w-28 h-28 mx-auto opacity-30" />
|
||||
</div>
|
||||
<h1 in:fly={{ y: 20, delay: 100, duration: 400, easing: quintOut }}>
|
||||
{page.status}
|
||||
</h1>
|
||||
<p class="message" in:fly={{ y: 15, delay: 200, duration: 400, easing: quintOut }}>
|
||||
{page.error?.message || 'Ce tetard s\'est perdu dans le marais.'}
|
||||
</p>
|
||||
<a
|
||||
class="btn-return"
|
||||
href="/"
|
||||
in:fly={{ y: 15, delay: 300, duration: 400, easing: quintOut }}
|
||||
>
|
||||
Retour au Marais
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
26
Frontend/src/routes/+layout.svelte
Normal file
26
Frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
import GameTick from '$lib/components/GameTick.svelte';
|
||||
import GameSync from '$lib/components/GameSync.svelte';
|
||||
import OfflineReport from '$lib/components/OfflineReport.svelte';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href="/svg/tadpole.svg" />
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</svelte:head>
|
||||
|
||||
<GameTick />
|
||||
<GameSync />
|
||||
<OfflineReport />
|
||||
<ToastContainer />
|
||||
<Navbar />
|
||||
<main style="min-height: 92vh; margin-top: 80px; padding: 0 0 2rem; background-color: var(--bg-color);">
|
||||
{@render children()}
|
||||
</main>
|
||||
<Footer />
|
||||
2
Frontend/src/routes/+layout.ts
Normal file
2
Frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const prerender = false;
|
||||
export const ssr = false;
|
||||
42
Frontend/src/routes/+page.svelte
Normal file
42
Frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { backOut, quintOut } from 'svelte/easing';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="zone" data-zone="landing">
|
||||
<div class="flex flex-col items-center gap-8 text-center" style="font-family: var(--font);">
|
||||
<div in:scale={{ duration: 600, start: 0.6, easing: backOut }}>
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Clickerz"
|
||||
class="w-48 h-48"
|
||||
style="filter: drop-shadow(0 0 30px rgba(52,211,153,0.2));"
|
||||
/>
|
||||
</div>
|
||||
<h1
|
||||
class="text-4xl font-extrabold"
|
||||
style="color: var(--color-grey);"
|
||||
in:fly={{ y: 30, delay: 150, duration: 500, easing: quintOut }}
|
||||
>
|
||||
Clickerz
|
||||
</h1>
|
||||
<p
|
||||
class="text-lg max-w-md"
|
||||
style="color: var(--color-grey); opacity: 0.7;"
|
||||
in:fly={{ y: 20, delay: 300, duration: 500, easing: quintOut }}
|
||||
>
|
||||
Clicker idle dans le Tetard Universe. Fais eclore des tetards, evolue, prestige.
|
||||
</p>
|
||||
<div in:scale={{ delay: 450, duration: 400, start: 0.8, easing: backOut }}>
|
||||
<button class="primary-button text-lg! px-8! py-3!" onclick={() => goto('/jeu')}>
|
||||
Jouer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
100
Frontend/src/routes/achievements/+page.svelte
Normal file
100
Frontend/src/routes/achievements/+page.svelte
Normal file
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, backOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { ACHIEVEMENTS } from '$lib/data/achievements';
|
||||
|
||||
let filter = $state<'all' | 'unlocked' | 'locked'>('all');
|
||||
|
||||
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(game.state)));
|
||||
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(game.state)));
|
||||
|
||||
let displayed = $derived(
|
||||
filter === 'unlocked' ? unlocked
|
||||
: filter === 'locked' ? locked
|
||||
: [...unlocked, ...locked]
|
||||
);
|
||||
|
||||
let progressPct = $derived(Math.round((unlocked.length / ACHIEVEMENTS.length) * 100));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Succes — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fullachieve">
|
||||
<!-- Header -->
|
||||
<div in:fly={{ y: -20, duration: 400, easing: quintOut }}>
|
||||
<h1>Succes</h1>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="max-w-xs mx-auto w-full mb-6"
|
||||
in:scale={{ delay: 100, duration: 300, start: 0.9 }}
|
||||
>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="achieve-counter mb-0!">{unlocked.length} / {ACHIEVEMENTS.length}</span>
|
||||
<span class="achieve-counter mb-0!">{progressPct}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full overflow-hidden" style="background: rgba(0,0,0,0.1);">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700 ease-out"
|
||||
style="width: {progressPct}%; background: linear-gradient(90deg, #059669, #34d399);"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div
|
||||
class="flex gap-1 justify-center mb-6"
|
||||
in:fade={{ delay: 200, duration: 300 }}
|
||||
>
|
||||
{#each [
|
||||
{ id: 'all', label: `Tous (${ACHIEVEMENTS.length})` },
|
||||
{ id: 'unlocked', label: `Debloques (${unlocked.length})` },
|
||||
{ id: 'locked', label: `Verrouilles (${locked.length})` },
|
||||
] as tab}
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
style="
|
||||
font-family: var(--font);
|
||||
{filter === tab.id
|
||||
? 'background: var(--color-grey); color: white;'
|
||||
: 'background: rgba(0,0,0,0.06); color: var(--color-grey); opacity: 0.7;'
|
||||
}
|
||||
"
|
||||
onclick={() => filter = tab.id as typeof filter}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
{#key filter}
|
||||
<div class="achievementscardcontainer">
|
||||
{#each displayed as a, i}
|
||||
{@const isUnlocked = unlocked.includes(a)}
|
||||
<div
|
||||
class="achieve-card {isUnlocked ? 'achieve-unlocked' : 'achieve-locked'}"
|
||||
in:fly={{ y: 20, delay: Math.min(i * 40, 400), duration: 300, easing: quintOut }}
|
||||
>
|
||||
<span class="achieve-icon">{isUnlocked ? a.icon : '🔒'}</span>
|
||||
<div class="achieve-info">
|
||||
<p class="achieve-name">{a.name}</p>
|
||||
<p class="achieve-desc">{isUnlocked ? a.description : '???'}</p>
|
||||
</div>
|
||||
{#if isUnlocked}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full shrink-0"
|
||||
style="background: rgba(16,185,129,0.15); color: #34d399; font-family: var(--font);"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
56
Frontend/src/routes/callback/+page.svelte
Normal file
56
Frontend/src/routes/callback/+page.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { exchangeCode, loadVerifier, clearVerifier } from '$lib/oauth';
|
||||
import { apiFetch } from '$lib/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const err = params.get('error');
|
||||
|
||||
if (err) { error = err; return; }
|
||||
if (!code) { error = "Code manquant dans l'URL."; return; }
|
||||
|
||||
const verifier = loadVerifier();
|
||||
if (!verifier) { error = 'Verifier PKCE manquant — reessaie la connexion.'; return; }
|
||||
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCode(code, verifier, redirectUri);
|
||||
clearVerifier();
|
||||
localStorage.setItem('clkz_oauth_token', tokens.access_token);
|
||||
|
||||
await apiFetch('/auth/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token: tokens.access_token, refreshToken: tokens.refresh_token }),
|
||||
});
|
||||
|
||||
await authStore.refresh();
|
||||
goto('/', { replaceState: true });
|
||||
} catch (e: any) {
|
||||
clearVerifier();
|
||||
error = e.message || 'Erreur de connexion.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Connexion... — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<div class="containererror">
|
||||
{#if error}
|
||||
<h1>Erreur de connexion</h1>
|
||||
<p class="message">{error}</p>
|
||||
<a class="btn-return" href="/login">Retour au login</a>
|
||||
{:else}
|
||||
<p class="message">Connexion en cours...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
15
Frontend/src/routes/cookies/+page.svelte
Normal file
15
Frontend/src/routes/cookies/+page.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<svelte:head>
|
||||
<title>Cookies — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey);">
|
||||
<h1>Politique des Cookies</h1>
|
||||
<div class="content">
|
||||
<p class="paragraphe">Clickerz utilise des cookies strictement necessaires au fonctionnement du jeu :</p>
|
||||
<ul class="paragraphe" style="margin-left: 1.5rem;">
|
||||
<li>Session d'authentification (httpOnly, secure)</li>
|
||||
<li>Sauvegarde locale (localStorage — fallback invite)</li>
|
||||
</ul>
|
||||
<p class="paragraphe">Aucun cookie de tracking ou publicitaire n'est utilise.</p>
|
||||
</div>
|
||||
</div>
|
||||
185
Frontend/src/routes/guide/+page.svelte
Normal file
185
Frontend/src/routes/guide/+page.svelte
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import { fly, slide, fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
interface Section {
|
||||
icon: string;
|
||||
title: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
icon: '🏞',
|
||||
title: 'Le Marais',
|
||||
content: [
|
||||
'Tu es le **Gardien du Marais**. Les tetards naissent sous tes clics, grandissent grace a tes generateurs, et evoluent a chaque nouvelle generation.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'Boucle de jeu',
|
||||
content: [
|
||||
'**1. Clique** pour pondre des tetards. Achete des **generateurs** pour produire automatiquement.',
|
||||
'**2. Prestige** quand tu atteins le seuil de tetards. Reset tetards et generateurs, mais gagne de l\'**ADN Ancestral** + un multiplicateur permanent.',
|
||||
'**3. Arbre d\'Evolution** — depense ton ADN dans 3 branches pour booster ta production, tes clics, et ta progression.',
|
||||
'**4. Repete** — chaque prestige est plus rapide grace aux bonus accumules. Le seuil monte pour garder le challenge.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '👆',
|
||||
title: 'Ponte (clic)',
|
||||
content: [
|
||||
'Chaque clic rapporte : **base × prestige × arbre**. Le panneau "Ponte" dans Production te montre le breakdown complet.',
|
||||
'**Double Ponte** — chance de doubler le gain (branche Ponte, 5 ADN)',
|
||||
'**Ponte Critique** — chance de ×10 (branche Ponte, 20 ADN)',
|
||||
'**Auto-Ponte** — clics automatiques par seconde (capstone Ponte, 200 ADN). Scale avec les repeatables.',
|
||||
'Le panneau montre aussi les gains auto-ponte/s — ta production passive par les clics.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🏭',
|
||||
title: 'Generateurs',
|
||||
content: [
|
||||
'5 generateurs (Nid → Lac Mystique). Chaque unite produit des tetards/s automatiquement.',
|
||||
'Le cockpit Production montre la **production effective** (avec tous les bonus) et le **+X/s** que le prochain achat ajoute.',
|
||||
'**Achat multiple** — x1, x5, x10, ou MAX. Le cout et la quantite s\'affichent sur le bouton.',
|
||||
'La **barre de part** montre quelle fraction de ta production vient de chaque type.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🧬',
|
||||
title: 'Prestige',
|
||||
content: [
|
||||
'Le prestige reset tes tetards et generateurs. Tu gagnes de l\'**ADN Ancestral** et un multiplicateur permanent (×0.1 par prestige).',
|
||||
'Le **seuil augmente** a chaque prestige : 1M × (1 + 0.1 × N)². Plus tu prestiges, plus il faut de tetards.',
|
||||
'L\'arbre et les cosmetiques sont **conserves**. Chaque prestige offre **1 reset d\'arbre gratuit**.',
|
||||
'Les noeuds Adaptation peuvent **reduire le seuil** — strategique pour prestige plus souvent.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🌳',
|
||||
title: 'Arbre d\'Evolution',
|
||||
content: [
|
||||
'3 branches + Convergence. Depense ton ADN pour debloquer des noeuds permanents.',
|
||||
'**Ponte** — booste tes clics : multiplicateur, double ponte, critique, auto-ponte',
|
||||
'**Marais** — booste la production : multiplicateur, Nid boost, synergie entre types',
|
||||
'**Adaptation** — booste la progression : bonus ADN, offline, reduction seuil prestige',
|
||||
'Chaque branche a un **capstone** (noeud final puissant) + des **post-capstones** repeatables a l\'infini.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '★',
|
||||
title: 'Capstones & Convergence',
|
||||
content: [
|
||||
'**Ponte Automatique** — auto-clic 1/s qui scale avec les upgrades',
|
||||
'**Symbiose Totale** — chaque type de generateur booste les autres',
|
||||
'**Memoire du Marais** — offline cap a 75%, duree 8h',
|
||||
'**Convergence Alpha** (1 capstone + tier 3 d\'une 2e branche) → +10% tous effets',
|
||||
'**Convergence Omega** (2 capstones) → +10% tous effets + -20% cout post-capstones',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🏆',
|
||||
title: 'Milestones',
|
||||
content: [
|
||||
'8 paliers de prestige (1 a 100). Recompenses :',
|
||||
'1 → Ruban queue | 3 → Titre | 5 → 1 Nid gratuit | 10 → Couronne',
|
||||
'15 → +5% offline | 25 → Cape | 50 → Queue enflamee | 100 → Skin Primordial',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '✨',
|
||||
title: 'Cosmetiques',
|
||||
content: [
|
||||
'Purement visuels — **zero pay-to-win**. 5 slots : chapeau, yeux, corps, queue, accessoire.',
|
||||
'Debloques via achievements et milestones prestige.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🌙',
|
||||
title: 'Offline',
|
||||
content: [
|
||||
'Le marais continue de produire quand tu fermes le jeu.',
|
||||
'Efficacite : 100% (0-15min) → degressive → 0% a 2h.',
|
||||
'Les noeuds Adaptation et milestones augmentent le cap et la duree.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔃',
|
||||
title: 'Reset d\'arbre',
|
||||
content: [
|
||||
'Reinitialise ton arbre pour tester d\'autres builds.',
|
||||
'**1 gratuit par prestige**, puis 5 ADN par reset supplementaire. L\'ADN investi est rembourse.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let openSections = $state<Set<number>>(new Set([0, 1]));
|
||||
|
||||
function toggle(idx: number) {
|
||||
const next = new Set(openSections);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
openSections = next;
|
||||
}
|
||||
|
||||
function renderBold(text: string): string {
|
||||
return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Guide du Gardien — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey); max-width: 720px;">
|
||||
<h1 in:fly={{ y: -20, duration: 400, easing: quintOut }}>Guide du Gardien</h1>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each sections as section, i}
|
||||
<div
|
||||
class="rounded-xl overflow-hidden transition-all duration-200"
|
||||
style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06);"
|
||||
in:fly={{ y: 15, delay: i * 60, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<button
|
||||
class="flex items-center gap-3 w-full px-5 py-3.5 cursor-pointer text-left group"
|
||||
style="font-family: var(--font);"
|
||||
onclick={() => toggle(i)}
|
||||
aria-expanded={openSections.has(i)}
|
||||
>
|
||||
<span class="text-xl">{section.icon}</span>
|
||||
<span class="text-base font-semibold flex-1" style="color: var(--color-grey);">
|
||||
{section.title}
|
||||
</span>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200 opacity-40 group-hover:opacity-70"
|
||||
class:rotate-180={openSections.has(i)}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
{#if openSections.has(i)}
|
||||
<div transition:slide={{ duration: 250, easing: quintOut }}>
|
||||
<div class="flex flex-col gap-2 px-5 pb-4 pl-14">
|
||||
{#each section.content as line}
|
||||
<p
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--color-grey); opacity: 0.8; font-family: var(--font);"
|
||||
>
|
||||
{@html renderBold(line)}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
159
Frontend/src/routes/jeu/+page.svelte
Normal file
159
Frontend/src/routes/jeu/+page.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, elasticOut } from 'svelte/easing';
|
||||
import { game } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CockpitHeader from '$lib/components/CockpitHeader.svelte';
|
||||
import GeneratorShop from '$lib/components/GeneratorShop.svelte';
|
||||
import ClickPanel from '$lib/components/ClickPanel.svelte';
|
||||
import PrestigePanel from '$lib/components/PrestigePanel.svelte';
|
||||
import EvolutionTree from '$lib/components/EvolutionTree.svelte';
|
||||
import MilestoneBar from '$lib/components/MilestoneBar.svelte';
|
||||
import MilestonesPanel from '$lib/components/MilestonesPanel.svelte';
|
||||
import CosmeticsPanel from '$lib/components/CosmeticsPanel.svelte';
|
||||
import TadpoleSprite from '$lib/components/TadpoleSprite.svelte';
|
||||
import PrestigeScreen from '$lib/components/PrestigeScreen.svelte';
|
||||
import ClickParticles from '$lib/components/ClickParticles.svelte';
|
||||
import SidebarTabs from '$lib/components/SidebarTabs.svelte';
|
||||
import { ACHIEVEMENTS } from '$lib/data/achievements';
|
||||
|
||||
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(game.state)).length);
|
||||
|
||||
const sidebarTabs = [
|
||||
{ id: 'production', label: 'Production', icon: '🏭' },
|
||||
{ id: 'evolution', label: 'Evolution', icon: '🧬' },
|
||||
{ id: 'collection', label: 'Collection', icon: '✨' },
|
||||
];
|
||||
|
||||
// Component refs for imperative calls
|
||||
let clickParticles: ReturnType<typeof ClickParticles>;
|
||||
let tadpoleSprite: ReturnType<typeof TadpoleSprite>;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
game.click();
|
||||
clickParticles?.spawn(e.clientX, e.clientY, game.lastClickGain, game.lastClickDouble, game.lastClickCrit);
|
||||
tadpoleSprite?.bounce();
|
||||
}
|
||||
|
||||
// Mobile sidebar toggle
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
</svelte:head>
|
||||
|
||||
{#if !game.ready}
|
||||
<div class="flex items-center justify-center min-h-[80vh]" in:fade>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-16 h-16 border-4 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin"></div>
|
||||
<p class="text-slate-400" style="font-family: var(--font);">Chargement de ta progression...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="zone" data-zone="swamp" in:fade={{ duration: 400 }}>
|
||||
<PrestigeScreen />
|
||||
<ClickParticles bind:this={clickParticles} />
|
||||
|
||||
<!-- Click zone -->
|
||||
<div class="click-zone" onclick={handleClick}>
|
||||
<div in:scale={{ duration: 500, start: 0.8, easing: elasticOut }}>
|
||||
<TadpoleSprite bind:this={tadpoleSprite} />
|
||||
</div>
|
||||
<div
|
||||
class="click-zone-counter"
|
||||
in:fly={{ y: 20, duration: 400, easing: quintOut }}
|
||||
>
|
||||
{formatNumber(game.state.resources)}
|
||||
</div>
|
||||
<span
|
||||
class="text-sm font-semibold pointer-events-none"
|
||||
style="color: rgba(255,255,255,0.5); font-family: var(--font); text-shadow: 0 1px 4px rgba(0,0,0,0.6);"
|
||||
>
|
||||
+{formatNumber(game.clickGain)} / clic
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="game-sidebar hidden md:flex" in:fly={{ x: 100, duration: 500, easing: quintOut }}>
|
||||
<!-- Pinned: always visible -->
|
||||
<CockpitHeader />
|
||||
<MilestoneBar />
|
||||
|
||||
<!-- Tabbed content -->
|
||||
<SidebarTabs tabs={sidebarTabs}>
|
||||
{#snippet children(activeTab)}
|
||||
{#if activeTab === 'production'}
|
||||
<ClickPanel />
|
||||
<GeneratorShop />
|
||||
<PrestigePanel />
|
||||
{:else if activeTab === 'evolution'}
|
||||
<EvolutionTree />
|
||||
<MilestonesPanel />
|
||||
{:else if activeTab === 'collection'}
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" class="achieve-badge">
|
||||
{achieveCount}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
<a href="/guide" class="achieve-badge" style="border-color: rgba(139, 92, 246, 0.2); background: rgba(139, 92, 246, 0.08); color: #a78bfa;">
|
||||
Guide du Gardien
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarTabs>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom bar: toggle button -->
|
||||
<button
|
||||
class="md:hidden fixed bottom-4 right-4 z-30 w-14 h-14 rounded-full flex items-center justify-center shadow-xl"
|
||||
style="background: rgba(17,17,17,0.9); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1);"
|
||||
onclick={() => sidebarOpen = !sidebarOpen}
|
||||
>
|
||||
<span class="text-2xl">{sidebarOpen ? '✕' : '🎮'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
{#if sidebarOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="md:hidden fixed inset-0 z-20"
|
||||
style="background: rgba(0,0,0,0.5);"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={() => sidebarOpen = false}
|
||||
></div>
|
||||
<div
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 z-25 flex flex-col gap-3 max-h-[75vh] overflow-y-auto rounded-t-2xl p-4 pb-20"
|
||||
style="background: rgba(10,10,10,0.95); backdrop-filter: blur(12px); border-top: 1px solid rgba(255,255,255,0.08);"
|
||||
transition:fly={{ y: 300, duration: 350, easing: quintOut }}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-10 h-1 rounded-full" style="background: rgba(255,255,255,0.2);"></div>
|
||||
</div>
|
||||
|
||||
<CockpitHeader />
|
||||
<MilestoneBar />
|
||||
|
||||
<SidebarTabs tabs={sidebarTabs}>
|
||||
{#snippet children(activeTab)}
|
||||
{#if activeTab === 'production'}
|
||||
<GeneratorShop />
|
||||
<PrestigePanel />
|
||||
{:else if activeTab === 'evolution'}
|
||||
<EvolutionTree />
|
||||
<MilestonesPanel />
|
||||
{:else if activeTab === 'collection'}
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" class="achieve-badge">
|
||||
{achieveCount}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarTabs>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
54
Frontend/src/routes/login/+page.svelte
Normal file
54
Frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { quintOut, backOut } from 'svelte/easing';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { buildAuthUrl, saveVerifier } from '$lib/oauth';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: 'discord', label: 'Discord', emoji: '🎮', color: '#5865F2' },
|
||||
{ id: 'github', label: 'GitHub', emoji: '🐙', color: '#333' },
|
||||
{ id: 'google', label: 'Google', emoji: '🌐', color: '#4285f4' },
|
||||
{ id: 'twitch', label: 'Twitch', emoji: '🎬', color: '#9146FF' },
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.user) goto('/', { replaceState: true });
|
||||
});
|
||||
|
||||
async function handleLogin(provider: string) {
|
||||
const redirectUri = `${window.location.origin}/callback`;
|
||||
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
|
||||
saveVerifier(verifier);
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Connexion — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<div class="containererror">
|
||||
<div in:scale={{ duration: 400, start: 0.7, easing: backOut }}>
|
||||
<img src="/svg/tadpole.svg" alt="" class="w-24 h-24 mx-auto mb-2 opacity-60" />
|
||||
</div>
|
||||
<h1 in:fly={{ y: 20, delay: 100, duration: 400, easing: quintOut }}>Connexion</h1>
|
||||
<p class="message" in:fly={{ y: 15, delay: 200, duration: 400, easing: quintOut }}>
|
||||
Connecte-toi pour sauvegarder ta progression.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 mt-6 w-full max-w-xs mx-auto">
|
||||
{#each PROVIDERS as p, i}
|
||||
<button
|
||||
class="btn-return flex items-center justify-center gap-2 py-3! text-base!"
|
||||
onclick={() => handleLogin(p.id)}
|
||||
type="button"
|
||||
in:fly={{ y: 20, delay: 300 + i * 80, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<span class="text-xl">{p.emoji}</span>
|
||||
Continuer avec {p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
11
Frontend/src/routes/mentionslegales/+page.svelte
Normal file
11
Frontend/src/routes/mentionslegales/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<svelte:head>
|
||||
<title>Mentions Legales — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey);">
|
||||
<h1>Mentions Legales</h1>
|
||||
<div class="content">
|
||||
<p class="paragraphe">Clickerz est un projet personnel developpe par Tetardtek.</p>
|
||||
<p class="paragraphe">Hebergement : VPS auto-gere.</p>
|
||||
</div>
|
||||
</div>
|
||||
172
Frontend/src/routes/settings/+page.svelte
Normal file
172
Frontend/src/routes/settings/+page.svelte
Normal 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
Reference in New Issue
Block a user