feat: migrate frontend React 18 → Svelte 5 + SvelteKit
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s
Core logic portable (economy, balance, cosmetics, migrateSave) — zero rewrite. 136 tests green, identiques. Backend inchangé. - Svelte 5 runes stores (game, auth, toast) remplacent Zustand - SvelteKit adapter-static SPA (dist/ output, fallback index.html) - Tailwind v4 conservé, design system .gp-* porté - Transitions natives : slide, fly, scale, fade sur toute l'UI - Sidebar tabbée (Production/Evolution/Collection) + CollapsiblePanel - Mobile bottom sheet avec FAB toggle + backdrop blur - Click particles réactifs Svelte (plus de DOM impératif) - TadpoleSprite bounce + glow ring au clic - Guide refait en accordéon, Achievements avec filtres - a11y : focus-visible, Escape modals, aria-current, aria-labels - CI/CD adapté (tests + build + rsync) - Build 504K (vs ~1.2MB React)
This commit is contained in:
@@ -42,11 +42,16 @@ 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
|
||||
|
||||
@@ -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,35 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import Navbar from "./components/navbar";
|
||||
import Footer from "./components/footer";
|
||||
import { GameTick } from "./components/GameTick";
|
||||
import { GameSync } from "./components/GameSync";
|
||||
import { OfflineReport } from "./components/OfflineReport";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
|
||||
import navData from "./data/NavBarData.json";
|
||||
|
||||
function App() {
|
||||
const [toggleRain, setToggleRain] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameTick />
|
||||
<GameSync />
|
||||
<OfflineReport />
|
||||
<ToastContainer />
|
||||
<Navbar
|
||||
navData={navData}
|
||||
toggleRain={toggleRain}
|
||||
setToggleRain={setToggleRain}
|
||||
/>
|
||||
<main>
|
||||
<Outlet context={[toggleRain, setToggleRain]} />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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;
|
||||
|
||||
452
Frontend/src/index.css → Frontend/src/app.css
Executable file → Normal file
452
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;
|
||||
@@ -367,31 +347,17 @@
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
from { opacity: 0; transform: translateX(100%) scale(0.95); }
|
||||
to { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes float-up {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1.2);
|
||||
}
|
||||
60% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-80px) scale(1.5);
|
||||
}
|
||||
0% { opacity: 1; transform: translateY(0) scale(1.2); }
|
||||
60% { opacity: 0.9; }
|
||||
100% { opacity: 0; transform: translateY(-80px) scale(1.5); }
|
||||
}
|
||||
|
||||
/* ── Navbar ── */
|
||||
/* -- Navbar -- */
|
||||
@layer components {
|
||||
.header-main {
|
||||
display: flex;
|
||||
@@ -402,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 {
|
||||
@@ -418,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;
|
||||
}
|
||||
|
||||
@@ -439,9 +398,7 @@
|
||||
list-style-type: none;
|
||||
}
|
||||
@media (max-width: 999px) {
|
||||
.nav-list {
|
||||
display: none;
|
||||
}
|
||||
.nav-list { display: none; }
|
||||
}
|
||||
|
||||
.nav-list li {
|
||||
@@ -460,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;
|
||||
@@ -522,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;
|
||||
@@ -649,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;
|
||||
@@ -675,7 +489,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
/* -- Footer -- */
|
||||
@layer components {
|
||||
.footer {
|
||||
display: flex;
|
||||
@@ -692,7 +506,6 @@
|
||||
}
|
||||
.footer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 90%;
|
||||
@@ -707,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;
|
||||
@@ -770,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;
|
||||
@@ -786,7 +545,6 @@
|
||||
color: var(--color-grey);
|
||||
font-size: 1.8rem;
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
}
|
||||
.container h2 {
|
||||
font-family: var(--font);
|
||||
@@ -794,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;
|
||||
@@ -896,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;
|
||||
@@ -910,6 +587,7 @@
|
||||
min-height: 200px;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
.achieve-card {
|
||||
display: flex;
|
||||
@@ -921,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);
|
||||
@@ -933,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,289 +0,0 @@
|
||||
// EvolutionTree.tsx — Arbre d'Évolution V2 (Sprint 3)
|
||||
// 3 branches + capstones + post-capstone repeatables + Convergence évolutif
|
||||
|
||||
import { useState } from "react";
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import {
|
||||
canBuyEvolutionNode,
|
||||
getSpentDna,
|
||||
getTreeResetCost,
|
||||
canResetTree,
|
||||
getRepeatableCost,
|
||||
canUpgradeConvergence,
|
||||
} from "../core/economy";
|
||||
import type { EvolutionNode, Branch } from "../core/economy";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} tetards au depart`,
|
||||
unlock_generator: () => `Lac Mystique des le debut`,
|
||||
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
|
||||
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
|
||||
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
|
||||
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
|
||||
generator_boost: (v) => `x${v} Nid`,
|
||||
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
|
||||
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
|
||||
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
|
||||
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
|
||||
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
|
||||
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
|
||||
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
|
||||
};
|
||||
|
||||
const BRANCH_CONFIG: Record<Branch | "cross", { label: string; color: string; accent: string }> = {
|
||||
ponte: { label: "Ponte", color: "border-emerald-500/30", accent: "gp-accent-green" },
|
||||
marais: { label: "Marais", color: "border-blue-500/30", accent: "text-blue-400" },
|
||||
adaptation: { label: "Adaptation", color: "border-amber-500/30", accent: "gp-accent-amber" },
|
||||
cross: { label: "Convergence", color: "border-purple-500/30", accent: "gp-accent-purple" },
|
||||
};
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
canBuy,
|
||||
isExcluded,
|
||||
onBuy,
|
||||
}: {
|
||||
node: EvolutionNode;
|
||||
canBuy: boolean;
|
||||
isExcluded: boolean;
|
||||
onBuy: () => void;
|
||||
}) {
|
||||
const isCapstone = node.capstone;
|
||||
const isRepeatable = node.repeatable;
|
||||
const purchased = node.purchased ?? 0;
|
||||
|
||||
const rowClass = node.unlocked
|
||||
? isCapstone
|
||||
? "gp-row gp-row--unlocked border-amber-400/40!"
|
||||
: "gp-row gp-row--unlocked"
|
||||
: isExcluded
|
||||
? "gp-row gp-row--locked opacity-30!"
|
||||
: canBuy
|
||||
? isCapstone
|
||||
? "gp-row gp-row--evolution border-amber-400/30!"
|
||||
: "gp-row gp-row--evolution"
|
||||
: "gp-row gp-row--locked";
|
||||
|
||||
const cost = isRepeatable && node.unlocked
|
||||
? getRepeatableCost(node)
|
||||
: isRepeatable
|
||||
? node.cost
|
||||
: node.cost;
|
||||
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isCapstone && <span className="text-amber-400 text-[0.6rem]">★</span>}
|
||||
<span className="gp-value text-[0.7rem]!">{node.name}</span>
|
||||
{isRepeatable && node.unlocked && (
|
||||
<span className="gp-label text-[0.55rem]!">x{purchased}</span>
|
||||
)}
|
||||
{node.exclusive_with && !node.unlocked && !isExcluded && (
|
||||
<span className="gp-label text-[0.55rem]!">OU</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||
</div>
|
||||
{node.unlocked && !isRepeatable ? (
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
) : node.unlocked && isRepeatable ? (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
) : isExcluded ? (
|
||||
<span className="gp-label text-[0.55rem]!">verrouille</span>
|
||||
) : (
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={onBuy}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchColumn({ branch }: { branch: Branch }) {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const buyNode = useGameStore((s) => s.buyNode);
|
||||
const nodes = state.evolutionTree.filter((n) => n.branch === branch);
|
||||
const config = BRANCH_CONFIG[branch];
|
||||
|
||||
return (
|
||||
<div className={`gp flex-1 min-w-0 border-t-2 ${config.color}`}>
|
||||
<span className={`gp-title text-center ${config.accent}`}>{config.label}</span>
|
||||
{nodes.map((node) => {
|
||||
const isExcluded = node.exclusive_with
|
||||
? state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false
|
||||
: false;
|
||||
return (
|
||||
<NodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
canBuy={canBuyEvolutionNode(state, node.id)}
|
||||
isExcluded={isExcluded}
|
||||
onBuy={() => buyNode(node.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConvergenceSection() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const buyNode = useGameStore((s) => s.buyNode);
|
||||
const upgradeConv = useGameStore((s) => s.upgradeConvergenceNode);
|
||||
const conv = state.evolutionTree.find((n) => n.id === "convergence");
|
||||
|
||||
if (!conv) return null;
|
||||
|
||||
const canBuy = canBuyEvolutionNode(state, "convergence");
|
||||
const canUpgrade = canUpgradeConvergence(state);
|
||||
const tier = conv.tier ?? 1;
|
||||
const maxTier = conv.maxTier ?? 2;
|
||||
const tierName = tier >= 2 ? "Omega" : "Alpha";
|
||||
|
||||
return (
|
||||
<div className="gp border-t-2 border-purple-500/30">
|
||||
<span className="gp-title text-center gp-accent-purple">
|
||||
Convergence {conv.unlocked ? tierName : ""}
|
||||
</span>
|
||||
{conv.unlocked ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="gp-row gp-row--unlocked border-purple-400/30!">
|
||||
<div className="flex flex-col">
|
||||
<span className="gp-value text-[0.7rem]!">
|
||||
{tier >= 2 ? "Omega" : "Alpha"} (tier {tier}/{maxTier})
|
||||
</span>
|
||||
<span className="gp-label">
|
||||
{tier >= 2
|
||||
? "+10% tous effets + -20% cout post-capstones"
|
||||
: "+10% a tous les effets de l'arbre"
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<span className="gp-label gp-accent-green">OK</span>
|
||||
</div>
|
||||
{tier < maxTier && (
|
||||
<button
|
||||
disabled={!canUpgrade}
|
||||
onClick={upgradeConv}
|
||||
className={`gp-btn ${canUpgrade ? "gp-btn--buy" : "gp-btn--disabled"} w-full`}
|
||||
>
|
||||
{canUpgrade
|
||||
? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)`
|
||||
: `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gp-row gp-row--locked">
|
||||
<div className="flex flex-col">
|
||||
<span className="gp-value text-[0.7rem]!">Convergence Alpha</span>
|
||||
<span className="gp-label">+10% a tous les effets de l'arbre</span>
|
||||
<span className="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
|
||||
</div>
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onClick={() => buyNode("convergence")}
|
||||
className={`gp-btn ${canBuy ? "gp-btn--buy" : "gp-btn--disabled"}`}
|
||||
>
|
||||
{conv.cost}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BRANCHES: Branch[] = ["ponte", "marais", "adaptation"];
|
||||
|
||||
export function EvolutionTree() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const resetTree = useGameStore((s) => s.resetTree);
|
||||
const { prestigeCount, ancestralDna, evolutionTree } = state;
|
||||
const [activeBranch, setActiveBranch] = useState<Branch>("ponte");
|
||||
|
||||
if (prestigeCount < 1) return null;
|
||||
|
||||
const spentDna = getSpentDna(evolutionTree);
|
||||
const hasUnlocked = spentDna > 0;
|
||||
const resetCost = getTreeResetCost(state);
|
||||
const canReset = canResetTree(state);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!canReset) return;
|
||||
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : " (gratuit)";
|
||||
const confirmed = window.confirm(
|
||||
`Reinitialiser l'Arbre d'Evolution ?\n\n` +
|
||||
`Tu recuperes ${spentDna} ADN Ancestral.${costLabel}\n` +
|
||||
`Tous les noeuds seront verrouilles.\n\n` +
|
||||
`Confirmer ?`
|
||||
);
|
||||
if (confirmed) resetTree();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center px-1">
|
||||
<span className="gp-title">Evolution</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="gp-value gp-accent-amber">{formatNumber(ancestralDna)} ADN</span>
|
||||
{hasUnlocked && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={!canReset}
|
||||
className={`gp-btn text-[0.55rem]! ${
|
||||
canReset
|
||||
? "gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!"
|
||||
: "gp-btn--disabled"
|
||||
}`}
|
||||
title={`Recuperer ${spentDna} ADN${resetCost > 0 ? ` (coute ${resetCost})` : " (gratuit)"}`}
|
||||
>
|
||||
Reset{resetCost > 0 ? ` (${resetCost})` : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Branch tabs */}
|
||||
<div className="flex gap-1">
|
||||
{BRANCHES.map((branch) => {
|
||||
const config = BRANCH_CONFIG[branch];
|
||||
const isActive = activeBranch === branch;
|
||||
return (
|
||||
<button
|
||||
key={branch}
|
||||
onClick={() => setActiveBranch(branch)}
|
||||
className={`gp-btn flex-1 py-1.5! text-[0.7rem]! font-bold! uppercase! tracking-wider! ${
|
||||
isActive
|
||||
? `gp-btn--buy ${config.accent}`
|
||||
: "gp-btn--disabled"
|
||||
}`}
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active branch content */}
|
||||
<BranchColumn branch={activeBranch} />
|
||||
|
||||
<ConvergenceSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,90 +0,0 @@
|
||||
// MilestonesPanel.tsx — Paliers de prestige (Sprint 3)
|
||||
// Progress bar vers le prochain milestone, claim button, preview reward
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { getClaimableMilestones, getNextMilestone } from "../core/economy";
|
||||
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
|
||||
|
||||
export function MilestonesPanel() {
|
||||
const state = useGameStore((s) => s.state);
|
||||
const claim = useGameStore((s) => s.claimMilestone);
|
||||
|
||||
if (state.prestigeCount < 1) return null;
|
||||
|
||||
const claimable = getClaimableMilestones(state);
|
||||
const nextMilestone = getNextMilestone(state);
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
const totalClaimed = claimed.length;
|
||||
|
||||
return (
|
||||
<div className="gp">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="gp-title">Milestones</span>
|
||||
<span className="gp-label">{totalClaimed}/{PRESTIGE_MILESTONES.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Claimable milestones */}
|
||||
{claimable.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{claimable.map((m) => (
|
||||
<div key={m.id} className="gp-row gp-row--evolution border-purple-400/30!">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="gp-value text-[0.7rem]!">{m.name}</span>
|
||||
<span className="gp-label">{m.reward.label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => claim(m.id)}
|
||||
className="gp-btn gp-btn--buy"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress vers le prochain milestone */}
|
||||
{nextMilestone && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="gp-label">Prochain : {nextMilestone.name}</span>
|
||||
<span className="gp-label">
|
||||
{state.prestigeCount}/{nextMilestone.threshold}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gp-progress">
|
||||
<div
|
||||
className="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400"
|
||||
style={{
|
||||
width: `${Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="gp-label">{nextMilestone.reward.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tous les milestones réclamés */}
|
||||
{!nextMilestone && claimable.length === 0 && (
|
||||
<span className="gp-label text-center gp-accent-purple">
|
||||
Tous les milestones reclames !
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Liste compacte des milestones passés */}
|
||||
{totalClaimed > 0 && claimable.length === 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{PRESTIGE_MILESTONES.filter((m) => claimed.includes(m.id)).map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
|
||||
title={`${m.name} — ${m.description}`}
|
||||
>
|
||||
{m.threshold}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,56 +0,0 @@
|
||||
// ToastContainer.tsx — Stack de toasts en bas à droite
|
||||
|
||||
import { useToastStore } from "../store/useToastStore";
|
||||
import type { ToastVariant } from "../store/useToastStore";
|
||||
|
||||
const VARIANT_STYLES: Record<ToastVariant, string> = {
|
||||
success: "border-emerald-500/40 bg-emerald-500/10",
|
||||
info: "border-blue-400/40 bg-blue-400/10",
|
||||
reward: "border-amber-400/40 bg-amber-400/10",
|
||||
warning: "border-red-400/40 bg-red-400/10",
|
||||
};
|
||||
|
||||
const VARIANT_ICONS: Record<ToastVariant, string> = {
|
||||
success: "✓",
|
||||
info: "ℹ",
|
||||
reward: "★",
|
||||
warning: "⚠",
|
||||
};
|
||||
|
||||
const VARIANT_ICON_COLORS: Record<ToastVariant, string> = {
|
||||
success: "text-emerald-400",
|
||||
info: "text-blue-400",
|
||||
reward: "text-amber-400",
|
||||
warning: "text-red-400",
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const remove = useToastStore((s) => s.removeToast);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 max-w-sm">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => remove(t.id)}
|
||||
className={`
|
||||
gp cursor-pointer border
|
||||
${VARIANT_STYLES[t.variant]}
|
||||
animate-[slide-in_0.3s_ease-out]
|
||||
`}
|
||||
style={{ backdropFilter: "blur(12px)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-bold ${VARIANT_ICON_COLORS[t.variant]}`}>
|
||||
{VARIANT_ICONS[t.variant]}
|
||||
</span>
|
||||
<span className="gp-value text-[0.75rem]!">{t.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,20 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"linkname": "Jeu",
|
||||
"linkurl": "/jeu",
|
||||
"btn": false
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"linkname": "Succès",
|
||||
"linkurl": "/achievements",
|
||||
"btn": false
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"linkname": "Guide",
|
||||
"linkurl": "/guide",
|
||||
"btn": false
|
||||
}
|
||||
]
|
||||
@@ -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();
|
||||
}
|
||||
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>
|
||||
29
Frontend/src/lib/components/CockpitHeader.svelte
Normal file
29
Frontend/src/lib/components/CockpitHeader.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { gameStore } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
</script>
|
||||
|
||||
<div class="gp">
|
||||
<div class="grid grid-cols-5 gap-0.5 px-1">
|
||||
<div class="gp-stat" title="Production automatique par seconde">
|
||||
<span class="gp-label">Prod/s</span>
|
||||
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(gameStore.productionPerSecond)}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Tetards gagnes par clic">
|
||||
<span class="gp-label">/clic</span>
|
||||
<span class="gp-value text-[0.8rem]!">{formatNumber(gameStore.getClickGain())}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Multiplicateur global (prestige)">
|
||||
<span class="gp-label">Mult</span>
|
||||
<span class="gp-value text-[0.8rem]!">x{gameStore.state.prestigeMultiplier.toFixed(1)}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="ADN Ancestral">
|
||||
<span class="gp-label">ADN</span>
|
||||
<span class="gp-value gp-accent-purple text-[0.8rem]!">{gameStore.state.ancestralDna}</span>
|
||||
</div>
|
||||
<div class="gp-stat" title="Nombre de prestiges">
|
||||
<span class="gp-label">Gen.</span>
|
||||
<span class="gp-value text-[0.8rem]!">{gameStore.state.prestigeCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
const SLOT_LABELS: Record<CosmeticSlot, string> = {
|
||||
hat: 'Tete', eyes: 'Yeux', body: 'Corps', tail: 'Queue', accessory: 'Aura',
|
||||
};
|
||||
const SLOT_ICONS: Record<CosmeticSlot, string> = {
|
||||
hat: '👑', eyes: '👁', body: '🛡', tail: '🦎', accessory: '✨',
|
||||
};
|
||||
const SLOT_ORDER: CosmeticSlot[] = ['hat', 'eyes', 'body', 'tail', 'accessory'];
|
||||
|
||||
let inventory = $derived(gameStore.state.cosmeticInventory);
|
||||
let equipped = $derived(gameStore.state.cosmeticEquipped);
|
||||
let ownedCosmetics = $derived(COSMETICS.filter((c) => inventory.includes(c.id)));
|
||||
</script>
|
||||
|
||||
{#if inventory.length > 0}
|
||||
<CollapsiblePanel
|
||||
title="Cosmetiques"
|
||||
badge="{inventory.length}/{COSMETICS.length}"
|
||||
defaultOpen={false}
|
||||
>
|
||||
{#each SLOT_ORDER as slot, si}
|
||||
{@const slotCosmetics = ownedCosmetics.filter((c) => c.slot === slot)}
|
||||
{#if slotCosmetics.length > 0}
|
||||
<div
|
||||
class="flex flex-col gap-0.5"
|
||||
in:fly={{ y: 15, delay: si * 60, duration: 250, easing: quintOut }}
|
||||
>
|
||||
<span class="gp-zone-label">{SLOT_ICONS[slot]} {SLOT_LABELS[slot]}</span>
|
||||
{#each slotCosmetics as cos}
|
||||
{@const isEquipped = equipped[slot] === cos.id}
|
||||
<div class="gp-row {isEquipped ? 'gp-row--unlocked' : 'gp-row--active'}">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="gp-value text-[0.7rem]!">{cos.name}</span>
|
||||
<span class="gp-label">{cos.description}</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => isEquipped ? gameStore.unequipCosmetic(slot) : gameStore.equipCosmetic(cos.id)}
|
||||
class="gp-btn {isEquipped ? 'gp-btn--disabled' : 'gp-btn--buy'}"
|
||||
>
|
||||
{isEquipped ? 'Retirer' : 'Equiper'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</CollapsiblePanel>
|
||||
{/if}
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import {
|
||||
canBuyEvolutionNode,
|
||||
getSpentDna,
|
||||
getTreeResetCost,
|
||||
canResetTree,
|
||||
getRepeatableCost,
|
||||
canUpgradeConvergence,
|
||||
type EvolutionNode,
|
||||
type Branch,
|
||||
} from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
const EFFECT_LABELS: Record<string, (v: number, n?: EvolutionNode) => string> = {
|
||||
click_multiplier: (v) => `x${v} ponte`,
|
||||
production_multiplier: (v) => `x${v} production`,
|
||||
start_bonus: (v) => `+${v} tetards au depart`,
|
||||
unlock_generator: () => `Lac Mystique des le debut`,
|
||||
double_click_chance: (v) => `${(v * 100).toFixed(0)}% chance double ponte`,
|
||||
auto_click: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `${v} auto-ponte/s`,
|
||||
auto_click_scaling: (v) => `${v} auto-ponte/s (scale)`,
|
||||
crit_click_chance: (v) => `${(v * 100).toFixed(0)}% chance crit x10`,
|
||||
generator_boost: (v) => `x${v} Nid`,
|
||||
generator_synergy: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% par type`,
|
||||
cost_reduction: (v) => `-${(v * 100).toFixed(0)}% cout generateurs`,
|
||||
prestige_dna_bonus: (v) => `+${(v * 100).toFixed(0)}% ADN prestige`,
|
||||
offline_boost: (v, n) => n?.repeatable ? `+${(v * 100).toFixed(0)}%/achat` : `+${(v * 100).toFixed(0)}% gains offline`,
|
||||
offline_cap_boost: (v) => `Offline cap → ${(v * 100).toFixed(0)}%, duree 8h`,
|
||||
prestige_threshold_reduction: (v) => `Prestige a ${((1 - v) * 100).toFixed(0)}% du seuil`,
|
||||
all_effects_boost: (v) => `+${(v * 100).toFixed(0)}% tous effets`,
|
||||
post_capstone_discount: (v) => `-${(v * 100).toFixed(0)}% cout post-capstones`,
|
||||
};
|
||||
|
||||
const BRANCH_CONFIG: Record<string, { label: string; color: string; accent: string }> = {
|
||||
ponte: { label: 'Ponte', color: 'border-emerald-500/30', accent: 'gp-accent-green' },
|
||||
marais: { label: 'Marais', color: 'border-blue-500/30', accent: 'text-blue-400' },
|
||||
adaptation: { label: 'Adaptation', color: 'border-amber-500/30', accent: 'gp-accent-amber' },
|
||||
cross: { label: 'Convergence', color: 'border-purple-500/30', accent: 'gp-accent-purple' },
|
||||
};
|
||||
|
||||
const BRANCHES: Branch[] = ['ponte', 'marais', 'adaptation'];
|
||||
|
||||
let activeBranch = $state<Branch>('ponte');
|
||||
|
||||
let branchConfig = $derived(BRANCH_CONFIG[activeBranch]);
|
||||
let branchNodes = $derived(gameStore.state.evolutionTree.filter((n) => n.branch === activeBranch));
|
||||
let spentDna = $derived(getSpentDna(gameStore.state.evolutionTree));
|
||||
let hasUnlocked = $derived(spentDna > 0);
|
||||
let resetCost = $derived(getTreeResetCost(gameStore.state));
|
||||
let canReset = $derived(canResetTree(gameStore.state));
|
||||
let conv = $derived(gameStore.state.evolutionTree.find((n) => n.id === 'convergence'));
|
||||
let canBuyConv = $derived(canBuyEvolutionNode(gameStore.state, 'convergence'));
|
||||
let canUpgradeConv = $derived(canUpgradeConvergence(gameStore.state));
|
||||
|
||||
function handleReset() {
|
||||
if (!canReset) return;
|
||||
const costLabel = resetCost > 0 ? ` (coute ${resetCost} ADN)` : ' (gratuit)';
|
||||
const confirmed = window.confirm(
|
||||
`Reinitialiser l'Arbre d'Evolution ?\n\nTu recuperes ${spentDna} ADN Ancestral.${costLabel}\nTous les noeuds seront verrouilles.\n\nConfirmer ?`
|
||||
);
|
||||
if (confirmed) gameStore.resetTree();
|
||||
}
|
||||
|
||||
function getNodeRowClass(node: EvolutionNode, isExcluded: boolean, canBuy: boolean): string {
|
||||
if (node.unlocked) return node.capstone ? 'gp-row gp-row--unlocked border-amber-400/40!' : 'gp-row gp-row--unlocked';
|
||||
if (isExcluded) return 'gp-row gp-row--locked opacity-30!';
|
||||
if (canBuy) return node.capstone ? 'gp-row gp-row--evolution border-amber-400/30!' : 'gp-row gp-row--evolution';
|
||||
return 'gp-row gp-row--locked';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if gameStore.state.prestigeCount >= 1}
|
||||
<div class="flex flex-col gap-2">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<span class="gp-title">Evolution</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="gp-value gp-accent-amber">{formatNumber(gameStore.state.ancestralDna)} ADN</span>
|
||||
{#if hasUnlocked}
|
||||
<button
|
||||
onclick={handleReset}
|
||||
disabled={!canReset}
|
||||
class="gp-btn text-[0.55rem]! {canReset ? 'gp-btn--disabled hover:bg-red-500/20! hover:text-red-400!' : 'gp-btn--disabled'}"
|
||||
title="Recuperer {spentDna} ADN{resetCost > 0 ? ` (coute ${resetCost})` : ' (gratuit)'}"
|
||||
>
|
||||
Reset{resetCost > 0 ? ` (${resetCost})` : ''}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch tabs -->
|
||||
<div class="flex gap-1">
|
||||
{#each BRANCHES as branch}
|
||||
{@const config = BRANCH_CONFIG[branch]}
|
||||
{@const isActive = activeBranch === branch}
|
||||
<button
|
||||
onclick={() => activeBranch = branch}
|
||||
class="gp-btn flex-1 py-1.5! text-[0.7rem]! font-bold! uppercase! tracking-wider! {isActive ? `gp-btn--buy ${config.accent}` : 'gp-btn--disabled'}"
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Active branch -->
|
||||
<div class="gp flex-1 min-w-0 border-t-2 {branchConfig.color}">
|
||||
<span class="gp-title text-center {branchConfig.accent}">{branchConfig.label}</span>
|
||||
{#each branchNodes as node}
|
||||
{@const isExcluded = node.exclusive_with ? (gameStore.state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
|
||||
{@const canBuy = canBuyEvolutionNode(gameStore.state, node.id)}
|
||||
{@const cost = node.repeatable && node.unlocked ? getRepeatableCost(node) : node.cost}
|
||||
<div class={getNodeRowClass(node, isExcluded, canBuy)}>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
{#if node.capstone}<span class="text-amber-400 text-[0.6rem]">★</span>{/if}
|
||||
<span class="gp-value text-[0.7rem]!">{node.name}</span>
|
||||
{#if node.repeatable && node.unlocked}
|
||||
<span class="gp-label text-[0.55rem]!">x{node.purchased ?? 0}</span>
|
||||
{/if}
|
||||
{#if node.exclusive_with && !node.unlocked && !isExcluded}
|
||||
<span class="gp-label text-[0.55rem]!">OU</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="gp-label">{EFFECT_LABELS[node.effect]?.(node.value, node) ?? node.effect}</span>
|
||||
</div>
|
||||
{#if node.unlocked && !node.repeatable}
|
||||
<span class="gp-label gp-accent-green">OK</span>
|
||||
{:else if isExcluded}
|
||||
<span class="gp-label text-[0.55rem]!">verrouille</span>
|
||||
{:else}
|
||||
<button
|
||||
disabled={!canBuy}
|
||||
onclick={() => gameStore.buyNode(node.id)}
|
||||
class="gp-btn {canBuy ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Convergence -->
|
||||
{#if conv}
|
||||
<div class="gp border-t-2 border-purple-500/30">
|
||||
<span class="gp-title text-center gp-accent-purple">
|
||||
Convergence {conv.unlocked ? ((conv.tier ?? 1) >= 2 ? 'Omega' : 'Alpha') : ''}
|
||||
</span>
|
||||
{#if conv.unlocked}
|
||||
{@const tier = conv.tier ?? 1}
|
||||
{@const maxTier = conv.maxTier ?? 2}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="gp-row gp-row--unlocked border-purple-400/30!">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">{tier >= 2 ? 'Omega' : 'Alpha'} (tier {tier}/{maxTier})</span>
|
||||
<span class="gp-label">
|
||||
{tier >= 2 ? '+10% tous effets + -20% cout post-capstones' : "+10% a tous les effets de l'arbre"}
|
||||
</span>
|
||||
</div>
|
||||
<span class="gp-label gp-accent-green">OK</span>
|
||||
</div>
|
||||
{#if tier < maxTier}
|
||||
<button
|
||||
disabled={!canUpgradeConv}
|
||||
onclick={() => gameStore.upgradeConvergence()}
|
||||
class="gp-btn {canUpgradeConv ? 'gp-btn--buy' : 'gp-btn--disabled'} w-full"
|
||||
>
|
||||
{canUpgradeConv ? `Evoluer → Omega (${conv.tierUpgradeCost} ADN)` : `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gp-row gp-row--locked">
|
||||
<div class="flex flex-col">
|
||||
<span class="gp-value text-[0.7rem]!">Convergence Alpha</span>
|
||||
<span class="gp-label">+10% a tous les effets de l'arbre</span>
|
||||
<span class="gp-label text-[0.55rem]!">Requis : 1 capstone + tier 3 d'une 2e branche</span>
|
||||
</div>
|
||||
<button
|
||||
disabled={!canBuyConv}
|
||||
onclick={() => gameStore.buyNode('convergence')}
|
||||
class="gp-btn {canBuyConv ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{conv.cost}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import {
|
||||
loadFromServer,
|
||||
startAutoSave,
|
||||
stopAutoSave,
|
||||
setupVisibilitySync,
|
||||
} from '$lib/save-sync';
|
||||
|
||||
onMount(async () => {
|
||||
// Init auth
|
||||
await authStore.init();
|
||||
|
||||
// Load save or init guest
|
||||
if (authStore.user) {
|
||||
const loaded = await loadFromServer();
|
||||
if (!loaded && !gameStore.ready) {
|
||||
gameStore.initGuest();
|
||||
}
|
||||
startAutoSave();
|
||||
setupVisibilitySync();
|
||||
} else {
|
||||
gameStore.initGuest();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => gameStore.tick(), 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
50
Frontend/src/lib/components/GeneratorShop.svelte
Normal file
50
Frontend/src/lib/components/GeneratorShop.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { scale } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { gameStore } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel
|
||||
title="Generateurs"
|
||||
badge="{formatNumber(gameStore.productionPerSecond)}/s"
|
||||
accentClass=""
|
||||
>
|
||||
{#each gameStore.state.generators as gen, i}
|
||||
{@const cost = gameStore.generatorCostWithTree(gen)}
|
||||
{@const canAfford = gameStore.state.resources >= cost}
|
||||
{@const currentProd = gen.baseProduction * gen.owned}
|
||||
<div
|
||||
class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}"
|
||||
in:scale={{ delay: i * 30, duration: 200, start: 0.95, easing: quintOut }}
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="gp-value">{gen.name}</span>
|
||||
{#if gen.owned > 0}
|
||||
<span
|
||||
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
|
||||
style="background: rgba(16,185,129,0.15); color: var(--color-gp-accent-green);"
|
||||
>
|
||||
x{gen.owned}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="gp-label">
|
||||
+{gen.baseProduction}/s
|
||||
{#if gen.owned > 0}
|
||||
<span class="gp-accent-green"> · {formatNumber(currentProd)}/s</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => gameStore.buy(gen.id)}
|
||||
disabled={!canAfford}
|
||||
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
|
||||
>
|
||||
{formatNumber(cost)}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</CollapsiblePanel>
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import { getPrestigeThreshold } from '$lib/core/economy';
|
||||
|
||||
let threshold = $derived(getPrestigeThreshold(gameStore.state));
|
||||
let progress = $derived(Math.min(gameStore.state.resources / threshold, 1));
|
||||
let progressPercent = $derived((progress * 100).toFixed(1));
|
||||
let remaining = $derived(Math.max(threshold - gameStore.state.resources, 0));
|
||||
</script>
|
||||
|
||||
<div class="gp gap-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Prochaine Generation</span>
|
||||
<span class="gp-label">{formatNumber(gameStore.state.resources)} / {formatNumber(threshold)}</span>
|
||||
</div>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<span class="gp-label text-right">
|
||||
{remaining > 0 ? `${formatNumber(remaining)} restants` : 'Nouvelle Generation disponible !'}
|
||||
</span>
|
||||
</div>
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy';
|
||||
import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let claimable = $derived(getClaimableMilestones(gameStore.state));
|
||||
let nextMilestone = $derived(getNextMilestone(gameStore.state));
|
||||
let claimed = $derived(gameStore.state.claimedMilestones ?? []);
|
||||
let totalClaimed = $derived(claimed.length);
|
||||
</script>
|
||||
|
||||
{#if gameStore.state.prestigeCount >= 1}
|
||||
<CollapsiblePanel
|
||||
title="Milestones"
|
||||
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
|
||||
accentClass="gp-accent-amber"
|
||||
>
|
||||
{#if claimable.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each claimable as m, i}
|
||||
<div
|
||||
class="gp-row gp-row--evolution border-purple-400/30!"
|
||||
in:fly={{ y: 20, delay: i * 80, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="gp-value text-[0.7rem]!">{m.name}</span>
|
||||
<span class="gp-label">{m.reward.label}</span>
|
||||
</div>
|
||||
<button onclick={() => gameStore.claimMilestone(m.id)} class="gp-btn gp-btn--buy">
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if nextMilestone}
|
||||
{@const progressPct = Math.min((gameStore.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Prochain : {nextMilestone.name}</span>
|
||||
<span class="gp-label">{gameStore.state.prestigeCount}/{nextMilestone.threshold}</span>
|
||||
</div>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400" style="width: {progressPct}%"></div>
|
||||
</div>
|
||||
<span class="gp-label">{nextMilestone.reward.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !nextMilestone && claimable.length === 0}
|
||||
<span class="gp-label text-center gp-accent-purple">Tous les milestones reclames !</span>
|
||||
{/if}
|
||||
|
||||
{#if totalClaimed > 0 && claimable.length === 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each PRESTIGE_MILESTONES.filter((m) => claimed.includes(m.id)) as m, i}
|
||||
<span
|
||||
class="gp-label text-[0.55rem]! px-1.5 py-0.5 rounded bg-purple-500/10 border border-purple-500/20"
|
||||
title="{m.name} — {m.description}"
|
||||
in:scale={{ delay: i * 40, duration: 200 }}
|
||||
>
|
||||
{m.threshold}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsiblePanel>
|
||||
{/if}
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}h${minutes % 60}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.offlineReport) gameStore.dismissOfflineReport(); }} />
|
||||
|
||||
{#if gameStore.offlineReport}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);"
|
||||
transition:fade={{ duration: 250 }}
|
||||
onclick={() => gameStore.dismissOfflineReport()}
|
||||
>
|
||||
<div
|
||||
class="gp max-w-sm w-full mx-4 text-center"
|
||||
onclick={(e: MouseEvent) => e.stopPropagation()}
|
||||
in:scale={{ duration: 400, start: 0.8, easing: backOut }}
|
||||
out:scale={{ duration: 200 }}
|
||||
>
|
||||
<div in:fly={{ y: -15, delay: 100, duration: 350, easing: quintOut }}>
|
||||
<h2 class="gp-title text-lg!">Retour au Marais</h2>
|
||||
<p class="gp-label mt-2">
|
||||
Absent pendant <span class="gp-accent-green">{formatDuration(gameStore.offlineReport.duration)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}>
|
||||
<p
|
||||
class="gp-value text-3xl! mt-4 mb-2 gp-accent-green"
|
||||
style="text-shadow: 0 0 15px rgba(52,211,153,0.3);"
|
||||
>
|
||||
+{formatNumber(gameStore.offlineReport.gains)} tetards
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
|
||||
Efficacite : {Math.round(gameStore.offlineReport.efficiency * 100)}%
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
|
||||
onclick={() => gameStore.dismissOfflineReport()}
|
||||
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
|
||||
>
|
||||
Continuer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CollapsiblePanel from './CollapsiblePanel.svelte';
|
||||
|
||||
let baseDna = $derived(computePrestigeDna(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
|
||||
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
|
||||
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
|
||||
let threshold = $derived(getPrestigeThreshold(gameStore.state));
|
||||
let progress = $derived(Math.min(gameStore.state.lifetimeTadpoles / threshold * 100, 100));
|
||||
</script>
|
||||
|
||||
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
|
||||
{#if gameStore.canPrestige}
|
||||
<div class="flex flex-col gap-2" in:scale={{ duration: 300, start: 0.9, easing: quintOut }}>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="gp-value gp-accent-purple">+{dnaPreview} ADN</span>
|
||||
<span class="gp-label">+0.1x mult</span>
|
||||
</div>
|
||||
<button onclick={() => gameStore.openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="gp-label">Atteins {formatNumber(threshold)} tetards</span>
|
||||
<div class="gp-progress">
|
||||
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progress.toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsiblePanel>
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
let baseDna = $derived(computePrestigeDna(gameStore.state.lifetimeTadpoles, gameStore.state.prestigeCount));
|
||||
let dnaBonus = $derived(getPrestigeDnaBonus(gameStore.state.evolutionTree));
|
||||
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
|
||||
let threshold = $derived(getPrestigeThreshold(gameStore.state));
|
||||
let canPrestige = $derived(gameStore.state.lifetimeTadpoles >= threshold);
|
||||
let runDuration = $derived(Date.now() - gameStore.state.runStats.startedAt);
|
||||
let bestRun = $derived(gameStore.state.runStats.bestRun);
|
||||
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
|
||||
let isBestTadpoles = $derived(!bestRun || gameStore.state.lifetimeTadpoles > bestRun.tadpoles);
|
||||
|
||||
function handlePrestige() {
|
||||
if (canPrestige) gameStore.prestige();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.showPrestigeScreen) gameStore.closePrestige(); }} />
|
||||
|
||||
{#if gameStore.showPrestigeScreen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style="background: rgba(0,0,0,0.85); backdrop-filter: blur(8px);"
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Modal card -->
|
||||
<div
|
||||
class="gp max-w-md w-full mx-4"
|
||||
in:scale={{ duration: 400, start: 0.85, easing: backOut }}
|
||||
out:scale={{ duration: 200, start: 0.95 }}
|
||||
>
|
||||
<!-- Header with generation number -->
|
||||
<div class="text-center" in:fly={{ y: -20, delay: 100, duration: 400, easing: quintOut }}>
|
||||
<span class="gp-title text-lg!">Nouvelle Generation</span>
|
||||
<p class="gp-label mt-1">Generation #{gameStore.state.prestigeCount + 1}</p>
|
||||
</div>
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- ADN Preview — the hero number -->
|
||||
<div
|
||||
class="flex flex-col items-center gap-1 py-3"
|
||||
in:scale={{ delay: 200, duration: 500, start: 0.5, easing: backOut }}
|
||||
>
|
||||
<span class="gp-label">ADN Ancestral</span>
|
||||
<span
|
||||
class="text-4xl font-extrabold"
|
||||
style="color: #a78bfa; font-family: var(--font); text-shadow: 0 0 20px rgba(167,139,250,0.4);"
|
||||
>
|
||||
+{formatNumber(dnaPreview)}
|
||||
</span>
|
||||
{#if dnaBonus > 0}
|
||||
<span class="gp-label">(base {formatNumber(baseDna)} + {Math.round(dnaBonus * 100)}% arbre)</span>
|
||||
{/if}
|
||||
<span class="gp-label mt-1">Total apres : {formatNumber(gameStore.state.ancestralDna + dnaPreview)} ADN</span>
|
||||
</div>
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- Run Stats -->
|
||||
<div class="flex flex-col gap-2" in:fly={{ y: 20, delay: 300, duration: 400, easing: quintOut }}>
|
||||
<span class="gp-zone-label">Stats de la run</span>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Duree</span>
|
||||
<span class="gp-value">{formatDuration(runDuration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Tetards produits</span>
|
||||
<span class="gp-value {isBestTadpoles ? 'gp-accent-green' : ''}">
|
||||
{formatNumber(gameStore.state.lifetimeTadpoles)}
|
||||
{#if isBestTadpoles && bestRun} ★{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">ADN cette run</span>
|
||||
<span class="gp-value {isBestAdn ? 'gp-accent-green' : ''}">
|
||||
{formatNumber(dnaPreview)}
|
||||
{#if isBestAdn && bestRun} ★{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if bestRun}
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Vitesse vs meilleure</span>
|
||||
<span class="gp-value {runDuration < bestRun.duration ? 'gp-accent-green' : 'gp-accent-amber'}">
|
||||
{#if runDuration < bestRun.duration}
|
||||
{Math.round((1 - runDuration / bestRun.duration) * 100)}% plus rapide
|
||||
{:else if runDuration > bestRun.duration}
|
||||
{Math.round((runDuration / bestRun.duration - 1) * 100)}% plus lent
|
||||
{:else}
|
||||
identique
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if bestRun}
|
||||
<div class="gp-sep"></div>
|
||||
<div class="flex flex-col gap-1" in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}>
|
||||
<span class="gp-zone-label">Meilleure run</span>
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">Duree</span>
|
||||
<span class="gp-value">{formatDuration(bestRun.duration)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="gp-label">ADN</span>
|
||||
<span class="gp-value gp-accent-purple">{formatNumber(bestRun.adn)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gp-sep"></div>
|
||||
|
||||
<!-- Reset info -->
|
||||
<div class="text-center" in:fade={{ delay: 350, duration: 300 }}>
|
||||
<p class="gp-label">Tetards et generateurs remis a zero.</p>
|
||||
<p class="gp-label">Arbre d'Evolution et cosmetiques conserves.</p>
|
||||
<p class="gp-label mt-1 gp-accent-green">+1 reset d'arbre gratuit offert.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-1" in:fly={{ y: 20, delay: 450, duration: 300, easing: quintOut }}>
|
||||
<button
|
||||
onclick={() => gameStore.closePrestige()}
|
||||
class="gp-btn flex-1 py-2.5! text-[0.8rem]!"
|
||||
style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6);"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
{#if canPrestige}
|
||||
<button onclick={handlePrestige} class="gp-btn gp-btn--prestige flex-1 py-2.5! text-[0.8rem]!">
|
||||
Nouvelle Generation
|
||||
</button>
|
||||
{:else}
|
||||
<button class="gp-btn gp-btn--disabled flex-1 py-2.5!" disabled>
|
||||
{formatNumber(threshold - gameStore.state.lifetimeTadpoles)} manquants
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
|
||||
|
||||
const SLOT_ORDER: CosmeticSlot[] = ['body', 'tail', 'eyes', 'hat', 'accessory'];
|
||||
|
||||
let overlays = $derived(
|
||||
SLOT_ORDER
|
||||
.map((slot) => {
|
||||
const cosId = gameStore.state.cosmeticEquipped[slot];
|
||||
if (!cosId) return null;
|
||||
return COSMETICS.find((c) => c.id === cosId) ?? null;
|
||||
})
|
||||
.filter((c) => c !== null)
|
||||
);
|
||||
|
||||
// Click bounce animation
|
||||
let bouncing = $state(false);
|
||||
|
||||
export function bounce() {
|
||||
bouncing = true;
|
||||
setTimeout(() => bouncing = false, 150);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-[280px] h-[280px] md:w-[320px] md:h-[320px] transition-transform duration-100"
|
||||
class:scale-[0.92]={bouncing}
|
||||
class:rotate-[3deg]={bouncing}
|
||||
style="filter: drop-shadow(0 0 20px rgba(52,211,153,0.15));"
|
||||
>
|
||||
<!-- Base sprite -->
|
||||
<img
|
||||
src="/svg/tadpole.svg"
|
||||
alt="Tetard"
|
||||
class="w-full h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
<!-- Cosmetic overlays -->
|
||||
{#each overlays as cos}
|
||||
<img
|
||||
src={cos.svg}
|
||||
alt={cos.name}
|
||||
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
|
||||
draggable="false"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Glow ring on click -->
|
||||
{#if bouncing}
|
||||
<div
|
||||
class="absolute inset-0 rounded-full"
|
||||
style="
|
||||
background: radial-gradient(circle, rgba(52,211,153,0.15) 0%, transparent 70%);
|
||||
animation: click-ring 0.3s ease-out;
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes click-ring {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(1.3); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
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}
|
||||
@@ -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);
|
||||
}
|
||||
122
Frontend/src/lib/save-sync.ts
Normal file
122
Frontend/src/lib/save-sync.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// save-sync.ts — Auto-save game state to backend every 30s
|
||||
// Server = authority. NEVER save before server state is loaded (ready guard).
|
||||
|
||||
import { gameStore } from '$lib/stores/game.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { migrateSave } from '$lib/core/migrateSave';
|
||||
import type { GameState } from '$lib/core/economy';
|
||||
|
||||
const SAVE_INTERVAL_MS = 30_000;
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3310';
|
||||
|
||||
async function apiRequest(path: string, options: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${BACKEND_URL}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[SaveSync] ${path} failed:`, res.status);
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
let lastSave: string | null = null;
|
||||
let loaded = false;
|
||||
let saveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function saveToServer() {
|
||||
if (!authStore.user || !gameStore.ready) return;
|
||||
const result = await apiRequest('/save', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
gameState: gameStore.state,
|
||||
playTimeSeconds: gameStore.playSeconds,
|
||||
}),
|
||||
});
|
||||
if (result?.lastSave) {
|
||||
lastSave = result.lastSave;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFromServer(): Promise<boolean> {
|
||||
if (loaded || !authStore.user) {
|
||||
if (!authStore.user) loaded = true;
|
||||
return false;
|
||||
}
|
||||
loaded = true;
|
||||
|
||||
try {
|
||||
const data = await apiRequest('/save');
|
||||
if (data?.gameState) {
|
||||
const migrated = migrateSave(data.gameState);
|
||||
gameStore.loadFromServer(migrated);
|
||||
lastSave = data.lastSave;
|
||||
console.info('[SaveSync] Loaded save from server (v%d)', migrated.saveVersion);
|
||||
return true;
|
||||
}
|
||||
console.info('[SaveSync] No server save found');
|
||||
return false;
|
||||
} catch {
|
||||
console.warn('[SaveSync] Server unreachable');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoSave() {
|
||||
stopAutoSave();
|
||||
saveInterval = setInterval(() => {
|
||||
if (authStore.user && gameStore.ready) saveToServer();
|
||||
}, SAVE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopAutoSave() {
|
||||
if (saveInterval) {
|
||||
clearInterval(saveInterval);
|
||||
saveInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupVisibilitySync() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
if (!authStore.user) return;
|
||||
setTimeout(async () => {
|
||||
const data = await apiRequest('/save');
|
||||
if (data?.gameState && data.lastSave) {
|
||||
if (!lastSave || new Date(data.lastSave) > new Date(lastSave)) {
|
||||
const migrated = migrateSave(data.gameState);
|
||||
gameStore.loadFromServer(migrated);
|
||||
lastSave = data.lastSave;
|
||||
console.info('[SaveSync] Reloaded from server on focus');
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
if (authStore.user && gameStore.ready) saveToServer();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (!authStore.user || !gameStore.ready) return;
|
||||
const payload = JSON.stringify({
|
||||
gameState: gameStore.state,
|
||||
playTimeSeconds: gameStore.playSeconds,
|
||||
});
|
||||
fetch(`${BACKEND_URL}/api/save`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
export function resetSaveSync() {
|
||||
loaded = false;
|
||||
lastSave = null;
|
||||
}
|
||||
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,
|
||||
};
|
||||
309
Frontend/src/lib/stores/game.svelte.ts
Normal file
309
Frontend/src/lib/stores/game.svelte.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// game.svelte.ts — Game store (Svelte 5 runes)
|
||||
// Server = authority. localStorage = fallback guest only.
|
||||
|
||||
import {
|
||||
type GameState,
|
||||
DEFAULT_STATE,
|
||||
applyIdleGains,
|
||||
applyClick,
|
||||
getClickGain,
|
||||
getAutoClicksPerSecond,
|
||||
buyGenerator,
|
||||
buyEvolutionNode,
|
||||
resetEvolutionTree,
|
||||
canResetTree,
|
||||
upgradeConvergence,
|
||||
claimMilestone as claimMilestoneFn,
|
||||
applyPrestige,
|
||||
canPrestige as canPrestigeCheck,
|
||||
totalProductionPerSecond,
|
||||
generatorCost as genCost,
|
||||
computeOfflineGains,
|
||||
} from '$lib/core/economy';
|
||||
import { migrateSave } from '$lib/core/migrateSave';
|
||||
import { toast } from './toast.svelte';
|
||||
import {
|
||||
computeNewUnlocks,
|
||||
equipCosmetic as equipCosmeticFn,
|
||||
unequipSlot as unequipSlotFn,
|
||||
addToInventory,
|
||||
type CosmeticSlot,
|
||||
} from '$lib/core/cosmetics';
|
||||
|
||||
const SAVE_KEY = 'clickerz_state';
|
||||
const OFFLINE_THRESHOLD = 60_000;
|
||||
|
||||
// --- Offline report ---
|
||||
|
||||
export interface OfflineReport {
|
||||
wasOffline: boolean;
|
||||
duration: number;
|
||||
gains: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
// --- Reactive state (Svelte 5 runes) ---
|
||||
|
||||
let state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
|
||||
let playSeconds = $state(0);
|
||||
let ready = $state(false);
|
||||
let offlineReport = $state<OfflineReport | null>(null);
|
||||
let showPrestigeScreen = $state(false);
|
||||
let lastClickGain = $state(0);
|
||||
let lastClickDouble = $state(false);
|
||||
let lastClickCrit = $state(false);
|
||||
let canPrestige = $state(false);
|
||||
let productionPerSecond = $state(0);
|
||||
|
||||
// --- Local storage ---
|
||||
|
||||
function loadLocalState(): GameState {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVE_KEY);
|
||||
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
const parsed = JSON.parse(raw);
|
||||
const saved = migrateSave(parsed);
|
||||
return applyIdleGains(saved, Date.now());
|
||||
} catch {
|
||||
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocal(s: GameState): void {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
|
||||
}
|
||||
|
||||
function hydrateWithOffline(saved: GameState, now: number): { state: GameState; report: OfflineReport | null } {
|
||||
const elapsed = now - saved.lastTick;
|
||||
|
||||
if (elapsed <= OFFLINE_THRESHOLD) {
|
||||
const hydrated = applyIdleGains(saved, now);
|
||||
return { state: { ...hydrated, lastOnline: now }, report: null };
|
||||
}
|
||||
|
||||
const gains = computeOfflineGains(saved, now);
|
||||
const pps = totalProductionPerSecond(saved);
|
||||
const fullGains = pps * (elapsed / 1000);
|
||||
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
|
||||
|
||||
const hydrated: GameState = {
|
||||
...saved,
|
||||
resources: saved.resources + gains,
|
||||
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
|
||||
lastTick: now,
|
||||
lastOnline: now,
|
||||
};
|
||||
|
||||
return {
|
||||
state: hydrated,
|
||||
report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Derived ---
|
||||
|
||||
function updateDerived() {
|
||||
canPrestige = canPrestigeCheck(state);
|
||||
productionPerSecond = totalProductionPerSecond(state);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
function tick() {
|
||||
if (!ready) return;
|
||||
const now = Date.now();
|
||||
const updated = applyIdleGains(state, now);
|
||||
updated.lastOnline = now;
|
||||
|
||||
// Auto-click from evolution tree
|
||||
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
|
||||
if (autoClicks > 0) {
|
||||
const autoGain = getClickGain(updated) * autoClicks;
|
||||
updated.resources += autoGain;
|
||||
updated.lifetimeTadpoles += autoGain;
|
||||
}
|
||||
|
||||
// Check cosmetic unlocks every 5s
|
||||
if (playSeconds % 5 === 0) {
|
||||
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
|
||||
const newUnlocks = computeNewUnlocks(updated, cosState);
|
||||
if (newUnlocks.length > 0) {
|
||||
const newCos = addToInventory(cosState, newUnlocks);
|
||||
updated.cosmeticInventory = newCos.inventory;
|
||||
newUnlocks.forEach(() => toast('Nouveau cosmetique debloque !', 'reward'));
|
||||
}
|
||||
}
|
||||
|
||||
saveLocal(updated);
|
||||
state = updated;
|
||||
playSeconds += 1;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function click() {
|
||||
if (!ready) return;
|
||||
const result = applyClick(applyIdleGains(state, Date.now()));
|
||||
saveLocal(result.state);
|
||||
state = result.state;
|
||||
lastClickGain = result.gain;
|
||||
lastClickDouble = result.isDouble;
|
||||
lastClickCrit = result.isCrit;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function buy(genId: string) {
|
||||
if (!ready) return;
|
||||
const withIdle = applyIdleGains(state, Date.now());
|
||||
const updated = buyGenerator(withIdle, genId);
|
||||
if (!updated) return;
|
||||
saveLocal(updated);
|
||||
state = updated;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function buyNode(nodeId: string) {
|
||||
if (!ready) return;
|
||||
const updated = buyEvolutionNode(state, nodeId);
|
||||
if (!updated) return;
|
||||
const node = updated.evolutionTree.find((n) => n.id === nodeId);
|
||||
saveLocal(updated);
|
||||
if (node?.capstone) {
|
||||
toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
|
||||
}
|
||||
state = updated;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function prestige() {
|
||||
if (!ready) return;
|
||||
if (!canPrestigeCheck(state)) return;
|
||||
const updated = applyPrestige(state);
|
||||
saveLocal(updated);
|
||||
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
|
||||
state = updated;
|
||||
showPrestigeScreen = false;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function equipCosmetic(cosmeticId: string) {
|
||||
if (!ready) return;
|
||||
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
|
||||
const updated = equipCosmeticFn(cosState, cosmeticId);
|
||||
const newState = { ...state, cosmeticEquipped: updated.equipped };
|
||||
saveLocal(newState);
|
||||
state = newState;
|
||||
}
|
||||
|
||||
function unequipCosmetic(slot: CosmeticSlot) {
|
||||
if (!ready) return;
|
||||
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
|
||||
const updated = unequipSlotFn(cosState, slot);
|
||||
const newState = { ...state, cosmeticEquipped: updated.equipped };
|
||||
saveLocal(newState);
|
||||
state = newState;
|
||||
}
|
||||
|
||||
function doResetTree() {
|
||||
if (!ready) return;
|
||||
if (!canResetTree(state)) return;
|
||||
const updated = resetEvolutionTree(state);
|
||||
saveLocal(updated);
|
||||
state = updated;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function doUpgradeConvergence() {
|
||||
if (!ready) return;
|
||||
const updated = upgradeConvergence(state);
|
||||
if (!updated) return;
|
||||
saveLocal(updated);
|
||||
state = updated;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function doClaimMilestone(milestoneId: string) {
|
||||
if (!ready) return;
|
||||
const updated = claimMilestoneFn(state, milestoneId);
|
||||
if (!updated) return;
|
||||
saveLocal(updated);
|
||||
toast('Milestone debloque !', 'reward', 4000);
|
||||
state = updated;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
|
||||
saveLocal(fresh);
|
||||
state = fresh;
|
||||
playSeconds = 0;
|
||||
ready = true;
|
||||
offlineReport = null;
|
||||
canPrestige = false;
|
||||
productionPerSecond = 0;
|
||||
}
|
||||
|
||||
function loadFromServer(serverState: GameState) {
|
||||
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
|
||||
const result = hydrateWithOffline(migrated, Date.now());
|
||||
saveLocal(result.state);
|
||||
state = result.state;
|
||||
ready = true;
|
||||
offlineReport = result.report;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function initGuest() {
|
||||
const local = loadLocalState();
|
||||
const result = hydrateWithOffline(local, Date.now());
|
||||
saveLocal(result.state);
|
||||
state = result.state;
|
||||
ready = true;
|
||||
offlineReport = result.report;
|
||||
updateDerived();
|
||||
}
|
||||
|
||||
function dismissOfflineReport() {
|
||||
offlineReport = null;
|
||||
}
|
||||
|
||||
function openPrestige() {
|
||||
showPrestigeScreen = true;
|
||||
}
|
||||
|
||||
function closePrestige() {
|
||||
showPrestigeScreen = false;
|
||||
}
|
||||
|
||||
// --- Public API (single object export for ergonomic access) ---
|
||||
|
||||
export const gameStore = {
|
||||
get state() { return state; },
|
||||
get playSeconds() { return playSeconds; },
|
||||
get ready() { return ready; },
|
||||
get offlineReport() { return offlineReport; },
|
||||
get showPrestigeScreen() { return showPrestigeScreen; },
|
||||
get lastClickGain() { return lastClickGain; },
|
||||
get lastClickDouble() { return lastClickDouble; },
|
||||
get lastClickCrit() { return lastClickCrit; },
|
||||
get canPrestige() { return canPrestige; },
|
||||
get productionPerSecond() { return productionPerSecond; },
|
||||
|
||||
tick,
|
||||
click,
|
||||
buy,
|
||||
buyNode,
|
||||
prestige,
|
||||
equipCosmetic,
|
||||
unequipCosmetic,
|
||||
resetTree: doResetTree,
|
||||
upgradeConvergence: doUpgradeConvergence,
|
||||
claimMilestone: doClaimMilestone,
|
||||
reset,
|
||||
loadFromServer,
|
||||
initGuest,
|
||||
dismissOfflineReport,
|
||||
openPrestige,
|
||||
closePrestige,
|
||||
generatorCost: genCost,
|
||||
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, state.evolutionTree),
|
||||
getClickGain: () => getClickGain(state),
|
||||
};
|
||||
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,72 +0,0 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import Landing from "./pages/Landing";
|
||||
import Home from "./pages/Home";
|
||||
import ErrorPage from "./pages/404";
|
||||
import Login from "./pages/Login";
|
||||
import AuthCallback from "./pages/AuthCallback";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import Achievements from "./pages/Achievements";
|
||||
import Settings from "./pages/Settings";
|
||||
import Legal from "./pages/Legal";
|
||||
import Cookie from "./pages/Cookie";
|
||||
import Guide from "./pages/Guide";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
element: <Landing />,
|
||||
},
|
||||
{
|
||||
path: "/jeu",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/achievements",
|
||||
element: <Achievements />,
|
||||
},
|
||||
{
|
||||
path: "/mentionslegales",
|
||||
element: <Legal />,
|
||||
},
|
||||
{
|
||||
path: "/cookies",
|
||||
element: <Cookie />,
|
||||
},
|
||||
{
|
||||
path: "/guide",
|
||||
element: <Guide />,
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
element: <Settings />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: "/callback",
|
||||
element: <AuthCallback />,
|
||||
},
|
||||
{
|
||||
path: "*",
|
||||
element: <ErrorPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
|
||||
root.render(
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
);
|
||||
@@ -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,105 +0,0 @@
|
||||
// Guide.tsx — Guide joueur in-game
|
||||
|
||||
export default function Guide() {
|
||||
return (
|
||||
<div className="container" style={{ color: "var(--color-grey)" }}>
|
||||
<h1>Guide du Gardien</h1>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Le Marais</h2>
|
||||
<p className="paragraphe">
|
||||
Tu es le <strong>Gardien du Marais</strong>. Les tetards naissent sous tes clics,
|
||||
grandissent grace a tes generateurs, et evoluent a chaque nouvelle generation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Boucle de jeu</h2>
|
||||
<p className="paragraphe">
|
||||
<strong>1. Clique</strong> pour pondre des tetards. Achete des <strong>generateurs</strong> (Nid, Mare, Marecage...)
|
||||
qui produisent des tetards automatiquement.
|
||||
</p>
|
||||
<p className="paragraphe">
|
||||
<strong>2. Prestige</strong> quand tu atteins 1M de tetards. Tu perds tes tetards et generateurs,
|
||||
mais tu gagnes de l'<strong>ADN Ancestral</strong> et un multiplicateur permanent.
|
||||
Chaque generation est plus rapide que la precedente.
|
||||
</p>
|
||||
<p className="paragraphe">
|
||||
<strong>3. Arbre d'Evolution</strong> — depense ton ADN dans 3 branches :
|
||||
</p>
|
||||
<ul className="paragraphe" style={{ marginLeft: "1.5rem" }}>
|
||||
<li><strong>Ponte</strong> — booste tes clics, double ponte, critiques</li>
|
||||
<li><strong>Marais</strong> — booste la production des generateurs</li>
|
||||
<li><strong>Adaptation</strong> — bonus offline, ADN bonus, seuil prestige reduit</li>
|
||||
</ul>
|
||||
<p className="paragraphe">
|
||||
Chaque branche a un <strong>capstone</strong> (noeud final puissant) et des
|
||||
<strong> post-capstones</strong> achetables a l'infini pour une progression endless.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Capstones</h2>
|
||||
<ul className="paragraphe" style={{ marginLeft: "1.5rem" }}>
|
||||
<li><strong>Ponte Automatique</strong> — auto-click 1/s qui scale avec les upgrades</li>
|
||||
<li><strong>Symbiose Totale</strong> — chaque type de generateur booste les autres</li>
|
||||
<li><strong>Memoire du Marais</strong> — offline cap passe a 75%, duree 8h</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Convergence</h2>
|
||||
<p className="paragraphe">
|
||||
Quand tu as debloque un capstone + des noeuds d'une 2e branche, tu peux acheter
|
||||
<strong> Convergence Alpha</strong> (+10% a tous les effets).
|
||||
Avec 2 capstones, elle evolue en <strong>Convergence Omega</strong> (-20% cout post-capstones).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Reset d'arbre</h2>
|
||||
<p className="paragraphe">
|
||||
Tu peux reinitialiser ton arbre pour tester d'autres builds.
|
||||
<strong> 1 reset gratuit par prestige</strong>, puis 5 ADN par reset supplementaire.
|
||||
L'ADN investi est entierement rembourse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Milestones</h2>
|
||||
<p className="paragraphe">
|
||||
8 paliers de prestige (de 1 a 100) qui debloquent des <strong>cosmetiques exclusifs</strong> et
|
||||
des <strong>bonus gameplay legers</strong> :
|
||||
</p>
|
||||
<ul className="paragraphe" style={{ marginLeft: "1.5rem" }}>
|
||||
<li>1 prestige — Ruban queue</li>
|
||||
<li>3 prestiges — Titre "Gardien Recurrent"</li>
|
||||
<li>5 prestiges — 1 Nid gratuit au depart</li>
|
||||
<li>10 prestiges — Couronne doree</li>
|
||||
<li>15 prestiges — +5% offline permanent</li>
|
||||
<li>25 prestiges — Cape d'algues ancestrales</li>
|
||||
<li>50 prestiges — Queue enflamee + particules</li>
|
||||
<li>100 prestiges — Skin Tetard Primordial</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Cosmetiques</h2>
|
||||
<p className="paragraphe">
|
||||
Les cosmetiques sont purement visuels — <strong>zero pay-to-win</strong>.
|
||||
Debloque-les via les achievements et les milestones prestige.
|
||||
5 slots : chapeau, yeux, corps, queue, accessoire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<h2 className="subtitle">Offline</h2>
|
||||
<p className="paragraphe">
|
||||
Quand tu fermes le jeu, le marais continue de produire.
|
||||
Efficacite : 100% les 15 premieres minutes, puis degressive jusqu'a 0% a 2h.
|
||||
Les noeuds d'arbre et milestones peuvent augmenter le cap offline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
import { useGameStore } from "../store/useGameStore";
|
||||
import { formatNumber } from "../utils/formatNumber";
|
||||
import { getClickGain } from "../core/economy";
|
||||
import { GeneratorShop } from "../components/GeneratorShop";
|
||||
import { PrestigePanel } from "../components/PrestigePanel";
|
||||
import { EvolutionTree } from "../components/EvolutionTree";
|
||||
import { MilestoneBar } from "../components/MilestoneBar";
|
||||
import { CockpitHeader } from "../components/CockpitHeader";
|
||||
import { TadpoleSprite } from "../components/TadpoleSprite";
|
||||
import { CosmeticsPanel } from "../components/CosmeticsPanel";
|
||||
import { PrestigeScreen } from "../components/PrestigeScreen";
|
||||
import { MilestonesPanel } from "../components/MilestonesPanel";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
|
||||
export default function Home() {
|
||||
const [toggleRain] = useOutletContext();
|
||||
const ready = useGameStore((s) => s.ready);
|
||||
const click = useGameStore((s) => s.click);
|
||||
const resources = useGameStore((s) => s.state.resources);
|
||||
const state = useGameStore((s) => s.state);
|
||||
const clickGain = getClickGain(state);
|
||||
|
||||
const lastClickGain = useGameStore((s) => s.lastClickGain);
|
||||
const lastClickDouble = useGameStore((s) => s.lastClickDouble);
|
||||
const lastClickCrit = useGameStore((s) => s.lastClickCrit);
|
||||
|
||||
const createParticle = useCallback((clientX, clientY, gain, isDouble, isCrit) => {
|
||||
const particle = document.createElement("span");
|
||||
particle.className = "click-particle";
|
||||
const prefix = isCrit ? "CRIT " : isDouble ? "x2 " : "";
|
||||
particle.textContent = `${prefix}+${formatNumber(gain)}`;
|
||||
if (isCrit) particle.style.color = "#f59e0b";
|
||||
else if (isDouble) particle.style.color = "#a78bfa";
|
||||
particle.style.left = `${clientX}px`;
|
||||
particle.style.top = `${clientY}px`;
|
||||
document.body.appendChild(particle);
|
||||
setTimeout(() => {
|
||||
if (particle.parentNode) particle.parentNode.removeChild(particle);
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
const handleIncrement = useCallback((e) => {
|
||||
click();
|
||||
// Read latest click result from store after click
|
||||
const s = useGameStore.getState();
|
||||
createParticle(e.clientX, e.clientY, s.lastClickGain, s.lastClickDouble, s.lastClickCrit);
|
||||
}, [click, createParticle]);
|
||||
|
||||
// Rain effect (ambiance)
|
||||
useEffect(() => {
|
||||
const rain = {
|
||||
wind: 0, maxXrange: 40, minXrange: 20, maxSpeed: 1, minSpeed: 3,
|
||||
color: "#8ecae6", char: "°", maxSize: 28, minSize: 8,
|
||||
flakes: [], WIDTH: -10, HEIGHT: 0, running: false,
|
||||
init(nb) {
|
||||
const frag = document.createDocumentFragment();
|
||||
this.getSize();
|
||||
this.running = true;
|
||||
for (let i = 0; i < nb; i++) {
|
||||
const flake = {
|
||||
x: this.random(this.WIDTH), y: -this.maxSize,
|
||||
xrange: this.minXrange + this.random(this.maxXrange - this.minXrange),
|
||||
yspeed: this.minSpeed + this.random(this.maxSpeed - this.minSpeed, 100),
|
||||
life: 0, size: this.minSize + this.random(this.maxSize - this.minSize),
|
||||
html: document.createElement("span"),
|
||||
};
|
||||
Object.assign(flake.html.style, {
|
||||
position: "absolute", top: `${flake.y}px`, left: `${flake.x}px`,
|
||||
fontSize: `${flake.size}px`, color: this.color, userSelect: "none", overflow: "hidden",
|
||||
});
|
||||
flake.html.appendChild(document.createTextNode(this.char));
|
||||
frag.appendChild(flake.html);
|
||||
this.flakes.push(flake);
|
||||
}
|
||||
document.body.appendChild(frag);
|
||||
this.animate();
|
||||
window.onresize = () => this.getSize();
|
||||
},
|
||||
animate() {
|
||||
if (!this.running) return;
|
||||
for (const flake of this.flakes) {
|
||||
const top = flake.y + flake.yspeed;
|
||||
const left = flake.x + Math.sin(flake.life) * flake.xrange + this.wind;
|
||||
if (top < this.HEIGHT - flake.size - 10 && left < this.WIDTH - flake.size && left > 0) {
|
||||
flake.html.style.top = `${top}px`;
|
||||
flake.html.style.left = `${left}px`;
|
||||
flake.y = top;
|
||||
flake.x += this.wind;
|
||||
flake.life += 0.01;
|
||||
} else {
|
||||
flake.html.style.top = `${-this.maxSize}px`;
|
||||
flake.x = this.random(this.WIDTH);
|
||||
flake.y = -this.maxSize;
|
||||
flake.html.style.left = `${flake.x}px`;
|
||||
flake.life = 0;
|
||||
}
|
||||
}
|
||||
setTimeout(() => this.animate(), 20);
|
||||
},
|
||||
stop() {
|
||||
this.running = false;
|
||||
for (const flake of this.flakes) {
|
||||
if (flake.html.parentNode) flake.html.parentNode.removeChild(flake.html);
|
||||
}
|
||||
this.flakes = [];
|
||||
},
|
||||
random(range, num = 1) {
|
||||
return Math.floor(Math.random() * (range + 1) * num) / num;
|
||||
},
|
||||
getSize() {
|
||||
this.WIDTH = document.body.clientWidth || window.innerWidth;
|
||||
this.HEIGHT = document.body.clientHeight || window.innerHeight;
|
||||
},
|
||||
};
|
||||
|
||||
if (toggleRain) rain.init(10);
|
||||
return () => rain.stop();
|
||||
}, [toggleRain]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<section className="game-container">
|
||||
<p className="text-center text-slate-400 mt-[20vh]">
|
||||
Chargement de ta progression...
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="zone" data-zone="swamp">
|
||||
<Helmet>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
</Helmet>
|
||||
|
||||
<PrestigeScreen />
|
||||
|
||||
{/* Clicker area — centre */}
|
||||
<div className="click-zone" onClick={handleIncrement}>
|
||||
<TadpoleSprite />
|
||||
<div className="click-zone-counter">
|
||||
{formatNumber(resources)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cockpit — sidebar structurée en zones */}
|
||||
<aside className="game-sidebar">
|
||||
<CockpitHeader />
|
||||
<div className="gp-sep" />
|
||||
<MilestoneBar />
|
||||
<GeneratorShop />
|
||||
<div className="gp-sep" />
|
||||
<PrestigePanel />
|
||||
<MilestonesPanel />
|
||||
<EvolutionTree />
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" className="achieve-badge">
|
||||
{ACHIEVEMENTS.filter((a) => a.check(useGameStore.getState().state)).length}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
<a href="/guide" className="achieve-badge" style={{ borderColor: "rgba(139, 92, 246, 0.2)", background: "rgba(139, 92, 246, 0.08)", color: "#a78bfa" }}>
|
||||
Guide du Gardien
|
||||
</a>
|
||||
</aside>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 { gameStore } from '$lib/stores/game.svelte';
|
||||
import { ACHIEVEMENTS } from '$lib/data/achievements';
|
||||
|
||||
let filter = $state<'all' | 'unlocked' | 'locked'>('all');
|
||||
|
||||
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(gameStore.state)));
|
||||
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(gameStore.state)));
|
||||
|
||||
let displayed = $derived(
|
||||
filter === 'unlocked' ? unlocked
|
||||
: filter === 'locked' ? locked
|
||||
: [...unlocked, ...locked]
|
||||
);
|
||||
|
||||
let progressPct = $derived(Math.round((unlocked.length / ACHIEVEMENTS.length) * 100));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Succes — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fullachieve">
|
||||
<!-- Header -->
|
||||
<div in:fly={{ y: -20, duration: 400, easing: quintOut }}>
|
||||
<h1>Succes</h1>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="max-w-xs mx-auto w-full mb-6"
|
||||
in:scale={{ delay: 100, duration: 300, start: 0.9 }}
|
||||
>
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="achieve-counter mb-0!">{unlocked.length} / {ACHIEVEMENTS.length}</span>
|
||||
<span class="achieve-counter mb-0!">{progressPct}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full overflow-hidden" style="background: rgba(0,0,0,0.1);">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-700 ease-out"
|
||||
style="width: {progressPct}%; background: linear-gradient(90deg, #059669, #34d399);"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div
|
||||
class="flex gap-1 justify-center mb-6"
|
||||
in:fade={{ delay: 200, duration: 300 }}
|
||||
>
|
||||
{#each [
|
||||
{ id: 'all', label: `Tous (${ACHIEVEMENTS.length})` },
|
||||
{ id: 'unlocked', label: `Debloques (${unlocked.length})` },
|
||||
{ id: 'locked', label: `Verrouilles (${locked.length})` },
|
||||
] as tab}
|
||||
<button
|
||||
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
style="
|
||||
font-family: var(--font);
|
||||
{filter === tab.id
|
||||
? 'background: var(--color-grey); color: white;'
|
||||
: 'background: rgba(0,0,0,0.06); color: var(--color-grey); opacity: 0.7;'
|
||||
}
|
||||
"
|
||||
onclick={() => filter = tab.id as typeof filter}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
{#key filter}
|
||||
<div class="achievementscardcontainer">
|
||||
{#each displayed as a, i}
|
||||
{@const isUnlocked = unlocked.includes(a)}
|
||||
<div
|
||||
class="achieve-card {isUnlocked ? 'achieve-unlocked' : 'achieve-locked'}"
|
||||
in:fly={{ y: 20, delay: Math.min(i * 40, 400), duration: 300, easing: quintOut }}
|
||||
>
|
||||
<span class="achieve-icon">{isUnlocked ? a.icon : '🔒'}</span>
|
||||
<div class="achieve-info">
|
||||
<p class="achieve-name">{a.name}</p>
|
||||
<p class="achieve-desc">{isUnlocked ? a.description : '???'}</p>
|
||||
</div>
|
||||
{#if isUnlocked}
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full shrink-0"
|
||||
style="background: rgba(16,185,129,0.15); color: #34d399; font-family: var(--font);"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
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>
|
||||
148
Frontend/src/routes/guide/+page.svelte
Normal file
148
Frontend/src/routes/guide/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { fly, slide, fade } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
interface Section {
|
||||
icon: string;
|
||||
title: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
icon: '🏞',
|
||||
title: 'Le Marais',
|
||||
content: [
|
||||
'Tu es le **Gardien du Marais**. Les tetards naissent sous tes clics, grandissent grace a tes generateurs, et evoluent a chaque nouvelle generation.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'Boucle de jeu',
|
||||
content: [
|
||||
'**1. Clique** pour pondre des tetards. Achete des **generateurs** (Nid, Mare, Marecage...) qui produisent des tetards automatiquement.',
|
||||
'**2. Prestige** quand tu atteins 1M de tetards. Tu perds tes tetards et generateurs, mais tu gagnes de l\'**ADN Ancestral** et un multiplicateur permanent.',
|
||||
'**3. Arbre d\'Evolution** — depense ton ADN dans 3 branches : Ponte (clics), Marais (production), Adaptation (offline/ADN).',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '★',
|
||||
title: 'Capstones',
|
||||
content: [
|
||||
'**Ponte Automatique** — auto-click 1/s qui scale avec les upgrades',
|
||||
'**Symbiose Totale** — chaque type de generateur booste les autres',
|
||||
'**Memoire du Marais** — offline cap passe a 75%, duree 8h',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🧬',
|
||||
title: 'Convergence',
|
||||
content: [
|
||||
'Avec un capstone + des noeuds d\'une 2e branche → **Convergence Alpha** (+10% tous effets).',
|
||||
'Avec 2 capstones → evolue en **Convergence Omega** (-20% cout post-capstones).',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🏆',
|
||||
title: 'Milestones',
|
||||
content: [
|
||||
'8 paliers de prestige (1 a 100). Recompenses :',
|
||||
'1 → Ruban queue | 3 → Titre | 5 → 1 Nid gratuit | 10 → Couronne',
|
||||
'15 → +5% offline | 25 → Cape | 50 → Queue enflamee | 100 → Skin Primordial',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '✨',
|
||||
title: 'Cosmetiques',
|
||||
content: [
|
||||
'Purement visuels — **zero pay-to-win**. 5 slots : chapeau, yeux, corps, queue, accessoire.',
|
||||
'Debloques via achievements et milestones prestige.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🌙',
|
||||
title: 'Offline',
|
||||
content: [
|
||||
'Le marais continue de produire quand tu fermes le jeu.',
|
||||
'Efficacite : 100% (0-15min) → degressive → 0% a 2h.',
|
||||
'Les noeuds Adaptation et milestones augmentent le cap et la duree.',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '🔃',
|
||||
title: 'Reset d\'arbre',
|
||||
content: [
|
||||
'Reinitialise ton arbre pour tester d\'autres builds.',
|
||||
'**1 gratuit par prestige**, puis 5 ADN par reset supplementaire. L\'ADN investi est rembourse.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
let openSections = $state<Set<number>>(new Set([0, 1]));
|
||||
|
||||
function toggle(idx: number) {
|
||||
const next = new Set(openSections);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
openSections = next;
|
||||
}
|
||||
|
||||
function renderBold(text: string): string {
|
||||
return text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Guide du Gardien — Clickerz</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container" style="color: var(--color-grey); max-width: 720px;">
|
||||
<h1 in:fly={{ y: -20, duration: 400, easing: quintOut }}>Guide du Gardien</h1>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each sections as section, i}
|
||||
<div
|
||||
class="rounded-xl overflow-hidden transition-all duration-200"
|
||||
style="background: rgba(0,0,0,0.03); border: 1px solid rgba(0,0,0,0.06);"
|
||||
in:fly={{ y: 15, delay: i * 60, duration: 300, easing: quintOut }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<button
|
||||
class="flex items-center gap-3 w-full px-5 py-3.5 cursor-pointer text-left group"
|
||||
style="font-family: var(--font);"
|
||||
onclick={() => toggle(i)}
|
||||
aria-expanded={openSections.has(i)}
|
||||
>
|
||||
<span class="text-xl">{section.icon}</span>
|
||||
<span class="text-base font-semibold flex-1" style="color: var(--color-grey);">
|
||||
{section.title}
|
||||
</span>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform duration-200 opacity-40 group-hover:opacity-70"
|
||||
class:rotate-180={openSections.has(i)}
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
{#if openSections.has(i)}
|
||||
<div transition:slide={{ duration: 250, easing: quintOut }}>
|
||||
<div class="flex flex-col gap-2 px-5 pb-4 pl-14">
|
||||
{#each section.content as line}
|
||||
<p
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--color-grey); opacity: 0.8; font-family: var(--font);"
|
||||
>
|
||||
{@html renderBold(line)}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
151
Frontend/src/routes/jeu/+page.svelte
Normal file
151
Frontend/src/routes/jeu/+page.svelte
Normal file
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale, fade } from 'svelte/transition';
|
||||
import { quintOut, elasticOut } from 'svelte/easing';
|
||||
import { gameStore } from '$lib/stores/game.svelte';
|
||||
import { formatNumber } from '$lib/utils/formatNumber';
|
||||
import CockpitHeader from '$lib/components/CockpitHeader.svelte';
|
||||
import GeneratorShop from '$lib/components/GeneratorShop.svelte';
|
||||
import PrestigePanel from '$lib/components/PrestigePanel.svelte';
|
||||
import EvolutionTree from '$lib/components/EvolutionTree.svelte';
|
||||
import MilestoneBar from '$lib/components/MilestoneBar.svelte';
|
||||
import MilestonesPanel from '$lib/components/MilestonesPanel.svelte';
|
||||
import CosmeticsPanel from '$lib/components/CosmeticsPanel.svelte';
|
||||
import TadpoleSprite from '$lib/components/TadpoleSprite.svelte';
|
||||
import PrestigeScreen from '$lib/components/PrestigeScreen.svelte';
|
||||
import ClickParticles from '$lib/components/ClickParticles.svelte';
|
||||
import SidebarTabs from '$lib/components/SidebarTabs.svelte';
|
||||
import { ACHIEVEMENTS } from '$lib/data/achievements';
|
||||
|
||||
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(gameStore.state)).length);
|
||||
|
||||
const sidebarTabs = [
|
||||
{ id: 'production', label: 'Production', icon: '🏭' },
|
||||
{ id: 'evolution', label: 'Evolution', icon: '🧬' },
|
||||
{ id: 'collection', label: 'Collection', icon: '✨' },
|
||||
];
|
||||
|
||||
// Component refs for imperative calls
|
||||
let clickParticles: ReturnType<typeof ClickParticles>;
|
||||
let tadpoleSprite: ReturnType<typeof TadpoleSprite>;
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
gameStore.click();
|
||||
clickParticles?.spawn(e.clientX, e.clientY, gameStore.lastClickGain, gameStore.lastClickDouble, gameStore.lastClickCrit);
|
||||
tadpoleSprite?.bounce();
|
||||
}
|
||||
|
||||
// Mobile sidebar toggle
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Clickerz — Tetard Universe</title>
|
||||
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
|
||||
</svelte:head>
|
||||
|
||||
{#if !gameStore.ready}
|
||||
<div class="flex items-center justify-center min-h-[80vh]" in:fade>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-16 h-16 border-4 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin"></div>
|
||||
<p class="text-slate-400" style="font-family: var(--font);">Chargement de ta progression...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="zone" data-zone="swamp" in:fade={{ duration: 400 }}>
|
||||
<PrestigeScreen />
|
||||
<ClickParticles bind:this={clickParticles} />
|
||||
|
||||
<!-- Click zone -->
|
||||
<div class="click-zone" onclick={handleClick}>
|
||||
<div in:scale={{ duration: 500, start: 0.8, easing: elasticOut }}>
|
||||
<TadpoleSprite bind:this={tadpoleSprite} />
|
||||
</div>
|
||||
<div
|
||||
class="click-zone-counter"
|
||||
in:fly={{ y: 20, duration: 400, easing: quintOut }}
|
||||
>
|
||||
{formatNumber(gameStore.state.resources)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<aside class="game-sidebar hidden md:flex" in:fly={{ x: 100, duration: 500, easing: quintOut }}>
|
||||
<!-- Pinned: always visible -->
|
||||
<CockpitHeader />
|
||||
<MilestoneBar />
|
||||
|
||||
<!-- Tabbed content -->
|
||||
<SidebarTabs tabs={sidebarTabs}>
|
||||
{#snippet children(activeTab)}
|
||||
{#if activeTab === 'production'}
|
||||
<GeneratorShop />
|
||||
<PrestigePanel />
|
||||
{:else if activeTab === 'evolution'}
|
||||
<EvolutionTree />
|
||||
<MilestonesPanel />
|
||||
{:else if activeTab === 'collection'}
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" class="achieve-badge">
|
||||
{achieveCount}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
<a href="/guide" class="achieve-badge" style="border-color: rgba(139, 92, 246, 0.2); background: rgba(139, 92, 246, 0.08); color: #a78bfa;">
|
||||
Guide du Gardien
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarTabs>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom bar: toggle button -->
|
||||
<button
|
||||
class="md:hidden fixed bottom-4 right-4 z-30 w-14 h-14 rounded-full flex items-center justify-center shadow-xl"
|
||||
style="background: rgba(17,17,17,0.9); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.1);"
|
||||
onclick={() => sidebarOpen = !sidebarOpen}
|
||||
>
|
||||
<span class="text-2xl">{sidebarOpen ? '✕' : '🎮'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
{#if sidebarOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="md:hidden fixed inset-0 z-20"
|
||||
style="background: rgba(0,0,0,0.5);"
|
||||
transition:fade={{ duration: 200 }}
|
||||
onclick={() => sidebarOpen = false}
|
||||
></div>
|
||||
<div
|
||||
class="md:hidden fixed bottom-0 left-0 right-0 z-25 flex flex-col gap-3 max-h-[75vh] overflow-y-auto rounded-t-2xl p-4 pb-20"
|
||||
style="background: rgba(10,10,10,0.95); backdrop-filter: blur(12px); border-top: 1px solid rgba(255,255,255,0.08);"
|
||||
transition:fly={{ y: 300, duration: 350, easing: quintOut }}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-10 h-1 rounded-full" style="background: rgba(255,255,255,0.2);"></div>
|
||||
</div>
|
||||
|
||||
<CockpitHeader />
|
||||
<MilestoneBar />
|
||||
|
||||
<SidebarTabs tabs={sidebarTabs}>
|
||||
{#snippet children(activeTab)}
|
||||
{#if activeTab === 'production'}
|
||||
<GeneratorShop />
|
||||
<PrestigePanel />
|
||||
{:else if activeTab === 'evolution'}
|
||||
<EvolutionTree />
|
||||
<MilestonesPanel />
|
||||
{:else if activeTab === 'collection'}
|
||||
<CosmeticsPanel />
|
||||
<a href="/achievements" class="achieve-badge">
|
||||
{achieveCount}/{ACHIEVEMENTS.length} succes
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SidebarTabs>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
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