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:
@@ -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}
|
||||
68
Frontend/src/lib/core/balance.ts
Normal file
68
Frontend/src/lib/core/balance.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// balance.ts — Constantes d'équilibrage centralisées
|
||||
// Toutes les valeurs de tuning en un seul fichier pour faciliter le playtest.
|
||||
// Sprint 3 — session brainstorm 2026-03-28
|
||||
|
||||
// --- Formule ADN prestige ---
|
||||
|
||||
export const PRESTIGE_ADN_BASE = 50;
|
||||
export const PRESTIGE_ADN_THRESHOLD = 1e6; // 1M têtards minimum pour prestige
|
||||
export const PRESTIGE_BONUS_PER_PRESTIGE = 0.05; // +5% par prestige
|
||||
export const PRESTIGE_BONUS_CAP = 3.0; // cap à ×4 total (80 prestiges)
|
||||
export const PRESTIGE_ADN_MIN = 1; // clamp : jamais 0 ADN si seuil atteint
|
||||
|
||||
// --- Seuil prestige ---
|
||||
|
||||
export const BASE_PRESTIGE_THRESHOLD = 1_000_000; // 1M têtards
|
||||
|
||||
// --- Post-capstone scaling par tranche ---
|
||||
|
||||
export const POST_CAPSTONE_TIERS = [
|
||||
{ maxPurchases: 5, multiplier: 1.5 }, // achats 1-5
|
||||
{ maxPurchases: 10, multiplier: 1.8 }, // achats 6-10
|
||||
{ maxPurchases: Infinity, multiplier: 2.0 }, // achats 11+
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Calcule le coût du N-ième achat post-capstone repeatable (0-indexed).
|
||||
* Scaling par tranche : ×1.5 (achats 0-4), ×1.8 (5-9), ×2.0 (10+)
|
||||
*/
|
||||
export function postCapstoneCost(baseCost: number, purchased: number): number {
|
||||
let cost = baseCost;
|
||||
for (let i = 0; i < purchased; i++) {
|
||||
if (i < 5) cost *= 1.5;
|
||||
else if (i < 10) cost *= 1.8;
|
||||
else cost *= 2.0;
|
||||
}
|
||||
return Math.floor(cost);
|
||||
}
|
||||
|
||||
// --- Reset arbre ---
|
||||
|
||||
export const TREE_RESET_FREE_PER_PRESTIGE = 1; // 1 gratuit par prestige
|
||||
export const TREE_RESET_EXTRA_COST = 5; // 5 ADN × n pour les resets supplémentaires
|
||||
|
||||
/**
|
||||
* Coût du prochain reset arbre.
|
||||
* 1 gratuit par prestige, puis linéaire (5 × n) au-delà.
|
||||
*/
|
||||
export function treeResetCost(freeResetAvailable: boolean, extraResetsUsed: number): number {
|
||||
if (freeResetAvailable) return 0;
|
||||
return TREE_RESET_EXTRA_COST * (extraResetsUsed + 1);
|
||||
}
|
||||
|
||||
// --- Offline ---
|
||||
|
||||
export const OFFLINE_THRESHOLD_MS = 60_000; // 60s
|
||||
export const OFFLINE_FULL_MS = 15 * 60_000; // 0-15min : 100%
|
||||
export const OFFLINE_DECAY_END_MS = 60 * 60_000; // 15min-1h : 100% → 25%
|
||||
export const OFFLINE_ZERO_MS = 2 * 60 * 60_000; // 1h-2h : 25% → 0%
|
||||
export const OFFLINE_FLOOR = 0.25; // plancher decay
|
||||
|
||||
// --- Anti-cheat ---
|
||||
|
||||
export const MAX_PRODUCTION_PER_SECOND = 750_000;
|
||||
export const CHEAT_MARGIN = 1.1;
|
||||
|
||||
// --- Save version ---
|
||||
|
||||
export const CURRENT_SAVE_VERSION = 2;
|
||||
97
Frontend/src/lib/core/cosmetics.ts
Normal file
97
Frontend/src/lib/core/cosmetics.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// cosmetics.ts — Système cosmétique (récompenses achievements + prestige)
|
||||
|
||||
import type { GameState } from "./economy";
|
||||
import { ACHIEVEMENTS } from "../data/achievements";
|
||||
|
||||
export type CosmeticSlot = "hat" | "eyes" | "body" | "tail" | "accessory";
|
||||
|
||||
export interface Cosmetic {
|
||||
id: string;
|
||||
name: string;
|
||||
slot: CosmeticSlot;
|
||||
svg: string; // chemin vers le SVG overlay (/svg/cosmetics/...)
|
||||
source: "achievement" | "prestige";
|
||||
sourceId: string; // achievement id ou "prestige_N"
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CosmeticState {
|
||||
inventory: string[]; // ids des cosmétiques débloqués
|
||||
equipped: Partial<Record<CosmeticSlot, string>>; // slot → cosmetic id
|
||||
}
|
||||
|
||||
export const DEFAULT_COSMETIC_STATE: CosmeticState = {
|
||||
inventory: [],
|
||||
equipped: {},
|
||||
};
|
||||
|
||||
// --- Catalogue des cosmétiques ---
|
||||
|
||||
export const COSMETICS: Cosmetic[] = [
|
||||
// Hat
|
||||
{ id: "crown", name: "Couronne Ancestrale", slot: "hat", svg: "/svg/cosmetics/crown.svg", source: "prestige", sourceId: "prestige_10", description: "10 prestiges — la royauté du marais" },
|
||||
{ id: "cap_swamp", name: "Casquette du Marais", slot: "hat", svg: "/svg/cosmetics/cap-swamp.svg", source: "achievement", sourceId: "industriel", description: "10 générateurs au total" },
|
||||
|
||||
// Eyes
|
||||
{ id: "glasses_savant", name: "Lunettes du Savant", slot: "eyes", svg: "/svg/cosmetics/glasses-savant.svg", source: "prestige", sourceId: "prestige_5", description: "5 prestiges — la sagesse" },
|
||||
{ id: "mask_frog", name: "Masque Grenouille", slot: "eyes", svg: "/svg/cosmetics/mask-frog.svg", source: "achievement", sourceId: "empire", description: "1M têtards — le regard de l'empire" },
|
||||
|
||||
// Body
|
||||
{ id: "cape_algae", name: "Cape d'Algues", slot: "body", svg: "/svg/cosmetics/cape-algae.svg", source: "prestige", sourceId: "prestige_25", description: "25 prestiges — tissée par le marais" },
|
||||
{ id: "armor_scales", name: "Armure d'Écailles", slot: "body", svg: "/svg/cosmetics/armor-scales.svg", source: "achievement", sourceId: "tycoon", description: "100 générateurs — blindage total" },
|
||||
|
||||
// Tail
|
||||
{ id: "flame_tail", name: "Queue Enflammée", slot: "tail", svg: "/svg/cosmetics/flame-tail.svg", source: "prestige", sourceId: "prestige_50", description: "50 prestiges — la traîne de feu" },
|
||||
{ id: "ribbon", name: "Ruban du Nouveau-Né", slot: "tail", svg: "/svg/cosmetics/ribbon.svg", source: "achievement", sourceId: "first_prestige", description: "Premier prestige — le début de tout" },
|
||||
|
||||
// Accessory
|
||||
{ id: "aura_swamp", name: "Aura du Marais", slot: "accessory", svg: "/svg/aura-swamp.svg", source: "achievement", sourceId: "veteran", description: "5 prestiges — l'aura des anciens" },
|
||||
{ id: "particles_gold", name: "Particules Dorées", slot: "accessory", svg: "/svg/cosmetics/particles-gold.svg", source: "prestige", sourceId: "prestige_3", description: "3 prestiges — poussière d'étoiles" },
|
||||
];
|
||||
|
||||
// --- Fonctions cosmétiques ---
|
||||
|
||||
// Vérifie si un cosmétique devrait être débloqué
|
||||
export function shouldUnlockCosmetic(cosmetic: Cosmetic, state: GameState): boolean {
|
||||
if (cosmetic.source === "prestige") {
|
||||
const tier = parseInt(cosmetic.sourceId.replace("prestige_", ""), 10);
|
||||
return state.prestigeCount >= tier;
|
||||
}
|
||||
if (cosmetic.source === "achievement") {
|
||||
const achievement = ACHIEVEMENTS.find((a) => a.id === cosmetic.sourceId);
|
||||
return achievement ? achievement.check(state) : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calcule les cosmétiques nouvellement débloqués (pas encore dans l'inventaire)
|
||||
export function computeNewUnlocks(state: GameState, cosmeticState: CosmeticState): string[] {
|
||||
return COSMETICS
|
||||
.filter((c) => !cosmeticState.inventory.includes(c.id) && shouldUnlockCosmetic(c, state))
|
||||
.map((c) => c.id);
|
||||
}
|
||||
|
||||
// Équiper un cosmétique (retourne le nouvel état)
|
||||
export function equipCosmetic(cosmeticState: CosmeticState, cosmeticId: string): CosmeticState {
|
||||
const cosmetic = COSMETICS.find((c) => c.id === cosmeticId);
|
||||
if (!cosmetic) return cosmeticState;
|
||||
if (!cosmeticState.inventory.includes(cosmeticId)) return cosmeticState;
|
||||
|
||||
return {
|
||||
...cosmeticState,
|
||||
equipped: { ...cosmeticState.equipped, [cosmetic.slot]: cosmeticId },
|
||||
};
|
||||
}
|
||||
|
||||
// Déséquiper un slot
|
||||
export function unequipSlot(cosmeticState: CosmeticState, slot: CosmeticSlot): CosmeticState {
|
||||
const { [slot]: _, ...rest } = cosmeticState.equipped;
|
||||
return { ...cosmeticState, equipped: rest };
|
||||
}
|
||||
|
||||
// Ajouter des cosmétiques à l'inventaire
|
||||
export function addToInventory(cosmeticState: CosmeticState, ids: string[]): CosmeticState {
|
||||
const newIds = ids.filter((id) => !cosmeticState.inventory.includes(id));
|
||||
if (newIds.length === 0) return cosmeticState;
|
||||
return { ...cosmeticState, inventory: [...cosmeticState.inventory, ...newIds] };
|
||||
}
|
||||
776
Frontend/src/lib/core/economy.ts
Normal file
776
Frontend/src/lib/core/economy.ts
Normal file
@@ -0,0 +1,776 @@
|
||||
// economy.ts — Core clicker logic (lazy calculation pattern)
|
||||
// Jamais de timer actif : tout est calculé au read depuis lastTick
|
||||
|
||||
import {
|
||||
PRESTIGE_ADN_BASE,
|
||||
PRESTIGE_ADN_THRESHOLD,
|
||||
PRESTIGE_BONUS_PER_PRESTIGE,
|
||||
PRESTIGE_BONUS_CAP,
|
||||
PRESTIGE_ADN_MIN,
|
||||
BASE_PRESTIGE_THRESHOLD,
|
||||
OFFLINE_THRESHOLD_MS as OFFLINE_THRESHOLD,
|
||||
OFFLINE_FULL_MS,
|
||||
OFFLINE_DECAY_END_MS,
|
||||
OFFLINE_ZERO_MS,
|
||||
OFFLINE_FLOOR,
|
||||
CURRENT_SAVE_VERSION,
|
||||
treeResetCost,
|
||||
postCapstoneCost,
|
||||
} from "./balance";
|
||||
import { PRESTIGE_MILESTONES } from "../data/prestigeMilestones";
|
||||
import type { PrestigeMilestone } from "../data/prestigeMilestones";
|
||||
|
||||
export interface Generator {
|
||||
id: string;
|
||||
name: string;
|
||||
baseCost: number;
|
||||
baseProduction: number; // ressource/s
|
||||
owned: number;
|
||||
}
|
||||
|
||||
export type EffectType =
|
||||
| "click_multiplier"
|
||||
| "production_multiplier"
|
||||
| "start_bonus"
|
||||
| "unlock_generator"
|
||||
| "double_click_chance"
|
||||
| "auto_click"
|
||||
| "crit_click_chance"
|
||||
| "generator_boost"
|
||||
| "cost_reduction"
|
||||
| "prestige_dna_bonus"
|
||||
| "offline_boost"
|
||||
| "prestige_threshold_reduction"
|
||||
// Sprint 3 — capstones
|
||||
| "auto_click_scaling" // Ponte Auto — auto-click scale avec upgrades
|
||||
| "generator_synergy" // Symbiose Totale — +X% par type possédé
|
||||
| "offline_cap_boost" // Mémoire du Marais — offline cap + durée
|
||||
// Sprint 3 — Convergence
|
||||
| "all_effects_boost" // +X% à tous les effets
|
||||
| "post_capstone_discount"; // -X% coût post-capstones
|
||||
|
||||
export type Branch = "ponte" | "marais" | "adaptation" | "cross";
|
||||
|
||||
export interface EvolutionNode {
|
||||
id: string;
|
||||
name: string;
|
||||
cost: number; // en ADN Ancestral (base cost for repeatables)
|
||||
effect: EffectType;
|
||||
value: number;
|
||||
unlocked: boolean;
|
||||
requires: string | null; // id du nœud prérequis (null = racine)
|
||||
branch: Branch;
|
||||
exclusive_with?: string; // id du nœud alternatif (pick one)
|
||||
// Sprint 3 — capstone & repeatable
|
||||
capstone?: boolean; // nœud capstone (bordure dorée, game-changer)
|
||||
repeatable?: boolean; // post-capstone achetable en boucle
|
||||
purchased?: number; // nombre d'achats pour les repeatables
|
||||
// Sprint 3 — Convergence (nœud évolutif)
|
||||
tier?: number; // tier actuel (1 = Alpha, 2 = Omega)
|
||||
maxTier?: number; // tier max
|
||||
tierUpgradeCost?: number; // coût upgrade au tier suivant
|
||||
tierUpgradeRequires?: string; // condition pour upgrade ("2_capstones")
|
||||
}
|
||||
|
||||
export interface CosmeticSlotMap {
|
||||
[slot: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface RunStats {
|
||||
startedAt: number; // timestamp ms début de la run
|
||||
tadpolesProduced: number; // têtards produits cette run (tracking granulaire)
|
||||
bestRun: {
|
||||
duration: number; // ms
|
||||
tadpoles: number;
|
||||
adn: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
saveVersion: number;
|
||||
resources: number;
|
||||
clickMultiplier: number;
|
||||
generators: Generator[];
|
||||
lastTick: number; // timestamp ms — lazy calc reference
|
||||
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
|
||||
prestigeCount: number;
|
||||
prestigeMultiplier: number; // legacy — conservé pour compat, remplacé par arbre
|
||||
ancestralDna: number;
|
||||
evolutionTree: EvolutionNode[];
|
||||
lifetimeTadpoles: number; // total cumulé de la run (pour calcul ADN)
|
||||
cosmeticInventory: string[]; // ids des cosmétiques débloqués
|
||||
cosmeticEquipped: CosmeticSlotMap; // slot → cosmetic id
|
||||
// Sprint 3
|
||||
runStats: RunStats;
|
||||
freeResetAvailable: boolean; // 1 gratuit par prestige
|
||||
extraResetsUsed: number; // resets payants dans la génération courante
|
||||
claimedMilestones: string[]; // IDs des milestones réclamés
|
||||
}
|
||||
|
||||
// --- Arbre d'Évolution ---
|
||||
|
||||
export const DEFAULT_EVOLUTION_TREE: EvolutionNode[] = [
|
||||
// ═══ PONTE (click) — 10 nœuds ═══
|
||||
|
||||
// Tier 1
|
||||
{ id: "ponte_amelioree", name: "Ponte Améliorée", cost: 1, effect: "click_multiplier", value: 2, unlocked: false, requires: null, branch: "ponte" },
|
||||
// Tier 2
|
||||
{ id: "double_ponte", name: "Double Ponte", cost: 5, effect: "double_click_chance", value: 0.10, unlocked: false, requires: "ponte_amelioree", branch: "ponte" },
|
||||
// Tier 3 (exclusif)
|
||||
{ id: "ponte_frenetique", name: "Frénésie", cost: 15, effect: "click_multiplier", value: 3, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "concentration" },
|
||||
{ id: "concentration", name: "Concentration", cost: 15, effect: "click_multiplier", value: 4, unlocked: false, requires: "double_ponte", branch: "ponte", exclusive_with: "ponte_frenetique" },
|
||||
// Tier 3 (parallèle)
|
||||
{ id: "ponte_critique", name: "Ponte Critique", cost: 20, effect: "crit_click_chance", value: 0.05, unlocked: false, requires: "double_ponte", branch: "ponte" },
|
||||
// Tier 4
|
||||
{ id: "maitre_pondeur", name: "Maître Pondeur", cost: 40, effect: "click_multiplier", value: 5, unlocked: false, requires: "ponte_critique", branch: "ponte" },
|
||||
// Capstone
|
||||
{ id: "ponte_auto", name: "Ponte Automatique", cost: 200, effect: "auto_click_scaling", value: 1, unlocked: false, requires: "maitre_pondeur", branch: "ponte", capstone: true },
|
||||
// Post-capstone (repeatable)
|
||||
{ id: "ponte_post", name: "+5% auto-ponte", cost: 500, effect: "auto_click", value: 0.05, unlocked: false, requires: "ponte_auto", branch: "ponte", repeatable: true, purchased: 0 },
|
||||
|
||||
// ═══ MARAIS (production) — 10 nœuds ═══
|
||||
|
||||
// Tier 1
|
||||
{ id: "instinct_gregaire", name: "Instinct Grégaire", cost: 3, effect: "production_multiplier", value: 1.5, unlocked: false, requires: null, branch: "marais" },
|
||||
// Tier 2
|
||||
{ id: "symbiose_algale", name: "Symbiose Algale", cost: 8, effect: "generator_boost", value: 2, unlocked: false, requires: "instinct_gregaire", branch: "marais" },
|
||||
// Tier 3 (exclusif)
|
||||
{ id: "courant_profond", name: "Courant Profond", cost: 25, effect: "production_multiplier", value: 2, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "maree_haute" },
|
||||
{ id: "maree_haute", name: "Marée Haute", cost: 25, effect: "cost_reduction", value: 0.20, unlocked: false, requires: "symbiose_algale", branch: "marais", exclusive_with: "courant_profond" },
|
||||
// Tier 3 (parallèle)
|
||||
{ id: "ecosysteme_mature", name: "Écosystème Mature", cost: 25, effect: "production_multiplier", value: 3, unlocked: false, requires: "symbiose_algale", branch: "marais" },
|
||||
// Tier 4
|
||||
{ id: "marais_eternel", name: "Marais Éternel", cost: 60, effect: "production_multiplier", value: 5, unlocked: false, requires: "ecosysteme_mature", branch: "marais" },
|
||||
// Capstone
|
||||
{ id: "symbiose_totale", name: "Symbiose Totale", cost: 300, effect: "generator_synergy", value: 0.02, unlocked: false, requires: "marais_eternel", branch: "marais", capstone: true },
|
||||
// Post-capstone (repeatable)
|
||||
{ id: "marais_post", name: "+1% synergie", cost: 600, effect: "generator_synergy", value: 0.01, unlocked: false, requires: "symbiose_totale", branch: "marais", repeatable: true, purchased: 0 },
|
||||
|
||||
// ═══ ADAPTATION (utility) — 10 nœuds ═══
|
||||
|
||||
// Tier 1
|
||||
{ id: "memoire_genetique", name: "Mémoire Génétique", cost: 2, effect: "start_bonus", value: 100, unlocked: false, requires: null, branch: "adaptation" },
|
||||
// Tier 2
|
||||
{ id: "adn_renforce", name: "ADN Renforcé", cost: 10, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "memoire_genetique", branch: "adaptation" },
|
||||
// Tier 3 (exclusif)
|
||||
{ id: "eveil_rapide", name: "Éveil Rapide", cost: 30, effect: "offline_boost", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "mutation_adn" },
|
||||
{ id: "mutation_adn", name: "Mutation ADN", cost: 30, effect: "prestige_dna_bonus", value: 0.25, unlocked: false, requires: "adn_renforce", branch: "adaptation", exclusive_with: "eveil_rapide" },
|
||||
// Tier 3 (parallèle)
|
||||
{ id: "heritage", name: "Héritage", cost: 30, effect: "prestige_dna_bonus", value: 0.50, unlocked: false, requires: "adn_renforce", branch: "adaptation" },
|
||||
// Tier 4
|
||||
{ id: "transcendance", name: "Transcendance", cost: 60, effect: "prestige_threshold_reduction", value: 0.50, unlocked: false, requires: "heritage", branch: "adaptation" },
|
||||
// Capstone
|
||||
{ id: "memoire_marais", name: "Mémoire du Marais", cost: 250, effect: "offline_cap_boost", value: 0.75, unlocked: false, requires: "transcendance", branch: "adaptation", capstone: true },
|
||||
// Post-capstone (repeatable)
|
||||
{ id: "adapt_post", name: "+2% offline cap", cost: 500, effect: "offline_boost", value: 0.02, unlocked: false, requires: "memoire_marais", branch: "adaptation", repeatable: true, purchased: 0 },
|
||||
|
||||
// ═══ CROSS-BRANCHE — Convergence (nœud évolutif) ═══
|
||||
|
||||
{ id: "convergence", name: "Convergence", cost: 500, effect: "all_effects_boost", value: 0.10, unlocked: false, requires: null, branch: "cross",
|
||||
tier: 1, maxTier: 2, tierUpgradeCost: 500, tierUpgradeRequires: "2_capstones" },
|
||||
];
|
||||
|
||||
// Formule ADN Sprint 3 : max(1, floor(base × log10(t / threshold) × (1 + bonus)))
|
||||
// Clamp min 1 si seuil atteint, cap bonus ×4 à 80 prestiges
|
||||
|
||||
export function computePrestigeDna(lifetimeTadpoles: number, prestigeCount: number = 0): number {
|
||||
if (lifetimeTadpoles < PRESTIGE_ADN_THRESHOLD) return 0;
|
||||
const ratio = lifetimeTadpoles / PRESTIGE_ADN_THRESHOLD;
|
||||
if (ratio <= 1) return PRESTIGE_ADN_MIN;
|
||||
const bonus = Math.min(PRESTIGE_BONUS_PER_PRESTIGE * prestigeCount, PRESTIGE_BONUS_CAP);
|
||||
const raw = PRESTIGE_ADN_BASE * Math.log10(ratio) * (1 + bonus);
|
||||
return Math.max(PRESTIGE_ADN_MIN, Math.floor(raw));
|
||||
}
|
||||
|
||||
// --- Milestones prestige ---
|
||||
|
||||
// Milestones disponibles mais pas encore réclamés
|
||||
export function getClaimableMilestones(state: GameState): PrestigeMilestone[] {
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
return PRESTIGE_MILESTONES.filter(
|
||||
(m) => state.prestigeCount >= m.threshold && !claimed.includes(m.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Prochain milestone non atteint
|
||||
export function getNextMilestone(state: GameState): PrestigeMilestone | null {
|
||||
return PRESTIGE_MILESTONES.find((m) => state.prestigeCount < m.threshold) ?? null;
|
||||
}
|
||||
|
||||
// Réclamer un milestone
|
||||
export function claimMilestone(state: GameState, milestoneId: string): GameState | null {
|
||||
const milestone = PRESTIGE_MILESTONES.find((m) => m.id === milestoneId);
|
||||
if (!milestone) return null;
|
||||
if (state.prestigeCount < milestone.threshold) return null;
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes(milestoneId)) return null;
|
||||
|
||||
let newState = {
|
||||
...state,
|
||||
claimedMilestones: [...claimed, milestoneId],
|
||||
};
|
||||
|
||||
// Appliquer la récompense
|
||||
if (milestone.reward.type === "cosmetic") {
|
||||
if (!newState.cosmeticInventory.includes(milestone.reward.cosmeticId)) {
|
||||
newState = {
|
||||
...newState,
|
||||
cosmeticInventory: [...newState.cosmeticInventory, milestone.reward.cosmeticId],
|
||||
};
|
||||
}
|
||||
}
|
||||
// Les bonus gameplay sont appliqués passivement via getMilestoneBonus()
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
// Bonus gameplay cumulés depuis les milestones réclamés
|
||||
export function getMilestoneStartNid(state: GameState): number {
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes("milestone_5")) return 1; // 1 Nid gratuit
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getMilestoneOfflineBonus(state: GameState): number {
|
||||
const claimed = state.claimedMilestones ?? [];
|
||||
if (claimed.includes("milestone_15")) return 0.05; // +5% offline cap
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Compte les capstones débloqués
|
||||
export function getUnlockedCapstoneCount(tree: EvolutionNode[]): number {
|
||||
return tree.filter((n) => n.capstone && n.unlocked).length;
|
||||
}
|
||||
|
||||
// Coût actuel d'un nœud repeatable (scaling par tranche via balance.ts)
|
||||
export function getRepeatableCost(node: EvolutionNode): number {
|
||||
if (!node.repeatable) return node.cost;
|
||||
return postCapstoneCost(node.cost, node.purchased ?? 0);
|
||||
}
|
||||
|
||||
// Vérifie si le joueur peut acheter Convergence (condition spéciale)
|
||||
function canBuyConvergence(state: GameState, node: EvolutionNode): boolean {
|
||||
// Tier 1 : 1 capstone + au moins 1 nœud tier 3 d'une 2e branche
|
||||
if (!node.unlocked && (node.tier ?? 1) === 1) {
|
||||
const capstones = getUnlockedCapstoneCount(state.evolutionTree);
|
||||
if (capstones < 1) return false;
|
||||
// Check: au moins 1 nœud dans une branche différente de la capstone
|
||||
const capstoneBranches = new Set(
|
||||
state.evolutionTree.filter((n) => n.capstone && n.unlocked).map((n) => n.branch)
|
||||
);
|
||||
const otherBranchNodes = state.evolutionTree.filter(
|
||||
(n) => n.unlocked && !capstoneBranches.has(n.branch) && n.branch !== "cross" && n.cost >= 15
|
||||
);
|
||||
return otherBranchNodes.length > 0 && state.ancestralDna >= node.cost;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifie si Convergence peut être upgradé au tier suivant
|
||||
export function canUpgradeConvergence(state: GameState): boolean {
|
||||
const conv = state.evolutionTree.find((n) => n.id === "convergence");
|
||||
if (!conv || !conv.unlocked) return false;
|
||||
if ((conv.tier ?? 1) >= (conv.maxTier ?? 2)) return false;
|
||||
if (conv.tierUpgradeRequires === "2_capstones" && getUnlockedCapstoneCount(state.evolutionTree) < 2) return false;
|
||||
return state.ancestralDna >= (conv.tierUpgradeCost ?? 500);
|
||||
}
|
||||
|
||||
// Upgrade Convergence au tier suivant
|
||||
export function upgradeConvergence(state: GameState): GameState | null {
|
||||
if (!canUpgradeConvergence(state)) return null;
|
||||
const conv = state.evolutionTree.find((n) => n.id === "convergence")!;
|
||||
const cost = conv.tierUpgradeCost ?? 500;
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna - cost,
|
||||
evolutionTree: state.evolutionTree.map((n) =>
|
||||
n.id === "convergence"
|
||||
? { ...n, tier: (n.tier ?? 1) + 1, effect: "post_capstone_discount" as EffectType, value: 0.20 }
|
||||
: n
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Vérifie si un nœud peut être acheté
|
||||
export function canBuyEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
// Convergence a sa propre logique
|
||||
if (node.id === "convergence") return canBuyConvergence(state, node);
|
||||
|
||||
// Repeatable : toujours achetable si unlocked + prérequis + assez d'ADN
|
||||
if (node.repeatable && node.unlocked) {
|
||||
const cost = getRepeatableCost(node);
|
||||
return state.ancestralDna >= cost;
|
||||
}
|
||||
|
||||
if (node.unlocked) return false;
|
||||
|
||||
const cost = node.repeatable ? getRepeatableCost(node) : node.cost;
|
||||
if (state.ancestralDna < cost) return false;
|
||||
|
||||
if (node.requires) {
|
||||
const prereq = state.evolutionTree.find((n) => n.id === node.requires);
|
||||
if (!prereq || !prereq.unlocked) return false;
|
||||
}
|
||||
// Exclusive node: can't buy if the alternative is already unlocked
|
||||
if (node.exclusive_with) {
|
||||
const alt = state.evolutionTree.find((n) => n.id === node.exclusive_with);
|
||||
if (alt && alt.unlocked) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Achète un nœud d'évolution (retourne null si impossible)
|
||||
export function buyEvolutionNode(state: GameState, nodeId: string): GameState | null {
|
||||
if (!canBuyEvolutionNode(state, nodeId)) return null;
|
||||
|
||||
const node = state.evolutionTree.find((n) => n.id === nodeId)!;
|
||||
|
||||
// Repeatable node — already unlocked, increment purchased
|
||||
if (node.repeatable && node.unlocked) {
|
||||
const cost = getRepeatableCost(node);
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna - cost,
|
||||
evolutionTree: state.evolutionTree.map((n) =>
|
||||
n.id === nodeId ? { ...n, purchased: (n.purchased ?? 0) + 1 } : n
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const cost = node.repeatable ? getRepeatableCost(node) : node.cost;
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna - cost,
|
||||
evolutionTree: state.evolutionTree.map((n) =>
|
||||
n.id === nodeId ? { ...n, unlocked: true } : n
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Coût du prochain reset arbre (pour affichage UI)
|
||||
export function getTreeResetCost(state: GameState): number {
|
||||
return treeResetCost(state.freeResetAvailable, state.extraResetsUsed);
|
||||
}
|
||||
|
||||
// Vérifie si le joueur peut reset l'arbre
|
||||
export function canResetTree(state: GameState): boolean {
|
||||
if (state.prestigeCount < 1) return false;
|
||||
const cost = getTreeResetCost(state);
|
||||
return state.ancestralDna >= cost;
|
||||
}
|
||||
|
||||
// Reset l'arbre — rembourse l'ADN dépensé (y compris repeatables), déduit le coût du reset
|
||||
export function resetEvolutionTree(state: GameState): GameState {
|
||||
const cost = getTreeResetCost(state);
|
||||
if (state.ancestralDna < cost) return state;
|
||||
|
||||
const spentDna = getSpentDna(state.evolutionTree);
|
||||
|
||||
return {
|
||||
...state,
|
||||
ancestralDna: state.ancestralDna + spentDna - cost,
|
||||
evolutionTree: state.evolutionTree.map((n) => ({
|
||||
...n,
|
||||
unlocked: false,
|
||||
purchased: n.repeatable ? 0 : n.purchased,
|
||||
tier: n.maxTier ? 1 : n.tier,
|
||||
})),
|
||||
freeResetAvailable: state.freeResetAvailable ? false : state.freeResetAvailable,
|
||||
extraResetsUsed: state.freeResetAvailable ? state.extraResetsUsed : state.extraResetsUsed + 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Compte l'ADN total investi dans l'arbre (standard + repeatables + convergence upgrades)
|
||||
export function getSpentDna(tree: EvolutionNode[]): number {
|
||||
let total = 0;
|
||||
for (const n of tree) {
|
||||
if (!n.unlocked) continue;
|
||||
total += n.cost; // coût initial
|
||||
// Repeatables : somme des coûts de chaque achat
|
||||
if (n.repeatable && (n.purchased ?? 0) > 0) {
|
||||
for (let i = 0; i < n.purchased!; i++) {
|
||||
total += postCapstoneCost(n.cost, i);
|
||||
}
|
||||
}
|
||||
// Convergence tier upgrades
|
||||
if (n.maxTier && (n.tier ?? 1) > 1) {
|
||||
total += (n.tierUpgradeCost ?? 0) * ((n.tier ?? 1) - 1);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// Calcule le multiplicateur click total depuis l'arbre
|
||||
export function getClickMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "click_multiplier")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Calcule le multiplicateur production total depuis l'arbre
|
||||
export function getProductionMultiplierFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "production_multiplier")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Bonus de départ (têtards offerts au début de chaque run)
|
||||
export function getStartBonusFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "start_bonus")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// Chance de double click (0-1)
|
||||
export function getDoubleClickChance(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "double_click_chance")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// Auto-clicks par seconde depuis l'arbre (standard + capstone scaling)
|
||||
export function getAutoClicksPerSecond(tree: EvolutionNode[]): number {
|
||||
const standard = tree
|
||||
.filter((n) => n.unlocked && n.effect === "auto_click" && !n.repeatable)
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
const scaling = getAutoClickScaling(tree);
|
||||
return standard + scaling;
|
||||
}
|
||||
|
||||
// Chance de crit click (0-1), crit = x10
|
||||
export function getCritClickChance(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "crit_click_chance")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// Multiplicateur boost sur Nid (generator_boost)
|
||||
export function getGeneratorBoostFromTree(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "generator_boost")
|
||||
.reduce((mult, n) => mult * n.value, 1);
|
||||
}
|
||||
|
||||
// Réduction de coût générateurs (0-1)
|
||||
export function getCostReduction(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "cost_reduction")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// Bonus ADN prestige (additif, ex: 0.25 = +25%)
|
||||
export function getPrestigeDnaBonus(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "prestige_dna_bonus")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// Boost offline (additif, ex: 0.50 = +50% efficacité offline)
|
||||
export function getOfflineBoost(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "offline_boost")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// Réduction seuil prestige (multiplicatif, ex: 0.50 = seuil divisé par 2)
|
||||
export function getPrestigeThresholdReduction(tree: EvolutionNode[]): number {
|
||||
return tree
|
||||
.filter((n) => n.unlocked && n.effect === "prestige_threshold_reduction")
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
}
|
||||
|
||||
// --- Sprint 3 — Nouveaux effets ---
|
||||
|
||||
// Ponte Automatique (capstone) : 1 auto-click/s de base, scale avec les repeatables
|
||||
export function getAutoClickScaling(tree: EvolutionNode[]): number {
|
||||
const capstone = tree.find((n) => n.id === "ponte_auto" && n.unlocked);
|
||||
if (!capstone) return 0;
|
||||
const baseAutoClick = capstone.value; // 1/s
|
||||
// Post-capstone adds flat auto-click value per purchase
|
||||
const postNode = tree.find((n) => n.id === "ponte_post" && n.unlocked);
|
||||
const postBonus = postNode ? postNode.value * (postNode.purchased ?? 0) : 0;
|
||||
return baseAutoClick + postBonus;
|
||||
}
|
||||
|
||||
// Symbiose Totale (capstone) : +X% par type de générateur possédé
|
||||
// Retourne le multiplicateur (ex: 5 types × 0.02 = 0.10 → ×1.10)
|
||||
export function getGeneratorSynergyMultiplier(tree: EvolutionNode[], generators: Generator[]): number {
|
||||
const synergyNodes = tree.filter((n) => n.unlocked && n.effect === "generator_synergy");
|
||||
if (synergyNodes.length === 0) return 1;
|
||||
const totalSynergyRate = synergyNodes.reduce((sum, n) => {
|
||||
// For repeatables, each purchase adds to the rate
|
||||
const extra = n.repeatable ? n.value * (n.purchased ?? 0) : 0;
|
||||
return sum + n.value + extra;
|
||||
}, 0);
|
||||
const typesOwned = generators.filter((g) => g.owned > 0).length;
|
||||
return 1 + totalSynergyRate * typesOwned;
|
||||
}
|
||||
|
||||
// Convergence : all_effects_boost — multiplicateur global sur tous les effets de l'arbre
|
||||
export function getAllEffectsBoost(tree: EvolutionNode[]): number {
|
||||
const conv = tree.find((n) => n.id === "convergence" && n.unlocked);
|
||||
if (!conv) return 1;
|
||||
return 1 + conv.value; // 0.10 = ×1.10
|
||||
}
|
||||
|
||||
// Convergence Omega : post_capstone_discount
|
||||
export function getPostCapstoneDiscount(tree: EvolutionNode[]): number {
|
||||
const conv = tree.find((n) => n.id === "convergence" && n.unlocked && n.effect === "post_capstone_discount");
|
||||
if (!conv) return 0;
|
||||
return conv.value; // 0.20 = -20%
|
||||
}
|
||||
|
||||
// --- Offline gains (courbe inversée) ---
|
||||
|
||||
// Retourne le multiplicateur d'efficacité offline (1.0 → 0.0)
|
||||
// basé sur le temps d'absence en ms
|
||||
export function offlineEfficiency(elapsedMs: number): number {
|
||||
if (elapsedMs <= OFFLINE_THRESHOLD) return 1; // pas offline
|
||||
if (elapsedMs <= OFFLINE_FULL_MS) return 1; // 0-15min : 100%
|
||||
if (elapsedMs <= OFFLINE_DECAY_END_MS) {
|
||||
// 15min-1h : linéaire 1.0 → 0.25
|
||||
const t = (elapsedMs - OFFLINE_FULL_MS) / (OFFLINE_DECAY_END_MS - OFFLINE_FULL_MS);
|
||||
return 1 - t * (1 - OFFLINE_FLOOR);
|
||||
}
|
||||
if (elapsedMs <= OFFLINE_ZERO_MS) {
|
||||
// 1h-2h : linéaire 0.25 → 0.0
|
||||
const t = (elapsedMs - OFFLINE_DECAY_END_MS) / (OFFLINE_ZERO_MS - OFFLINE_DECAY_END_MS);
|
||||
return OFFLINE_FLOOR * (1 - t);
|
||||
}
|
||||
return 0; // >2h : rien
|
||||
}
|
||||
|
||||
// Calcule les gains offline avec la courbe dégressive
|
||||
// Intègre la courbe par tranches de 1 minute pour plus de précision
|
||||
export function computeOfflineGains(state: GameState, now: number): number {
|
||||
const elapsed = now - state.lastTick;
|
||||
if (elapsed <= OFFLINE_THRESHOLD) return computeIdleGains(state, now);
|
||||
|
||||
const pps = totalProductionPerSecond(state);
|
||||
if (pps <= 0) return 0;
|
||||
|
||||
const offlineBoost = 1 + getOfflineBoost(state.evolutionTree) + getMilestoneOfflineBonus(state);
|
||||
|
||||
// Intégration par tranches de 60s
|
||||
const STEP = 60_000;
|
||||
let total = 0;
|
||||
for (let t = 0; t < elapsed; t += STEP) {
|
||||
const chunk = Math.min(STEP, elapsed - t);
|
||||
const eff = offlineEfficiency(t + chunk / 2); // milieu de la tranche
|
||||
total += pps * (chunk / 1000) * eff;
|
||||
}
|
||||
return total * offlineBoost;
|
||||
}
|
||||
|
||||
// --- Core economy (mis à jour pour intégrer l'arbre) ---
|
||||
|
||||
// Coût d'achat du N-ième générateur : baseCost × 1.15^owned × (1 - costReduction)
|
||||
export function generatorCost(gen: Generator, tree?: EvolutionNode[]): number {
|
||||
const base = Math.floor(gen.baseCost * Math.pow(1.15, gen.owned));
|
||||
if (!tree) return base;
|
||||
const reduction = getCostReduction(tree);
|
||||
return Math.max(1, Math.floor(base * (1 - reduction)));
|
||||
}
|
||||
|
||||
// Production totale par seconde de tous les générateurs
|
||||
export function totalProductionPerSecond(state: GameState): number {
|
||||
const nidBoost = getGeneratorBoostFromTree(state.evolutionTree);
|
||||
const synergyMult = getGeneratorSynergyMultiplier(state.evolutionTree, state.generators);
|
||||
const base = state.generators.reduce(
|
||||
(sum, gen) => {
|
||||
const boost = gen.id === "nid" ? nidBoost : 1;
|
||||
return sum + gen.baseProduction * gen.owned * boost;
|
||||
},
|
||||
0
|
||||
);
|
||||
const treeMultiplier = getProductionMultiplierFromTree(state.evolutionTree);
|
||||
const convergenceBoost = getAllEffectsBoost(state.evolutionTree);
|
||||
return base * state.prestigeMultiplier * treeMultiplier * synergyMult * convergenceBoost;
|
||||
}
|
||||
|
||||
// Lazy calculation : ressources accumulées depuis lastTick
|
||||
export function computeIdleGains(state: GameState, now: number): number {
|
||||
const elapsedSeconds = (now - state.lastTick) / 1000;
|
||||
return totalProductionPerSecond(state) * elapsedSeconds;
|
||||
}
|
||||
|
||||
// Applique les gains idle et met à jour lastTick
|
||||
export function applyIdleGains(state: GameState, now: number): GameState {
|
||||
const gains = computeIdleGains(state, now);
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources + gains,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gains,
|
||||
lastTick: now,
|
||||
runStats: {
|
||||
...state.runStats,
|
||||
tadpolesProduced: state.runStats.tadpolesProduced + gains,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Gain de base par clic (sans RNG — pour affichage tooltip)
|
||||
export function getClickGain(state: GameState): number {
|
||||
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
|
||||
return state.clickMultiplier * state.prestigeMultiplier * treeClickMult;
|
||||
}
|
||||
|
||||
export interface ClickResult {
|
||||
state: GameState;
|
||||
gain: number;
|
||||
isDouble: boolean;
|
||||
isCrit: boolean;
|
||||
}
|
||||
|
||||
// Clic manuel avec double ponte + crit
|
||||
export function applyClick(state: GameState, rng: number = Math.random()): ClickResult {
|
||||
let gain = getClickGain(state);
|
||||
let isDouble = false;
|
||||
let isCrit = false;
|
||||
|
||||
const doubleChance = getDoubleClickChance(state.evolutionTree);
|
||||
if (doubleChance > 0 && rng < doubleChance) {
|
||||
gain *= 2;
|
||||
isDouble = true;
|
||||
}
|
||||
|
||||
const critChance = getCritClickChance(state.evolutionTree);
|
||||
// Use a second "roll" derived from rng to avoid double+crit being correlated
|
||||
const critRng = (rng * 7.13) % 1;
|
||||
if (critChance > 0 && critRng < critChance) {
|
||||
gain *= 10;
|
||||
isCrit = true;
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
resources: state.resources + gain,
|
||||
lifetimeTadpoles: state.lifetimeTadpoles + gain,
|
||||
runStats: {
|
||||
...state.runStats,
|
||||
tadpolesProduced: state.runStats.tadpolesProduced + gain,
|
||||
},
|
||||
},
|
||||
gain,
|
||||
isDouble,
|
||||
isCrit,
|
||||
};
|
||||
}
|
||||
|
||||
// Achat d'un générateur (retourne null si fonds insuffisants)
|
||||
export function buyGenerator(state: GameState, genId: string): GameState | null {
|
||||
const genIndex = state.generators.findIndex((g) => g.id === genId);
|
||||
if (genIndex === -1) return null;
|
||||
|
||||
const gen = state.generators[genIndex];
|
||||
const cost = generatorCost(gen, state.evolutionTree);
|
||||
if (state.resources < cost) return null;
|
||||
|
||||
const updatedGenerators = [...state.generators];
|
||||
updatedGenerators[genIndex] = { ...gen, owned: gen.owned + 1 };
|
||||
|
||||
return {
|
||||
...state,
|
||||
resources: state.resources - cost,
|
||||
generators: updatedGenerators,
|
||||
};
|
||||
}
|
||||
|
||||
// Prestige : reset run, gain ADN, arbre persiste
|
||||
|
||||
export function getPrestigeThreshold(state: GameState): number {
|
||||
const reduction = getPrestigeThresholdReduction(state.evolutionTree);
|
||||
return Math.floor(BASE_PRESTIGE_THRESHOLD * (1 - reduction));
|
||||
}
|
||||
|
||||
export function canPrestige(state: GameState): boolean {
|
||||
return state.resources >= getPrestigeThreshold(state);
|
||||
}
|
||||
|
||||
export function applyPrestige(state: GameState): GameState {
|
||||
const newPrestigeCount = state.prestigeCount + 1;
|
||||
const dnaBonus = getPrestigeDnaBonus(state.evolutionTree);
|
||||
const baseDna = computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount);
|
||||
const dnaGained = Math.floor(baseDna * (1 + dnaBonus));
|
||||
const startBonus = getStartBonusFromTree(state.evolutionTree);
|
||||
|
||||
// Résilience : commencer avec 1 Lac Mystique
|
||||
const hasUnlockGen = state.evolutionTree.some(
|
||||
(n) => n.unlocked && n.effect === "unlock_generator"
|
||||
);
|
||||
// Milestone bonus : Nid gratuit au départ
|
||||
const milestoneNid = getMilestoneStartNid(state);
|
||||
|
||||
// RunStats : snapshot de la run qui se termine
|
||||
const now = Date.now();
|
||||
const runDuration = now - state.runStats.startedAt;
|
||||
const bestRun = state.runStats.bestRun;
|
||||
const newBestRun =
|
||||
!bestRun || dnaGained > bestRun.adn
|
||||
? { duration: runDuration, tadpoles: state.lifetimeTadpoles, adn: dnaGained }
|
||||
: bestRun;
|
||||
|
||||
return {
|
||||
...state,
|
||||
resources: startBonus,
|
||||
generators: state.generators.map((g) => ({
|
||||
...g,
|
||||
owned:
|
||||
(hasUnlockGen && g.id === "lac") ? 1 :
|
||||
(milestoneNid > 0 && g.id === "nid") ? milestoneNid :
|
||||
0,
|
||||
})),
|
||||
prestigeCount: newPrestigeCount,
|
||||
prestigeMultiplier: 1 + newPrestigeCount * 0.1,
|
||||
ancestralDna: state.ancestralDna + dnaGained,
|
||||
lifetimeTadpoles: 0,
|
||||
lastTick: now,
|
||||
lastOnline: now,
|
||||
// Sprint 3 — nouvelle run
|
||||
runStats: {
|
||||
startedAt: now,
|
||||
tadpolesProduced: 0,
|
||||
bestRun: newBestRun,
|
||||
},
|
||||
freeResetAvailable: true, // 1 reset gratuit offert par prestige
|
||||
extraResetsUsed: 0,
|
||||
// evolutionTree persiste — jamais reset
|
||||
};
|
||||
}
|
||||
|
||||
// Valeurs par défaut — 5 tiers alignés GDD Tetard Universe (x10 coût / tier)
|
||||
export const DEFAULT_GENERATORS: Generator[] = [
|
||||
{ id: "nid", name: "Nid", baseCost: 10, baseProduction: 0.1, owned: 0 },
|
||||
{ id: "mare", name: "Mare", baseCost: 100, baseProduction: 0.5, owned: 0 },
|
||||
{ id: "marecage", name: "Marécage", baseCost: 1_000, baseProduction: 3, owned: 0 },
|
||||
{ id: "etang", name: "Étang Ancien", baseCost: 10_000, baseProduction: 20, owned: 0 },
|
||||
{ id: "lac", name: "Lac Mystique", baseCost: 100_000, baseProduction: 150, owned: 0 },
|
||||
];
|
||||
|
||||
export const DEFAULT_STATE: GameState = {
|
||||
saveVersion: CURRENT_SAVE_VERSION,
|
||||
resources: 0,
|
||||
clickMultiplier: 1,
|
||||
generators: DEFAULT_GENERATORS,
|
||||
lastTick: Date.now(),
|
||||
lastOnline: Date.now(),
|
||||
prestigeCount: 0,
|
||||
prestigeMultiplier: 1,
|
||||
ancestralDna: 0,
|
||||
evolutionTree: DEFAULT_EVOLUTION_TREE,
|
||||
lifetimeTadpoles: 0,
|
||||
cosmeticInventory: [],
|
||||
cosmeticEquipped: {},
|
||||
runStats: {
|
||||
startedAt: Date.now(),
|
||||
tadpolesProduced: 0,
|
||||
bestRun: null,
|
||||
},
|
||||
freeResetAvailable: true,
|
||||
extraResetsUsed: 0,
|
||||
claimedMilestones: [],
|
||||
};
|
||||
142
Frontend/src/lib/core/migrateSave.ts
Normal file
142
Frontend/src/lib/core/migrateSave.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// migrateSave.ts — Migration lazy des saves entre versions
|
||||
// Appliqué au chargement (frontend + backend). Jamais de migration en DB.
|
||||
// Chaque sprint ajoute un step (v2→v3, etc.)
|
||||
|
||||
import { CURRENT_SAVE_VERSION } from "./balance";
|
||||
import type { GameState } from "./economy";
|
||||
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
|
||||
|
||||
/**
|
||||
* Détecte la version d'une save et applique les migrations nécessaires.
|
||||
* Entrée : objet brut depuis la DB/localStorage (potentiellement incomplet).
|
||||
* Sortie : GameState conforme à la version courante.
|
||||
*/
|
||||
export function migrateSave(raw: Record<string, unknown>): GameState {
|
||||
const version = typeof raw.saveVersion === "number" ? raw.saveVersion : 1;
|
||||
|
||||
let state = raw as Record<string, unknown>;
|
||||
|
||||
if (version < 2) {
|
||||
state = migrateV1toV2(state);
|
||||
}
|
||||
|
||||
// Futures migrations :
|
||||
// if (version < 3) state = migrateV2toV3(state);
|
||||
|
||||
return state as unknown as GameState;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 → v2 : Sprint 2 → Sprint 3
|
||||
* - Ajoute saveVersion
|
||||
* - Ajoute runStats (vide)
|
||||
* - Ajoute freeResetAvailable + extraResetsUsed
|
||||
* - Merge les nouveaux nœuds arbre (conserve l'état des 18 existants)
|
||||
* - Backfill champs manquants (cosmeticInventory, cosmeticEquipped, lastOnline)
|
||||
*/
|
||||
function migrateV1toV2(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const state = { ...raw };
|
||||
|
||||
// saveVersion
|
||||
state.saveVersion = 2;
|
||||
|
||||
// RunStats (nouveau Sprint 3)
|
||||
if (!state.runStats) {
|
||||
state.runStats = {
|
||||
startedAt: typeof state.lastTick === "number" ? state.lastTick : Date.now(),
|
||||
tadpolesProduced: 0,
|
||||
bestRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Reset arbre : 1 gratuit par prestige
|
||||
if (typeof state.freeResetAvailable !== "boolean") {
|
||||
state.freeResetAvailable = true;
|
||||
}
|
||||
if (typeof state.extraResetsUsed !== "number") {
|
||||
state.extraResetsUsed = 0;
|
||||
}
|
||||
|
||||
// Milestones (Sprint 3)
|
||||
if (!Array.isArray(state.claimedMilestones)) {
|
||||
state.claimedMilestones = [];
|
||||
}
|
||||
|
||||
// Backfill champs Sprint 2 potentiellement manquants
|
||||
if (!state.lastOnline) state.lastOnline = state.lastTick;
|
||||
if (!Array.isArray(state.cosmeticInventory)) state.cosmeticInventory = [];
|
||||
if (!state.cosmeticEquipped || typeof state.cosmeticEquipped !== "object") {
|
||||
state.cosmeticEquipped = {};
|
||||
}
|
||||
|
||||
// Merge arbre : conserver les 18 nœuds existants + ajouter les nouveaux
|
||||
state.evolutionTree = mergeEvolutionTree(
|
||||
state.evolutionTree as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
|
||||
// Merge générateurs : conserver owned + ajouter les potentiels nouveaux
|
||||
state.generators = mergeGenerators(
|
||||
state.generators as Array<Record<string, unknown>> | undefined
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge l'arbre sauvegardé avec DEFAULT_EVOLUTION_TREE.
|
||||
* - Nœuds existants : conserve unlocked state
|
||||
* - Nœuds nouveaux : ajoutés avec unlocked: false
|
||||
* - Nœuds supprimés du default : retirés (forward compat)
|
||||
*/
|
||||
function mergeEvolutionTree(
|
||||
savedTree: Array<Record<string, unknown>> | undefined
|
||||
): typeof DEFAULT_EVOLUTION_TREE {
|
||||
if (!savedTree || !Array.isArray(savedTree)) {
|
||||
return DEFAULT_EVOLUTION_TREE.map((n) => ({ ...n }));
|
||||
}
|
||||
|
||||
const savedById = new Map(
|
||||
savedTree.map((n) => [n.id as string, n])
|
||||
);
|
||||
|
||||
return DEFAULT_EVOLUTION_TREE.map((defaultNode) => {
|
||||
const saved = savedById.get(defaultNode.id);
|
||||
if (saved) {
|
||||
// Conserver l'état unlocked, tout le reste vient du default
|
||||
// (permet de corriger des valeurs rebalancées sans casser les saves)
|
||||
return {
|
||||
...defaultNode,
|
||||
unlocked: saved.unlocked === true,
|
||||
};
|
||||
}
|
||||
// Nouveau nœud — ajouté verrouillé
|
||||
return { ...defaultNode };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge les générateurs sauvegardés avec DEFAULT_GENERATORS.
|
||||
* Conserve le owned count, met à jour les stats de base.
|
||||
*/
|
||||
function mergeGenerators(
|
||||
savedGens: Array<Record<string, unknown>> | undefined
|
||||
): typeof DEFAULT_GENERATORS {
|
||||
if (!savedGens || !Array.isArray(savedGens)) {
|
||||
return DEFAULT_GENERATORS.map((g) => ({ ...g }));
|
||||
}
|
||||
|
||||
const savedById = new Map(
|
||||
savedGens.map((g) => [g.id as string, g])
|
||||
);
|
||||
|
||||
return DEFAULT_GENERATORS.map((defaultGen) => {
|
||||
const saved = savedById.get(defaultGen.id);
|
||||
if (saved) {
|
||||
return {
|
||||
...defaultGen,
|
||||
owned: typeof saved.owned === "number" ? saved.owned : 0,
|
||||
};
|
||||
}
|
||||
return { ...defaultGen };
|
||||
});
|
||||
}
|
||||
219
Frontend/src/lib/data/achievements.ts
Normal file
219
Frontend/src/lib/data/achievements.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// achievements.ts — Milestones Clickerz basés sur le GameState réel
|
||||
import type { GameState } from "../core/economy";
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
check: (state: GameState) => boolean;
|
||||
}
|
||||
|
||||
function totalGeneratorsOwned(state: GameState): number {
|
||||
return state.generators.reduce((sum, g) => sum + g.owned, 0);
|
||||
}
|
||||
|
||||
function hasGenerator(state: GameState, genId: string): boolean {
|
||||
return state.generators.some((g) => g.id === genId && g.owned > 0);
|
||||
}
|
||||
|
||||
function hasEvolutionNode(state: GameState, nodeId: string): boolean {
|
||||
return state.evolutionTree.some((n) => n.id === nodeId && n.unlocked);
|
||||
}
|
||||
|
||||
export const ACHIEVEMENTS: Achievement[] = [
|
||||
// --- Paliers de ressources ---
|
||||
{
|
||||
id: "first_tadpole",
|
||||
name: "Premier Têtard",
|
||||
description: "Faire éclore son tout premier têtard.",
|
||||
icon: "🥚",
|
||||
check: (s) => s.resources >= 1 || s.lifetimeTadpoles >= 1,
|
||||
},
|
||||
{
|
||||
id: "colony",
|
||||
name: "Colonie",
|
||||
description: "Atteindre 100 têtards.",
|
||||
icon: "🌱",
|
||||
check: (s) => s.resources >= 100 || s.lifetimeTadpoles >= 100,
|
||||
},
|
||||
{
|
||||
id: "village",
|
||||
name: "Village",
|
||||
description: "Atteindre 1 000 têtards.",
|
||||
icon: "🏘️",
|
||||
check: (s) => s.resources >= 1_000 || s.lifetimeTadpoles >= 1_000,
|
||||
},
|
||||
{
|
||||
id: "city",
|
||||
name: "Cité",
|
||||
description: "Atteindre 10 000 têtards.",
|
||||
icon: "🏙️",
|
||||
check: (s) => s.resources >= 10_000 || s.lifetimeTadpoles >= 10_000,
|
||||
},
|
||||
{
|
||||
id: "metropolis",
|
||||
name: "Métropole",
|
||||
description: "Atteindre 100 000 têtards.",
|
||||
icon: "🌆",
|
||||
check: (s) => s.resources >= 100_000 || s.lifetimeTadpoles >= 100_000,
|
||||
},
|
||||
{
|
||||
id: "empire",
|
||||
name: "Empire du Marais",
|
||||
description: "Atteindre 1 000 000 de têtards.",
|
||||
icon: "👑",
|
||||
check: (s) => s.resources >= 1_000_000 || s.lifetimeTadpoles >= 1_000_000,
|
||||
},
|
||||
|
||||
// --- Générateurs ---
|
||||
{
|
||||
id: "first_nid",
|
||||
name: "Nidificateur",
|
||||
description: "Construire son premier Nid.",
|
||||
icon: "🪹",
|
||||
check: (s) => hasGenerator(s, "nid"),
|
||||
},
|
||||
{
|
||||
id: "first_mare",
|
||||
name: "Batracien",
|
||||
description: "Aménager sa première Mare.",
|
||||
icon: "💧",
|
||||
check: (s) => hasGenerator(s, "mare"),
|
||||
},
|
||||
{
|
||||
id: "first_marecage",
|
||||
name: "Marécageux",
|
||||
description: "S'enfoncer dans son premier Marécage.",
|
||||
icon: "🌿",
|
||||
check: (s) => hasGenerator(s, "marecage"),
|
||||
},
|
||||
{
|
||||
id: "first_etang",
|
||||
name: "Gardien de l'Étang",
|
||||
description: "Découvrir un Étang Ancien.",
|
||||
icon: "🏛️",
|
||||
check: (s) => hasGenerator(s, "etang"),
|
||||
},
|
||||
{
|
||||
id: "first_lac",
|
||||
name: "Seigneur du Lac",
|
||||
description: "Accéder au Lac Mystique.",
|
||||
icon: "🔮",
|
||||
check: (s) => hasGenerator(s, "lac"),
|
||||
},
|
||||
{
|
||||
id: "industriel",
|
||||
name: "Industriel",
|
||||
description: "Posséder 10 générateurs au total.",
|
||||
icon: "🏭",
|
||||
check: (s) => totalGeneratorsOwned(s) >= 10,
|
||||
},
|
||||
{
|
||||
id: "magnate",
|
||||
name: "Magnate",
|
||||
description: "Posséder 50 générateurs au total.",
|
||||
icon: "💎",
|
||||
check: (s) => totalGeneratorsOwned(s) >= 50,
|
||||
},
|
||||
{
|
||||
id: "tycoon",
|
||||
name: "Tycoon du Marais",
|
||||
description: "Posséder 100 générateurs au total.",
|
||||
icon: "🐸",
|
||||
check: (s) => totalGeneratorsOwned(s) >= 100,
|
||||
},
|
||||
|
||||
// --- Prestige ---
|
||||
{
|
||||
id: "first_prestige",
|
||||
name: "Nouvelle Génération",
|
||||
description: "Effectuer son premier prestige.",
|
||||
icon: "🧬",
|
||||
check: (s) => s.prestigeCount >= 1,
|
||||
},
|
||||
{
|
||||
id: "veteran",
|
||||
name: "Vétéran",
|
||||
description: "Atteindre 5 prestiges.",
|
||||
icon: "⭐",
|
||||
check: (s) => s.prestigeCount >= 5,
|
||||
},
|
||||
{
|
||||
id: "legend",
|
||||
name: "Légende du Marais",
|
||||
description: "Atteindre 10 prestiges.",
|
||||
icon: "🏆",
|
||||
check: (s) => s.prestigeCount >= 10,
|
||||
},
|
||||
|
||||
// --- ADN & Évolution ---
|
||||
{
|
||||
id: "first_dna",
|
||||
name: "ADN Ancestral",
|
||||
description: "Accumuler son premier ADN.",
|
||||
icon: "🧪",
|
||||
check: (s) => s.ancestralDna >= 1,
|
||||
},
|
||||
{
|
||||
id: "first_evolution",
|
||||
name: "Première Mutation",
|
||||
description: "Débloquer la Ponte Améliorée.",
|
||||
icon: "🦎",
|
||||
check: (s) => hasEvolutionNode(s, "ponte_amelioree"),
|
||||
},
|
||||
{
|
||||
id: "full_tree",
|
||||
name: "Évolution Complète",
|
||||
description: "Débloquer un nœud dans chaque branche de l'arbre.",
|
||||
icon: "🌳",
|
||||
check: (s) => {
|
||||
const branches = new Set(s.evolutionTree.filter((n) => n.unlocked).map((n) => n.branch));
|
||||
return branches.size >= 3;
|
||||
},
|
||||
},
|
||||
|
||||
// --- Easter eggs & humour ---
|
||||
{
|
||||
id: "chuck_norris",
|
||||
name: "Blague",
|
||||
description: "Quand Chuck Norris fait un programme, il installe les modules, code et vend le programme... ensuite il demande à quoi il doit servir.",
|
||||
icon: "🤜",
|
||||
check: (s) => s.lifetimeTadpoles >= 42,
|
||||
},
|
||||
{
|
||||
id: "patience",
|
||||
name: "Patience de Têtard",
|
||||
description: "Un têtard ne devient pas grenouille en un jour. Toi non plus visiblement.",
|
||||
icon: "🐌",
|
||||
check: (s) => hasGenerator(s, "nid") && s.resources < 50,
|
||||
},
|
||||
{
|
||||
id: "brain_powered",
|
||||
name: "Powered by Brain",
|
||||
description: "Le Brain a codé ce succès avant de savoir pourquoi. Classique.",
|
||||
icon: "🧠",
|
||||
check: (s) => s.prestigeCount >= 1 && totalGeneratorsOwned(s) >= 10,
|
||||
},
|
||||
{
|
||||
id: "marecage_addict",
|
||||
name: "Addict au Marécage",
|
||||
description: "T'as 10 marécages. Tu sens un peu la vase mais on respecte l'engagement.",
|
||||
icon: "🫠",
|
||||
check: (s) => s.generators.find((g) => g.id === "marecage")?.owned >= 10,
|
||||
},
|
||||
{
|
||||
id: "overkill",
|
||||
name: "Overkill",
|
||||
description: "25 Nids. T'aurais pu investir dans un Lac, mais non.",
|
||||
icon: "😅",
|
||||
check: (s) => s.generators.find((g) => g.id === "nid")?.owned >= 25,
|
||||
},
|
||||
{
|
||||
id: "symbiose_joke",
|
||||
name: "Le Cercle de la Vie",
|
||||
description: "Symbiose activée. Même Mufasa serait fier.",
|
||||
icon: "🦁",
|
||||
check: (s) => hasEvolutionNode(s, "symbiose_algale"),
|
||||
},
|
||||
];
|
||||
76
Frontend/src/lib/data/prestigeMilestones.ts
Normal file
76
Frontend/src/lib/data/prestigeMilestones.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// prestigeMilestones.ts — Paliers de prestige (Sprint 3)
|
||||
// 8 paliers : cosmétiques exclusifs + bonus gameplay légers
|
||||
|
||||
export type MilestoneRewardType = "cosmetic" | "bonus" | "title";
|
||||
|
||||
export interface PrestigeMilestone {
|
||||
id: string;
|
||||
threshold: number; // nombre de prestiges requis
|
||||
name: string;
|
||||
description: string;
|
||||
reward: MilestoneReward;
|
||||
}
|
||||
|
||||
export type MilestoneReward =
|
||||
| { type: "cosmetic"; cosmeticId: string; label: string }
|
||||
| { type: "bonus"; effect: string; value: number; label: string }
|
||||
| { type: "title"; title: string; label: string };
|
||||
|
||||
export const PRESTIGE_MILESTONES: PrestigeMilestone[] = [
|
||||
{
|
||||
id: "milestone_1",
|
||||
threshold: 1,
|
||||
name: "Premiere Generation",
|
||||
description: "Premier prestige accompli",
|
||||
reward: { type: "cosmetic", cosmeticId: "ribbon", label: "Ruban queue" },
|
||||
},
|
||||
{
|
||||
id: "milestone_3",
|
||||
threshold: 3,
|
||||
name: "Gardien Recurrent",
|
||||
description: "3 prestiges — la perseverance paie",
|
||||
reward: { type: "title", title: "Gardien Recurrent", label: "Titre exclusif" },
|
||||
},
|
||||
{
|
||||
id: "milestone_5",
|
||||
threshold: 5,
|
||||
name: "Nid Offert",
|
||||
description: "5 prestiges — un coup de pouce au depart",
|
||||
reward: { type: "bonus", effect: "start_nid", value: 1, label: "1 Nid gratuit au depart" },
|
||||
},
|
||||
{
|
||||
id: "milestone_10",
|
||||
threshold: 10,
|
||||
name: "Tetard Ancestral",
|
||||
description: "10 prestiges — la lignee s'affirme",
|
||||
reward: { type: "cosmetic", cosmeticId: "crown", label: "Couronne doree + skin Ancestral" },
|
||||
},
|
||||
{
|
||||
id: "milestone_15",
|
||||
threshold: 15,
|
||||
name: "Marais Fidele",
|
||||
description: "15 prestiges — le marais te reconnait",
|
||||
reward: { type: "bonus", effect: "offline_cap_perm", value: 0.05, label: "+5% offline cap permanent" },
|
||||
},
|
||||
{
|
||||
id: "milestone_25",
|
||||
threshold: 25,
|
||||
name: "Gardien Emerite",
|
||||
description: "25 prestiges — tissu d'algues ancestrales",
|
||||
reward: { type: "cosmetic", cosmeticId: "cape_algae", label: "Cape d'algues ancestrales" },
|
||||
},
|
||||
{
|
||||
id: "milestone_50",
|
||||
threshold: 50,
|
||||
name: "Legende du Marais",
|
||||
description: "50 prestiges — la legende est toi",
|
||||
reward: { type: "cosmetic", cosmeticId: "flame_tail", label: "Queue enflamee + particules dorees" },
|
||||
},
|
||||
{
|
||||
id: "milestone_100",
|
||||
threshold: 100,
|
||||
name: "Tetard Primordial",
|
||||
description: "100 prestiges — retour aux origines",
|
||||
reward: { type: "cosmetic", cosmeticId: "primordial_body", label: "Skin Tetard Primordial (full set)" },
|
||||
},
|
||||
];
|
||||
1
Frontend/src/lib/index.ts
Normal file
1
Frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,83 +0,0 @@
|
||||
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
|
||||
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
|
||||
|
||||
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
|
||||
|
||||
function base64UrlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier) {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function buildAuthUrl(redirectUri, provider, scope = 'openid profile email', clientId = OAUTH_CLIENT_ID) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
provider,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
|
||||
verifier,
|
||||
};
|
||||
}
|
||||
|
||||
export async function exchangeCode(code, verifier, redirectUri, clientId = OAUTH_CLIENT_ID) {
|
||||
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) throw new Error('No access_token in OAuth response');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveVerifier(verifier) {
|
||||
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
|
||||
}
|
||||
|
||||
export function loadVerifier() {
|
||||
return localStorage.getItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
|
||||
export function clearVerifier() {
|
||||
localStorage.removeItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
89
Frontend/src/lib/oauth.ts
Normal file
89
Frontend/src/lib/oauth.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// OAuth 2.0 PKCE client — SuperOAuth consumer for Clickerz
|
||||
|
||||
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
|
||||
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
|
||||
|
||||
const SESSION_KEY_VERIFIER = 'clkz_pkce_verifier';
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array.buffer);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function buildAuthUrl(
|
||||
redirectUri: string,
|
||||
provider: string,
|
||||
scope = 'openid profile email',
|
||||
clientId = OAUTH_CLIENT_ID
|
||||
) {
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
provider,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
return { url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`, verifier };
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
clientId = OAUTH_CLIENT_ID
|
||||
) {
|
||||
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) throw new Error('No access_token in OAuth response');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveVerifier(verifier: string) {
|
||||
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
|
||||
}
|
||||
|
||||
export function loadVerifier(): string | null {
|
||||
return localStorage.getItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
|
||||
export function clearVerifier() {
|
||||
localStorage.removeItem(SESSION_KEY_VERIFIER);
|
||||
}
|
||||
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;
|
||||
9
Frontend/src/lib/utils/formatNumber.ts
Normal file
9
Frontend/src/lib/utils/formatNumber.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// formatNumber.ts — Affichage formaté des grands nombres
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
if (n >= 1e12) return `${(n / 1e12).toFixed(2)}T`;
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
|
||||
return Math.floor(n).toString();
|
||||
}
|
||||
Reference in New Issue
Block a user