fix: refactor store to singleton class pattern (s.subscribe fix)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s

Exported $state proxies were confused with Svelte stores by SvelteKit
runtime, causing "s.subscribe is not a function" on /jeu.

Fix: encapsulate all $state fields in a Game class, export singleton.
Components import { game } and access game.state, game.click(), etc.
Class fields are proper $state — no raw proxy exported.
This commit is contained in:
2026-03-28 20:39:21 +01:00
parent cce7fa3190
commit 67931eeadb
16 changed files with 277 additions and 325 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { state, getProductionPerSecond, getCurrentClickGain } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
</script>
@@ -7,23 +7,23 @@
<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(getProductionPerSecond())}</span>
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(game.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(getCurrentClickGain())}</span>
<span class="gp-value text-[0.8rem]!">{formatNumber(game.clickGain)}</span>
</div>
<div class="gp-stat" title="Multiplicateur global (prestige)">
<span class="gp-label">Mult</span>
<span class="gp-value text-[0.8rem]!">x{state.prestigeMultiplier.toFixed(1)}</span>
<span class="gp-value text-[0.8rem]!">x{game.state.prestigeMultiplier.toFixed(1)}</span>
</div>
<div class="gp-stat" title="ADN Ancestral">
<span class="gp-label">ADN</span>
<span class="gp-value gp-accent-purple text-[0.8rem]!">{state.ancestralDna}</span>
<span class="gp-value gp-accent-purple text-[0.8rem]!">{game.state.ancestralDna}</span>
</div>
<div class="gp-stat" title="Nombre de prestiges">
<span class="gp-label">Gen.</span>
<span class="gp-value text-[0.8rem]!">{state.prestigeCount}</span>
<span class="gp-value text-[0.8rem]!">{game.state.prestigeCount}</span>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { state, equipCosmetic, unequipCosmetic } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
import CollapsiblePanel from './CollapsiblePanel.svelte';
@@ -13,8 +13,8 @@
};
const SLOT_ORDER: CosmeticSlot[] = ['hat', 'eyes', 'body', 'tail', 'accessory'];
let inventory = $derived(state.cosmeticInventory);
let equipped = $derived(state.cosmeticEquipped);
let inventory = $derived(game.state.cosmeticInventory);
let equipped = $derived(game.state.cosmeticEquipped);
let ownedCosmetics = $derived(COSMETICS.filter((c) => inventory.includes(c.id)));
</script>
@@ -40,7 +40,7 @@
<span class="gp-label">{cos.description}</span>
</div>
<button
onclick={() => isEquipped ? unequipCosmetic(slot) : equipCosmetic(cos.id)}
onclick={() => isEquipped ? game.unequipCosmetic(slot) : game.equipCosmetic(cos.id)}
class="gp-btn {isEquipped ? 'gp-btn--disabled' : 'gp-btn--buy'}"
>
{isEquipped ? 'Retirer' : 'Equiper'}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { state, buyNode, doResetTree, doUpgradeConvergence } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import {
canBuyEvolutionNode,
getSpentDna,
@@ -44,14 +44,14 @@
let activeBranch = $state<Branch>('ponte');
let branchConfig = $derived(BRANCH_CONFIG[activeBranch]);
let branchNodes = $derived(state.evolutionTree.filter((n) => n.branch === activeBranch));
let spentDna = $derived(getSpentDna(state.evolutionTree));
let branchNodes = $derived(game.state.evolutionTree.filter((n) => n.branch === activeBranch));
let spentDna = $derived(getSpentDna(game.state.evolutionTree));
let hasUnlocked = $derived(spentDna > 0);
let resetCost = $derived(getTreeResetCost(state));
let canReset = $derived(canResetTree(state));
let conv = $derived(state.evolutionTree.find((n) => n.id === 'convergence'));
let canBuyConv = $derived(canBuyEvolutionNode(state, 'convergence'));
let canUpgradeConv = $derived(canUpgradeConvergence(state));
let resetCost = $derived(getTreeResetCost(game.state));
let canReset = $derived(canResetTree(game.state));
let conv = $derived(game.state.evolutionTree.find((n) => n.id === 'convergence'));
let canBuyConv = $derived(canBuyEvolutionNode(game.state, 'convergence'));
let canUpgradeConv = $derived(canUpgradeConvergence(game.state));
function handleReset() {
if (!canReset) return;
@@ -59,7 +59,7 @@
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) doResetTree();
if (confirmed) game.resetTree();
}
function getNodeRowClass(node: EvolutionNode, isExcluded: boolean, canBuy: boolean): string {
@@ -70,13 +70,13 @@
}
</script>
{#if state.prestigeCount >= 1}
{#if game.state.prestigeCount >= 1}
<div class="flex flex-col gap-2">
<!-- Header -->
<div class="flex justify-between items-center px-1">
<span class="gp-title">Evolution</span>
<div class="flex items-center gap-2">
<span class="gp-value gp-accent-amber">{formatNumber(state.ancestralDna)} ADN</span>
<span class="gp-value gp-accent-amber">{formatNumber(game.state.ancestralDna)} ADN</span>
{#if hasUnlocked}
<button
onclick={handleReset}
@@ -108,8 +108,8 @@
<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 ? (state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
{@const canBuy = canBuyEvolutionNode(state, node.id)}
{@const isExcluded = node.exclusive_with ? (game.state.evolutionTree.find((n) => n.id === node.exclusive_with)?.unlocked ?? false) : false}
{@const canBuy = canBuyEvolutionNode(game.state, node.id)}
{@const cost = node.repeatable && node.unlocked ? getRepeatableCost(node) : node.cost}
<div class={getNodeRowClass(node, isExcluded, canBuy)}>
<div class="flex flex-col min-w-0">
@@ -132,7 +132,7 @@
{:else}
<button
disabled={!canBuy}
onclick={() => buyNode(node.id)}
onclick={() => game.buyNode(node.id)}
class="gp-btn {canBuy ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{formatNumber(cost)}
@@ -164,7 +164,7 @@
{#if tier < maxTier}
<button
disabled={!canUpgradeConv}
onclick={() => doUpgradeConvergence()}
onclick={() => game.upgradeConvergence()}
class="gp-btn {canUpgradeConv ? 'gp-btn--buy' : 'gp-btn--disabled'} w-full"
>
{canUpgradeConv ? `Evoluer Omega (${conv.tierUpgradeCost} ADN)` : `Requis : 2 capstones (${conv.tierUpgradeCost} ADN)`}
@@ -180,7 +180,7 @@
</div>
<button
disabled={!canBuyConv}
onclick={() => buyNode('convergence')}
onclick={() => game.buyNode('convergence')}
class="gp-btn {canBuyConv ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{conv.cost}

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { refs, initGuest } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import {
loadFromServer,
startAutoSave,
@@ -16,13 +16,13 @@
// Load save or init guest
if (authStore.user) {
const loaded = await loadFromServer();
if (!loaded && !refs.ready) {
initGuest();
if (!loaded && !game.ready) {
game.initGuest();
}
startAutoSave();
setupVisibilitySync();
} else {
initGuest();
game.initGuest();
}
});
</script>

View File

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

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { state, buy, getProductionPerSecond, getGeneratorCostWithTree } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import CollapsiblePanel from './CollapsiblePanel.svelte';
</script>
<CollapsiblePanel
title="Generateurs"
badge="{formatNumber(getProductionPerSecond())}/s"
badge="{formatNumber(game.productionPerSecond)}/s"
accentClass=""
>
{#each state.generators as gen, i}
{@const cost = getGeneratorCostWithTree(gen)}
{@const canAfford = state.resources >= cost}
{#each game.state.generators as gen, i}
{@const cost = game.generatorCostWithTree(gen)}
{@const canAfford = game.state.resources >= cost}
{@const currentProd = gen.baseProduction * gen.owned}
<div
class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}"
@@ -39,7 +39,7 @@
</span>
</div>
<button
onclick={() => buy(gen.id)}
onclick={() => game.buy(gen.id)}
disabled={!canAfford}
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { state } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import { getPrestigeThreshold } from '$lib/core/economy';
let threshold = $derived(getPrestigeThreshold(state));
let progress = $derived(Math.min(state.resources / threshold, 1));
let threshold = $derived(getPrestigeThreshold(game.state));
let progress = $derived(Math.min(game.state.resources / threshold, 1));
let progressPercent = $derived((progress * 100).toFixed(1));
let remaining = $derived(Math.max(threshold - state.resources, 0));
let remaining = $derived(Math.max(threshold - game.state.resources, 0));
</script>
<div class="gp gap-1">
<div class="flex justify-between">
<span class="gp-label">Prochaine Generation</span>
<span class="gp-label">{formatNumber(state.resources)} / {formatNumber(threshold)}</span>
<span class="gp-label">{formatNumber(game.state.resources)} / {formatNumber(threshold)}</span>
</div>
<div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progressPercent}%"></div>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import { fly, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { state, doClaimMilestone } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy';
import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones';
import CollapsiblePanel from './CollapsiblePanel.svelte';
let claimable = $derived(getClaimableMilestones(state));
let nextMilestone = $derived(getNextMilestone(state));
let claimed = $derived(state.claimedMilestones ?? []);
let claimable = $derived(getClaimableMilestones(game.state));
let nextMilestone = $derived(getNextMilestone(game.state));
let claimed = $derived(game.state.claimedMilestones ?? []);
let totalClaimed = $derived(claimed.length);
</script>
{#if state.prestigeCount >= 1}
{#if game.state.prestigeCount >= 1}
<CollapsiblePanel
title="Milestones"
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
@@ -29,7 +29,7 @@
<span class="gp-value text-[0.7rem]!">{m.name}</span>
<span class="gp-label">{m.reward.label}</span>
</div>
<button onclick={() => doClaimMilestone(m.id)} class="gp-btn gp-btn--buy">
<button onclick={() => game.claimMilestone(m.id)} class="gp-btn gp-btn--buy">
Claim
</button>
</div>
@@ -38,11 +38,11 @@
{/if}
{#if nextMilestone}
{@const progressPct = Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
{@const progressPct = Math.min((game.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
<div class="flex flex-col gap-1">
<div class="flex justify-between">
<span class="gp-label">Prochain : {nextMilestone.name}</span>
<span class="gp-label">{state.prestigeCount}/{nextMilestone.threshold}</span>
<span class="gp-label">{game.state.prestigeCount}/{nextMilestone.threshold}</span>
</div>
<div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400" style="width: {progressPct}%"></div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fade, fly, scale } from 'svelte/transition';
import { backOut, quintOut } from 'svelte/easing';
import { refs, dismissOfflineReport } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
function formatDuration(ms: number): string {
@@ -12,16 +12,16 @@
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && refs.offlineReport) dismissOfflineReport(); }} />
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && game.offlineReport) game.dismissOfflineReport(); }} />
{#if refs.offlineReport}
{#if game.offlineReport}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);"
transition:fade={{ duration: 250 }}
onclick={() => dismissOfflineReport()}
onclick={() => game.dismissOfflineReport()}
>
<div
class="gp max-w-sm w-full mx-4 text-center"
@@ -32,7 +32,7 @@
<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(refs.offlineReport.duration)}</span>
Absent pendant <span class="gp-accent-green">{formatDuration(game.offlineReport.duration)}</span>
</p>
</div>
@@ -41,17 +41,17 @@
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(refs.offlineReport.gains)} tetards
+{formatNumber(game.offlineReport.gains)} tetards
</p>
</div>
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
Efficacite : {Math.round(refs.offlineReport.efficiency * 100)}%
Efficacite : {Math.round(game.offlineReport.efficiency * 100)}%
</p>
<button
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
onclick={() => dismissOfflineReport()}
onclick={() => game.dismissOfflineReport()}
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
>
Continuer

View File

@@ -1,26 +1,26 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import { state, getCanPrestige, openPrestige } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
import { formatNumber } from '$lib/utils/formatNumber';
import CollapsiblePanel from './CollapsiblePanel.svelte';
let baseDna = $derived(computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(state.evolutionTree));
let baseDna = $derived(computePrestigeDna(game.state.lifetimeTadpoles, game.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(game.state.evolutionTree));
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
let threshold = $derived(getPrestigeThreshold(state));
let progress = $derived(Math.min(state.lifetimeTadpoles / threshold * 100, 100));
let threshold = $derived(getPrestigeThreshold(game.state));
let progress = $derived(Math.min(game.state.lifetimeTadpoles / threshold * 100, 100));
</script>
<CollapsiblePanel title="Prestige" accentClass="gp-accent-purple">
{#if getCanPrestige()}
{#if game.canPrestige}
<div class="flex flex-col gap-2" in:scale={{ duration: 300, start: 0.9, easing: quintOut }}>
<div class="flex items-center justify-between">
<span class="gp-value gp-accent-purple">+{dnaPreview} ADN</span>
<span class="gp-label">+0.1x mult</span>
</div>
<button onclick={() => openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
<button onclick={() => game.openPrestige()} class="gp-btn gp-btn--prestige w-full py-2.5!">
Nouvelle Generation
</button>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, backOut } from 'svelte/easing';
import { state, refs, prestige, closePrestige } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { computePrestigeDna, getPrestigeDnaBonus, getPrestigeThreshold } from '$lib/core/economy';
import { formatNumber } from '$lib/utils/formatNumber';
@@ -15,24 +15,24 @@
return `${seconds}s`;
}
let baseDna = $derived(computePrestigeDna(state.lifetimeTadpoles, state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(state.evolutionTree));
let baseDna = $derived(computePrestigeDna(game.game.state.lifetimeTadpoles, game.game.state.prestigeCount));
let dnaBonus = $derived(getPrestigeDnaBonus(game.state.evolutionTree));
let dnaPreview = $derived(Math.floor(baseDna * (1 + dnaBonus)));
let threshold = $derived(getPrestigeThreshold(state));
let canPrestige = $derived(state.lifetimeTadpoles >= threshold);
let runDuration = $derived(Date.now() - state.runStats.startedAt);
let bestRun = $derived(state.runStats.bestRun);
let threshold = $derived(getPrestigeThreshold(game.state));
let canPrestige = $derived(game.game.state.lifetimeTadpoles >= threshold);
let runDuration = $derived(Date.now() - game.state.runStats.startedAt);
let bestRun = $derived(game.state.runStats.bestRun);
let isBestAdn = $derived(!bestRun || dnaPreview > bestRun.adn);
let isBestTadpoles = $derived(!bestRun || state.lifetimeTadpoles > bestRun.tadpoles);
let isBestTadpoles = $derived(!bestRun || game.game.state.lifetimeTadpoles > bestRun.tadpoles);
function handlePrestige() {
if (canPrestige) prestige();
if (canPrestige) game.prestige();
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && refs.showPrestigeScreen) closePrestige(); }} />
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && game.showPrestigeScreen) game.game.closePrestige(); }} />
{#if refs.showPrestigeScreen}
{#if game.showPrestigeScreen}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center"
@@ -48,7 +48,7 @@
<!-- 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 #{state.prestigeCount + 1}</p>
<p class="gp-label mt-1">Generation #{game.state.prestigeCount + 1}</p>
</div>
<div class="gp-sep"></div>
@@ -68,7 +68,7 @@
{#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(state.ancestralDna + dnaPreview)} ADN</span>
<span class="gp-label mt-1">Total apres : {formatNumber(game.state.ancestralDna + dnaPreview)} ADN</span>
</div>
<div class="gp-sep"></div>
@@ -85,7 +85,7 @@
<div class="flex justify-between">
<span class="gp-label">Tetards produits</span>
<span class="gp-value {isBestTadpoles ? 'gp-accent-green' : ''}">
{formatNumber(state.lifetimeTadpoles)}
{formatNumber(game.state.lifetimeTadpoles)}
{#if isBestTadpoles && bestRun}{/if}
</span>
</div>
@@ -141,7 +141,7 @@
<!-- Actions -->
<div class="flex gap-2 mt-1" in:fly={{ y: 20, delay: 450, duration: 300, easing: quintOut }}>
<button
onclick={() => closePrestige()}
onclick={() => game.closePrestige()}
class="gp-btn flex-1 py-2.5! text-[0.8rem]!"
style="background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.6);"
>
@@ -153,7 +153,7 @@
</button>
{:else}
<button class="gp-btn gp-btn--disabled flex-1 py-2.5!" disabled>
{formatNumber(threshold - state.lifetimeTadpoles)} manquants
{formatNumber(threshold - game.state.lifetimeTadpoles)} manquants
</button>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { state } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { COSMETICS, type CosmeticSlot } from '$lib/core/cosmetics';
const SLOT_ORDER: CosmeticSlot[] = ['body', 'tail', 'eyes', 'hat', 'accessory'];
@@ -7,7 +7,7 @@
let overlays = $derived(
SLOT_ORDER
.map((slot) => {
const cosId = state.cosmeticEquipped[slot];
const cosId = game.state.cosmeticEquipped[slot];
if (!cosId) return null;
return COSMETICS.find((c) => c.id === cosId) ?? null;
})

View File

@@ -1,7 +1,7 @@
// save-sync.ts — Auto-save game state to backend every 30s
// Server = authority. NEVER save before server state is loaded (ready guard).
import { state, refs, loadFromServer as storeLoadFromServer, initGuest } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { migrateSave } from '$lib/core/migrateSave';
import type { GameState } from '$lib/core/economy';
@@ -27,12 +27,12 @@ let loaded = false;
let saveInterval: ReturnType<typeof setInterval> | null = null;
export async function saveToServer() {
if (!authStore.user || !refs.ready) return;
if (!authStore.user || !game.ready) return;
const result = await apiRequest('/save', {
method: 'POST',
body: JSON.stringify({
gameState: state,
playTimeSeconds: refs.playSeconds,
gameState: game.state,
playTimeSeconds: game.playSeconds,
}),
});
if (result?.lastSave) {
@@ -51,7 +51,7 @@ export async function loadFromServer(): Promise<boolean> {
const data = await apiRequest('/save');
if (data?.gameState) {
const migrated = migrateSave(data.gameState);
storeLoadFromServer(migrated);
game.loadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Loaded save from server (v%d)', migrated.saveVersion);
return true;
@@ -67,7 +67,7 @@ export async function loadFromServer(): Promise<boolean> {
export function startAutoSave() {
stopAutoSave();
saveInterval = setInterval(() => {
if (authStore.user && refs.ready) saveToServer();
if (authStore.user && game.ready) saveToServer();
}, SAVE_INTERVAL_MS);
}
@@ -88,7 +88,7 @@ export function setupVisibilitySync() {
if (data?.gameState && data.lastSave) {
if (!lastSave || new Date(data.lastSave) > new Date(lastSave)) {
const migrated = migrateSave(data.gameState);
storeLoadFromServer(migrated);
game.loadFromServer(migrated);
lastSave = data.lastSave;
console.info('[SaveSync] Reloaded from server on focus');
}
@@ -97,14 +97,14 @@ export function setupVisibilitySync() {
});
window.addEventListener('blur', () => {
if (authStore.user && refs.ready) saveToServer();
if (authStore.user && game.ready) saveToServer();
});
window.addEventListener('beforeunload', () => {
if (!authStore.user || !refs.ready) return;
if (!authStore.user || !game.ready) return;
const payload = JSON.stringify({
gameState: state,
playTimeSeconds: refs.playSeconds,
gameState: game.state,
playTimeSeconds: game.playSeconds,
});
fetch(`${BACKEND_URL}/api/save`, {
method: 'POST',

View File

@@ -1,6 +1,7 @@
// game.svelte.ts — Game store (Svelte 5 runes)
// game.svelte.ts — Game store (Svelte 5 class pattern)
// Server = authority. localStorage = fallback guest only.
// Architecture: $state for all reactive values, direct access (no getter indirection).
// Pattern: singleton class with $state fields — the officially recommended
// Svelte 5 approach for shared reactive state across components.
import {
type GameState,
@@ -41,229 +42,180 @@ export interface OfflineReport {
efficiency: number;
}
// --- All reactive state ---
// Svelte 5 modules cannot export $state that is reassigned.
// Use a reactive container object + property mutation instead.
class Game {
// --- Reactive fields ---
state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
playSeconds = $state(0);
ready = $state(false);
offlineReport = $state<OfflineReport | null>(null);
showPrestigeScreen = $state(false);
lastClickGain = $state(0);
lastClickDouble = $state(false);
lastClickCrit = $state(false);
export const refs = $state({
ready: false,
playSeconds: 0,
offlineReport: null as OfflineReport | null,
showPrestigeScreen: false,
lastClickGain: 0,
lastClickDouble: false,
lastClickCrit: false,
});
// --- Derived (computed live from state) ---
get canPrestige() { return canPrestigeCheck(this.state); }
get productionPerSecond() { return totalProductionPerSecond(this.state); }
get clickGain() { return getClickGain(this.state); }
// Main game state: exported as $state object, mutated via Object.assign (never reassigned).
export const state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
// --- 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;
return {
state: {
...saved,
resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now,
lastOnline: now,
},
report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency },
};
}
// --- Actions (exported directly, no wrapper object) ---
export function tick() {
if (!refs.ready) return;
const now = Date.now();
const updated = { ...applyIdleGains(state, now), lastOnline: now };
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
if (refs.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'));
// --- Private helpers ---
private loadLocalState(): GameState {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
return applyIdleGains(migrateSave(JSON.parse(raw)), Date.now());
} catch {
return { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
}
}
saveLocal(updated);
Object.assign(state, updated);
refs.playSeconds += 1;
private saveLocal(s: GameState) {
localStorage.setItem(SAVE_KEY, JSON.stringify(s));
}
private hydrateWithOffline(saved: GameState, now: number) {
const elapsed = now - saved.lastTick;
if (elapsed <= OFFLINE_THRESHOLD) {
return { state: { ...applyIdleGains(saved, now), lastOnline: now }, report: null };
}
const gains = computeOfflineGains(saved, now);
const pps = totalProductionPerSecond(saved);
const fullGains = pps * (elapsed / 1000);
return {
state: { ...saved, resources: saved.resources + gains, lifetimeTadpoles: saved.lifetimeTadpoles + gains, lastTick: now, lastOnline: now },
report: { wasOffline: true, duration: elapsed, gains, efficiency: fullGains > 0 ? gains / fullGains : 0 } as OfflineReport,
};
}
private applyState(updated: GameState) {
this.saveLocal(updated);
Object.assign(this.state, updated);
}
// --- Actions ---
tick() {
if (!this.ready) return;
const now = Date.now();
const updated = { ...applyIdleGains(this.state, now), lastOnline: now };
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks;
updated.resources += autoGain;
updated.lifetimeTadpoles += autoGain;
}
if (this.playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) {
updated.cosmeticInventory = addToInventory(cosState, newUnlocks).inventory;
newUnlocks.forEach(() => toast('Nouveau cosmetique debloque !', 'reward'));
}
}
this.applyState(updated);
this.playSeconds += 1;
}
click() {
if (!this.ready) return;
const result = applyClick(applyIdleGains(this.state, Date.now()));
this.applyState(result.state);
this.lastClickGain = result.gain;
this.lastClickDouble = result.isDouble;
this.lastClickCrit = result.isCrit;
}
buy(genId: string) {
if (!this.ready) return;
const updated = buyGenerator(applyIdleGains(this.state, Date.now()), genId);
if (updated) this.applyState(updated);
}
buyNode(nodeId: string) {
if (!this.ready) return;
const updated = buyEvolutionNode(this.state, nodeId);
if (!updated) return;
const node = updated.evolutionTree.find((n) => n.id === nodeId);
if (node?.capstone) toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
this.applyState(updated);
}
prestige() {
if (!this.ready || !canPrestigeCheck(this.state)) return;
const updated = applyPrestige(this.state);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
this.applyState(updated);
this.showPrestigeScreen = false;
}
equipCosmetic(cosmeticId: string) {
if (!this.ready) return;
const cosState = { inventory: this.state.cosmeticInventory, equipped: this.state.cosmeticEquipped };
this.state.cosmeticEquipped = equipCosmeticFn(cosState, cosmeticId).equipped;
this.saveLocal(this.state);
}
unequipCosmetic(slot: CosmeticSlot) {
if (!this.ready) return;
const cosState = { inventory: this.state.cosmeticInventory, equipped: this.state.cosmeticEquipped };
this.state.cosmeticEquipped = unequipSlotFn(cosState, slot).equipped;
this.saveLocal(this.state);
}
resetTree() {
if (!this.ready || !canResetTree(this.state)) return;
this.applyState(resetEvolutionTree(this.state));
}
upgradeConvergence() {
if (!this.ready) return;
const updated = upgradeConvergence(this.state);
if (updated) this.applyState(updated);
}
claimMilestone(milestoneId: string) {
if (!this.ready) return;
const updated = claimMilestoneFn(this.state, milestoneId);
if (!updated) return;
toast('Milestone debloque !', 'reward', 4000);
this.applyState(updated);
}
reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
this.applyState(fresh);
this.playSeconds = 0;
this.ready = true;
this.offlineReport = null;
}
loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = this.hydrateWithOffline(migrated, Date.now());
this.applyState(result.state);
this.ready = true;
this.offlineReport = result.report;
}
initGuest() {
const local = this.loadLocalState();
const result = this.hydrateWithOffline(local, Date.now());
this.applyState(result.state);
this.ready = true;
this.offlineReport = result.report;
}
dismissOfflineReport() { this.offlineReport = null; }
openPrestige() { this.showPrestigeScreen = true; }
closePrestige() { this.showPrestigeScreen = false; }
generatorCost = genCost;
generatorCostWithTree(gen: Parameters<typeof genCost>[0]) {
return genCost(gen, this.state.evolutionTree);
}
}
export function click() {
if (!refs.ready) return;
const result = applyClick(applyIdleGains(state, Date.now()));
saveLocal(result.state);
Object.assign(state, result.state);
refs.lastClickGain = result.gain;
refs.lastClickDouble = result.isDouble;
refs.lastClickCrit = result.isCrit;
}
export function buy(genId: string) {
if (!refs.ready) return;
const updated = buyGenerator(applyIdleGains(state, Date.now()), genId);
if (!updated) return;
saveLocal(updated);
Object.assign(state, updated);
}
export function buyNode(nodeId: string) {
if (!refs.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);
Object.assign(state, updated);
}
export function prestige() {
if (!refs.ready) return;
if (!canPrestigeCheck(state)) return;
const updated = applyPrestige(state);
saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
Object.assign(state, updated);
refs.showPrestigeScreen = false;
}
export function equipCosmetic(cosmeticId: string) {
if (!refs.ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId);
state.cosmeticEquipped = updated.equipped;
saveLocal(state);
}
export function unequipCosmetic(slot: CosmeticSlot) {
if (!refs.ready) return;
const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot);
state.cosmeticEquipped = updated.equipped;
saveLocal(state);
}
export function doResetTree() {
if (!refs.ready) return;
if (!canResetTree(state)) return;
const updated = resetEvolutionTree(state);
saveLocal(updated);
Object.assign(state, updated);
}
export function doUpgradeConvergence() {
if (!refs.ready) return;
const updated = upgradeConvergence(state);
if (!updated) return;
saveLocal(updated);
Object.assign(state, updated);
}
export function doClaimMilestone(milestoneId: string) {
if (!refs.ready) return;
const updated = claimMilestoneFn(state, milestoneId);
if (!updated) return;
saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000);
Object.assign(state, updated);
}
export function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh);
Object.assign(state, fresh);
refs.playSeconds = 0;
refs.ready = true;
refs.offlineReport = null;
}
export function loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = hydrateWithOffline(migrated, Date.now());
saveLocal(result.state);
Object.assign(state, result.state);
refs.ready = true;
refs.offlineReport = result.report;
}
export function initGuest() {
const local = loadLocalState();
const result = hydrateWithOffline(local, Date.now());
saveLocal(result.state);
Object.assign(state, result.state);
refs.ready = true;
refs.offlineReport = result.report;
}
export function dismissOfflineReport() {
refs.offlineReport = null;
}
export function openPrestige() {
refs.showPrestigeScreen = true;
}
export function closePrestige() {
refs.showPrestigeScreen = false;
}
// --- Computed helpers (call from templates, they read `state` reactively) ---
export function getProductionPerSecond() {
return totalProductionPerSecond(state);
}
export function getCanPrestige() {
return canPrestigeCheck(state);
}
export function getCurrentClickGain() {
return getClickGain(state);
}
export function getGeneratorCostWithTree(gen: Parameters<typeof genCost>[0]) {
return genCost(gen, state.evolutionTree);
}
export { genCost as generatorCost };
// Singleton — import { game } from '$lib/stores/game.svelte';
export const game = new Game();

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, backOut } from 'svelte/easing';
import { state } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { ACHIEVEMENTS } from '$lib/data/achievements';
let filter = $state<'all' | 'unlocked' | 'locked'>('all');
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(state)));
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(state)));
let unlocked = $derived(ACHIEVEMENTS.filter((a) => a.check(game.state)));
let locked = $derived(ACHIEVEMENTS.filter((a) => !a.check(game.state)));
let displayed = $derived(
filter === 'unlocked' ? unlocked

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { fly, scale, fade } from 'svelte/transition';
import { quintOut, elasticOut } from 'svelte/easing';
import { state, refs, click } from '$lib/stores/game.svelte';
import { game } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber';
import CockpitHeader from '$lib/components/CockpitHeader.svelte';
import GeneratorShop from '$lib/components/GeneratorShop.svelte';
@@ -16,7 +16,7 @@
import SidebarTabs from '$lib/components/SidebarTabs.svelte';
import { ACHIEVEMENTS } from '$lib/data/achievements';
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(state)).length);
let achieveCount = $derived(ACHIEVEMENTS.filter((a) => a.check(game.state)).length);
const sidebarTabs = [
{ id: 'production', label: 'Production', icon: '🏭' },
@@ -29,8 +29,8 @@
let tadpoleSprite: ReturnType<typeof TadpoleSprite>;
function handleClick(e: MouseEvent) {
click();
clickParticles?.spawn(e.clientX, e.clientY, refs.lastClickGain, refs.lastClickDouble, refs.lastClickCrit);
game.click();
clickParticles?.spawn(e.clientX, e.clientY, game.lastClickGain, game.lastClickDouble, game.lastClickCrit);
tadpoleSprite?.bounce();
}
@@ -43,7 +43,7 @@
<meta name="description" content="Clickerz — Clicker idle dans le Tetard Universe." />
</svelte:head>
{#if !refs.ready}
{#if !game.ready}
<div class="flex items-center justify-center min-h-[80vh]" in:fade>
<div class="flex flex-col items-center gap-4">
<div class="w-16 h-16 border-4 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin"></div>
@@ -66,7 +66,7 @@
class="click-zone-counter"
in:fly={{ y: 20, duration: 400, easing: quintOut }}
>
{formatNumber(state.resources)}
{formatNumber(game.state.resources)}
</div>
</div>