fix: refactor store to direct $state exports + Object.assign mutation
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 22s

Svelte 5 can't export reassigned $state — use const $state + Object.assign.
All components now import state/actions directly (no gameStore wrapper).
Deep reactivity works: evolutionTree nodes, generators, cosmetics all tracked.
This commit is contained in:
2026-03-28 20:23:57 +01:00
parent ce38975c10
commit 10ff2d32f5
16 changed files with 214 additions and 252 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { gameStore } from '$lib/stores/game.svelte'; import { state, getProductionPerSecond, getCurrentClickGain } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber'; import { formatNumber } from '$lib/utils/formatNumber';
</script> </script>
@@ -7,23 +7,23 @@
<div class="grid grid-cols-5 gap-0.5 px-1"> <div class="grid grid-cols-5 gap-0.5 px-1">
<div class="gp-stat" title="Production automatique par seconde"> <div class="gp-stat" title="Production automatique par seconde">
<span class="gp-label">Prod/s</span> <span class="gp-label">Prod/s</span>
<span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(gameStore.productionPerSecond)}</span> <span class="gp-value gp-accent-green text-[0.8rem]!">{formatNumber(getProductionPerSecond())}</span>
</div> </div>
<div class="gp-stat" title="Tetards gagnes par clic"> <div class="gp-stat" title="Tetards gagnes par clic">
<span class="gp-label">/clic</span> <span class="gp-label">/clic</span>
<span class="gp-value text-[0.8rem]!">{formatNumber(gameStore.getClickGain())}</span> <span class="gp-value text-[0.8rem]!">{formatNumber(getCurrentClickGain())}</span>
</div> </div>
<div class="gp-stat" title="Multiplicateur global (prestige)"> <div class="gp-stat" title="Multiplicateur global (prestige)">
<span class="gp-label">Mult</span> <span class="gp-label">Mult</span>
<span class="gp-value text-[0.8rem]!">x{gameStore.state.prestigeMultiplier.toFixed(1)}</span> <span class="gp-value text-[0.8rem]!">x{state.prestigeMultiplier.toFixed(1)}</span>
</div> </div>
<div class="gp-stat" title="ADN Ancestral"> <div class="gp-stat" title="ADN Ancestral">
<span class="gp-label">ADN</span> <span class="gp-label">ADN</span>
<span class="gp-value gp-accent-purple text-[0.8rem]!">{gameStore.state.ancestralDna}</span> <span class="gp-value gp-accent-purple text-[0.8rem]!">{state.ancestralDna}</span>
</div> </div>
<div class="gp-stat" title="Nombre de prestiges"> <div class="gp-stat" title="Nombre de prestiges">
<span class="gp-label">Gen.</span> <span class="gp-label">Gen.</span>
<span class="gp-value text-[0.8rem]!">{gameStore.state.prestigeCount}</span> <span class="gp-value text-[0.8rem]!">{state.prestigeCount}</span>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
<script lang="ts"> <script lang="ts">
import { gameStore } from '$lib/stores/game.svelte'; import { state } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber'; import { formatNumber } from '$lib/utils/formatNumber';
import { getPrestigeThreshold } from '$lib/core/economy'; import { getPrestigeThreshold } from '$lib/core/economy';
let threshold = $derived(getPrestigeThreshold(gameStore.state)); let threshold = $derived(getPrestigeThreshold(state));
let progress = $derived(Math.min(gameStore.state.resources / threshold, 1)); let progress = $derived(Math.min(state.resources / threshold, 1));
let progressPercent = $derived((progress * 100).toFixed(1)); let progressPercent = $derived((progress * 100).toFixed(1));
let remaining = $derived(Math.max(threshold - gameStore.state.resources, 0)); let remaining = $derived(Math.max(threshold - state.resources, 0));
</script> </script>
<div class="gp gap-1"> <div class="gp gap-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="gp-label">Prochaine Generation</span> <span class="gp-label">Prochaine Generation</span>
<span class="gp-label">{formatNumber(gameStore.state.resources)} / {formatNumber(threshold)}</span> <span class="gp-label">{formatNumber(state.resources)} / {formatNumber(threshold)}</span>
</div> </div>
<div class="gp-progress"> <div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-violet-600 to-violet-400" style="width: {progressPercent}%"></div> <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"> <script lang="ts">
import { fly, scale } from 'svelte/transition'; import { fly, scale } from 'svelte/transition';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte'; import { state, doClaimMilestone } from '$lib/stores/game.svelte';
import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy'; import { getClaimableMilestones, getNextMilestone } from '$lib/core/economy';
import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones'; import { PRESTIGE_MILESTONES } from '$lib/data/prestigeMilestones';
import CollapsiblePanel from './CollapsiblePanel.svelte'; import CollapsiblePanel from './CollapsiblePanel.svelte';
let claimable = $derived(getClaimableMilestones(gameStore.state)); let claimable = $derived(getClaimableMilestones(state));
let nextMilestone = $derived(getNextMilestone(gameStore.state)); let nextMilestone = $derived(getNextMilestone(state));
let claimed = $derived(gameStore.state.claimedMilestones ?? []); let claimed = $derived(state.claimedMilestones ?? []);
let totalClaimed = $derived(claimed.length); let totalClaimed = $derived(claimed.length);
</script> </script>
{#if gameStore.state.prestigeCount >= 1} {#if state.prestigeCount >= 1}
<CollapsiblePanel <CollapsiblePanel
title="Milestones" title="Milestones"
badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}" badge="{totalClaimed}/{PRESTIGE_MILESTONES.length}"
@@ -29,7 +29,7 @@
<span class="gp-value text-[0.7rem]!">{m.name}</span> <span class="gp-value text-[0.7rem]!">{m.name}</span>
<span class="gp-label">{m.reward.label}</span> <span class="gp-label">{m.reward.label}</span>
</div> </div>
<button onclick={() => gameStore.claimMilestone(m.id)} class="gp-btn gp-btn--buy"> <button onclick={() => doClaimMilestone(m.id)} class="gp-btn gp-btn--buy">
Claim Claim
</button> </button>
</div> </div>
@@ -38,11 +38,11 @@
{/if} {/if}
{#if nextMilestone} {#if nextMilestone}
{@const progressPct = Math.min((gameStore.state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)} {@const progressPct = Math.min((state.prestigeCount / nextMilestone.threshold) * 100, 100).toFixed(1)}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="gp-label">Prochain : {nextMilestone.name}</span> <span class="gp-label">Prochain : {nextMilestone.name}</span>
<span class="gp-label">{gameStore.state.prestigeCount}/{nextMilestone.threshold}</span> <span class="gp-label">{state.prestigeCount}/{nextMilestone.threshold}</span>
</div> </div>
<div class="gp-progress"> <div class="gp-progress">
<div class="gp-progress-fill bg-gradient-to-r from-purple-600 to-purple-400" style="width: {progressPct}%"></div> <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"> <script lang="ts">
import { fade, fly, scale } from 'svelte/transition'; import { fade, fly, scale } from 'svelte/transition';
import { backOut, quintOut } from 'svelte/easing'; import { backOut, quintOut } from 'svelte/easing';
import { gameStore } from '$lib/stores/game.svelte'; import { refs, dismissOfflineReport } from '$lib/stores/game.svelte';
import { formatNumber } from '$lib/utils/formatNumber'; import { formatNumber } from '$lib/utils/formatNumber';
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -12,16 +12,16 @@
} }
</script> </script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape' && gameStore.offlineReport) gameStore.dismissOfflineReport(); }} /> <svelte:window onkeydown={(e) => { if (e.key === 'Escape' && refs.offlineReport) dismissOfflineReport(); }} />
{#if gameStore.offlineReport} {#if refs.offlineReport}
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-50 flex items-center justify-center" class="fixed inset-0 z-50 flex items-center justify-center"
style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);" style="background: rgba(0,0,0,0.7); backdrop-filter: blur(6px);"
transition:fade={{ duration: 250 }} transition:fade={{ duration: 250 }}
onclick={() => gameStore.dismissOfflineReport()} onclick={() => dismissOfflineReport()}
> >
<div <div
class="gp max-w-sm w-full mx-4 text-center" 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 }}> <div in:fly={{ y: -15, delay: 100, duration: 350, easing: quintOut }}>
<h2 class="gp-title text-lg!">Retour au Marais</h2> <h2 class="gp-title text-lg!">Retour au Marais</h2>
<p class="gp-label mt-2"> <p class="gp-label mt-2">
Absent pendant <span class="gp-accent-green">{formatDuration(gameStore.offlineReport.duration)}</span> Absent pendant <span class="gp-accent-green">{formatDuration(refs.offlineReport.duration)}</span>
</p> </p>
</div> </div>
@@ -41,17 +41,17 @@
class="gp-value text-3xl! mt-4 mb-2 gp-accent-green" class="gp-value text-3xl! mt-4 mb-2 gp-accent-green"
style="text-shadow: 0 0 15px rgba(52,211,153,0.3);" style="text-shadow: 0 0 15px rgba(52,211,153,0.3);"
> >
+{formatNumber(gameStore.offlineReport.gains)} tetards +{formatNumber(refs.offlineReport.gains)} tetards
</p> </p>
</div> </div>
<p class="gp-label" in:fade={{ delay: 300, duration: 300 }}> <p class="gp-label" in:fade={{ delay: 300, duration: 300 }}>
Efficacite : {Math.round(gameStore.offlineReport.efficiency * 100)}% Efficacite : {Math.round(refs.offlineReport.efficiency * 100)}%
</p> </p>
<button <button
class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!" class="gp-btn gp-btn--buy mt-4 w-full py-2.5! text-[0.8rem]!"
onclick={() => gameStore.dismissOfflineReport()} onclick={() => dismissOfflineReport()}
in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }} in:fly={{ y: 15, delay: 400, duration: 300, easing: quintOut }}
> >
Continuer Continuer

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
// game.svelte.ts — Game store (Svelte 5 runes) // game.svelte.ts — Game store (Svelte 5 runes)
// Server = authority. localStorage = fallback guest only. // Server = authority. localStorage = fallback guest only.
// Architecture: $state for all reactive values, direct access (no getter indirection).
import { import {
type GameState, type GameState,
@@ -33,8 +34,6 @@ import {
const SAVE_KEY = 'clickerz_state'; const SAVE_KEY = 'clickerz_state';
const OFFLINE_THRESHOLD = 60_000; const OFFLINE_THRESHOLD = 60_000;
// --- Offline report ---
export interface OfflineReport { export interface OfflineReport {
wasOffline: boolean; wasOffline: boolean;
duration: number; duration: number;
@@ -42,31 +41,22 @@ export interface OfflineReport {
efficiency: number; efficiency: number;
} }
// --- Reactive state (Svelte 5 runes) --- // --- All reactive state ---
// $state.raw for GameState — replaced entirely on every mutation, never deep-patched. // Svelte 5 modules cannot export $state that is reassigned.
// This ensures Svelte detects every change by reference equality. // Use a reactive container object + property mutation instead.
let _stateVersion = $state(0); export const refs = $state({
let _state: GameState = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; ready: false,
let playSeconds = $state(0); playSeconds: 0,
let ready = $state(false); offlineReport: null as OfflineReport | null,
let offlineReport = $state<OfflineReport | null>(null); showPrestigeScreen: false,
let showPrestigeScreen = $state(false); lastClickGain: 0,
let lastClickGain = $state(0); lastClickDouble: false,
let lastClickDouble = $state(false); lastClickCrit: false,
let lastClickCrit = $state(false); });
// Bump version to trigger reactivity on state reads // Main game state: exported as $state object, mutated via Object.assign (never reassigned).
function setState(newState: GameState) { export const state = $state<GameState>({ ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() });
_state = newState;
_stateVersion++;
}
function getState(): GameState {
// Read _stateVersion to create a reactive dependency
void _stateVersion;
return _state;
}
// --- Local storage --- // --- Local storage ---
@@ -99,29 +89,25 @@ function hydrateWithOffline(saved: GameState, now: number): { state: GameState;
const fullGains = pps * (elapsed / 1000); const fullGains = pps * (elapsed / 1000);
const avgEfficiency = fullGains > 0 ? gains / fullGains : 0; const avgEfficiency = fullGains > 0 ? gains / fullGains : 0;
const hydrated: GameState = { return {
state: {
...saved, ...saved,
resources: saved.resources + gains, resources: saved.resources + gains,
lifetimeTadpoles: saved.lifetimeTadpoles + gains, lifetimeTadpoles: saved.lifetimeTadpoles + gains,
lastTick: now, lastTick: now,
lastOnline: now, lastOnline: now,
}; },
return {
state: hydrated,
report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency }, report: { wasOffline: true, duration: elapsed, gains, efficiency: avgEfficiency },
}; };
} }
// --- Actions --- // --- Actions (exported directly, no wrapper object) ---
function tick() { export function tick() {
if (!ready) return; if (!refs.ready) return;
const now = Date.now(); const now = Date.now();
const updated = applyIdleGains(_state, now); const updated = { ...applyIdleGains(state, now), lastOnline: now };
updated.lastOnline = now;
// Auto-click from evolution tree
const autoClicks = getAutoClicksPerSecond(updated.evolutionTree); const autoClicks = getAutoClicksPerSecond(updated.evolutionTree);
if (autoClicks > 0) { if (autoClicks > 0) {
const autoGain = getClickGain(updated) * autoClicks; const autoGain = getClickGain(updated) * autoClicks;
@@ -129,8 +115,7 @@ function tick() {
updated.lifetimeTadpoles += autoGain; updated.lifetimeTadpoles += autoGain;
} }
// Check cosmetic unlocks every 5s if (refs.playSeconds % 5 === 0) {
if (playSeconds % 5 === 0) {
const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped }; const cosState = { inventory: updated.cosmeticInventory, equipped: updated.cosmeticEquipped };
const newUnlocks = computeNewUnlocks(updated, cosState); const newUnlocks = computeNewUnlocks(updated, cosState);
if (newUnlocks.length > 0) { if (newUnlocks.length > 0) {
@@ -141,167 +126,144 @@ function tick() {
} }
saveLocal(updated); saveLocal(updated);
setState(updated); Object.assign(state, updated);
playSeconds += 1; refs.playSeconds += 1;
} }
function click() { export function click() {
if (!ready) return; if (!refs.ready) return;
const result = applyClick(applyIdleGains(_state, Date.now())); const result = applyClick(applyIdleGains(state, Date.now()));
saveLocal(result.state); saveLocal(result.state);
setState(result.state); Object.assign(state, result.state);
lastClickGain = result.gain; refs.lastClickGain = result.gain;
lastClickDouble = result.isDouble; refs.lastClickDouble = result.isDouble;
lastClickCrit = result.isCrit; refs.lastClickCrit = result.isCrit;
} }
function buy(genId: string) { export function buy(genId: string) {
if (!ready) return; if (!refs.ready) return;
const withIdle = applyIdleGains(_state, Date.now()); const updated = buyGenerator(applyIdleGains(state, Date.now()), genId);
const updated = buyGenerator(withIdle, genId);
if (!updated) return; if (!updated) return;
saveLocal(updated); saveLocal(updated);
setState(updated); Object.assign(state, updated);
} }
function buyNode(nodeId: string) { export function buyNode(nodeId: string) {
if (!ready) return; if (!refs.ready) return;
const updated = buyEvolutionNode(_state, nodeId); const updated = buyEvolutionNode(state, nodeId);
if (!updated) return; if (!updated) return;
const node = updated.evolutionTree.find((n) => n.id === nodeId); const node = updated.evolutionTree.find((n) => n.id === nodeId);
saveLocal(updated); saveLocal(updated);
if (node?.capstone) { if (node?.capstone) toast(`Capstone debloque : ${node.name} !`, 'reward', 5000);
toast(`Capstone debloque : ${node.name} !`, 'reward', 5000); Object.assign(state, updated);
}
setState(updated);
} }
function prestige() { export function prestige() {
if (!ready) return; if (!refs.ready) return;
if (!canPrestigeCheck(_state)) return; if (!canPrestigeCheck(state)) return;
const updated = applyPrestige(_state); const updated = applyPrestige(state);
saveLocal(updated); saveLocal(updated);
toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000); toast(`Generation #${updated.prestigeCount} — Nouvelle vie !`, 'success', 4000);
setState(updated); Object.assign(state, updated);
showPrestigeScreen = false; refs.showPrestigeScreen = false;
} }
function equipCosmetic(cosmeticId: string) { export function equipCosmetic(cosmeticId: string) {
if (!ready) return; if (!refs.ready) return;
const cosState = { inventory: _state.cosmeticInventory, equipped: _state.cosmeticEquipped }; const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = equipCosmeticFn(cosState, cosmeticId); const updated = equipCosmeticFn(cosState, cosmeticId);
const newState = { ..._state, cosmeticEquipped: updated.equipped }; state.cosmeticEquipped = updated.equipped;
saveLocal(newState); saveLocal(state);
setState(newState);
} }
function unequipCosmetic(slot: CosmeticSlot) { export function unequipCosmetic(slot: CosmeticSlot) {
if (!ready) return; if (!refs.ready) return;
const cosState = { inventory: _state.cosmeticInventory, equipped: _state.cosmeticEquipped }; const cosState = { inventory: state.cosmeticInventory, equipped: state.cosmeticEquipped };
const updated = unequipSlotFn(cosState, slot); const updated = unequipSlotFn(cosState, slot);
const newState = { ..._state, cosmeticEquipped: updated.equipped }; state.cosmeticEquipped = updated.equipped;
saveLocal(newState); saveLocal(state);
setState(newState);
} }
function doResetTree() { export function doResetTree() {
if (!ready) return; if (!refs.ready) return;
if (!canResetTree(_state)) return; if (!canResetTree(state)) return;
const updated = resetEvolutionTree(_state); const updated = resetEvolutionTree(state);
saveLocal(updated); saveLocal(updated);
setState(updated); Object.assign(state, updated);
} }
function doUpgradeConvergence() { export function doUpgradeConvergence() {
if (!ready) return; if (!refs.ready) return;
const updated = upgradeConvergence(_state); const updated = upgradeConvergence(state);
if (!updated) return; if (!updated) return;
saveLocal(updated); saveLocal(updated);
setState(updated); Object.assign(state, updated);
} }
function doClaimMilestone(milestoneId: string) { export function doClaimMilestone(milestoneId: string) {
if (!ready) return; if (!refs.ready) return;
const updated = claimMilestoneFn(_state, milestoneId); const updated = claimMilestoneFn(state, milestoneId);
if (!updated) return; if (!updated) return;
saveLocal(updated); saveLocal(updated);
toast('Milestone debloque !', 'reward', 4000); toast('Milestone debloque !', 'reward', 4000);
setState(updated); Object.assign(state, updated);
} }
function reset() { export function reset() {
const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() }; const fresh = { ...DEFAULT_STATE, lastTick: Date.now(), lastOnline: Date.now() };
saveLocal(fresh); saveLocal(fresh);
setState(fresh); Object.assign(state, fresh);
playSeconds = 0; refs.playSeconds = 0;
ready = true; refs.ready = true;
offlineReport = null; refs.offlineReport = null;
} }
function loadFromServer(serverState: GameState) { export function loadFromServer(serverState: GameState) {
const migrated = migrateSave(serverState as unknown as Record<string, unknown>); const migrated = migrateSave(serverState as unknown as Record<string, unknown>);
const result = hydrateWithOffline(migrated, Date.now()); const result = hydrateWithOffline(migrated, Date.now());
saveLocal(result.state); saveLocal(result.state);
setState(result.state); Object.assign(state, result.state);
ready = true; refs.ready = true;
offlineReport = result.report; refs.offlineReport = result.report;
} }
function initGuest() { export function initGuest() {
const local = loadLocalState(); const local = loadLocalState();
const result = hydrateWithOffline(local, Date.now()); const result = hydrateWithOffline(local, Date.now());
saveLocal(result.state); saveLocal(result.state);
setState(result.state); Object.assign(state, result.state);
ready = true; refs.ready = true;
offlineReport = result.report; refs.offlineReport = result.report;
} }
function dismissOfflineReport() { export function dismissOfflineReport() {
offlineReport = null; refs.offlineReport = null;
} }
function openPrestige() { export function openPrestige() {
showPrestigeScreen = true; refs.showPrestigeScreen = true;
} }
function closePrestige() { export function closePrestige() {
showPrestigeScreen = false; refs.showPrestigeScreen = false;
} }
// --- Public API --- // --- Computed helpers (call from templates, they read `state` reactively) ---
// All state reads go through getState() which depends on _stateVersion.
// This guarantees that any component reading gameStore.state re-renders
// whenever setState() is called — even for deep properties like evolutionTree[i].branch.
export const gameStore = { export function getProductionPerSecond() {
get state() { return getState(); }, return totalProductionPerSecond(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 canPrestigeCheck(getState()); },
get productionPerSecond() { return totalProductionPerSecond(getState()); },
tick, export function getCanPrestige() {
click, return canPrestigeCheck(state);
buy, }
buyNode,
prestige, export function getCurrentClickGain() {
equipCosmetic, return getClickGain(state);
unequipCosmetic, }
resetTree: doResetTree,
upgradeConvergence: doUpgradeConvergence, export function getGeneratorCostWithTree(gen: Parameters<typeof genCost>[0]) {
claimMilestone: doClaimMilestone, return genCost(gen, state.evolutionTree);
reset, }
loadFromServer,
initGuest, export { genCost as generatorCost };
dismissOfflineReport,
openPrestige,
closePrestige,
generatorCost: genCost,
generatorCostWithTree: (gen: Parameters<typeof genCost>[0]) => genCost(gen, getState().evolutionTree),
getClickGain: () => getClickGain(getState()),
};

View File

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

View File

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