feat: migrate frontend React 18 → Svelte 5 + SvelteKit
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:
2026-03-28 20:03:21 +01:00
parent 3de0492631
commit f6bff6e389
125 changed files with 5323 additions and 10373 deletions

View 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}