feat: click upgrades — buy click power with tadpoles, tied to generators
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 21s

5 click upgrades, each linked to a generator type:
- Nid Douillet (+1/clic, 50 base) — requires owning a Nid
- Eau Fertile (+3/clic, 500 base) — requires a Mare
- Spores Actives (+8/clic, 5k base) — requires a Marecage
- Courant Vital (+20/clic, 50k base) — requires an Etang
- Source Ancestrale (+50/clic, 500k base) — requires a Lac

Cost scales x1.2 per level. Reset at prestige (like generators).
Click gain = (base + upgradePower) × prestige × tree × infraBonus.
ClickPanel shows upgrade shop with level badges and gen requirements.
Adds tadpole sink for active play — strategic choice vs buying generators.
This commit is contained in:
2026-03-28 21:19:01 +01:00
parent f9dd4c3ca4
commit 7a8f4f325c
4 changed files with 155 additions and 53 deletions

View File

@@ -1,34 +1,36 @@
<script lang="ts">
import { game } from '$lib/stores/game.svelte';
import { getClickBreakdown } from '$lib/core/economy';
import { getClickBreakdown, clickUpgradeCost } from '$lib/core/economy';
import { formatNumber } from '$lib/utils/formatNumber';
import CollapsiblePanel from './CollapsiblePanel.svelte';
let b = $derived(getClickBreakdown(game.state));
let expected = $derived(b.total * (1 + b.doubleChance + b.critChance * 9));
// Estimate: 5 clicks/sec manual → effective click production
const CLICKS_PER_SEC = 5;
let manualProd = $derived(expected * CLICKS_PER_SEC);
let totalWithClicks = $derived(game.productionPerSecond + b.effectivePerSec + manualProd);
let clickShare = $derived(totalWithClicks > 0 ? ((b.effectivePerSec + manualProd) / totalWithClicks * 100) : 0);
// Generator names for display
const GEN_NAMES: Record<string, string> = {
nid: 'Nid', mare: 'Mare', marecage: 'Marecage', etang: 'Etang', lac: 'Lac',
};
</script>
<CollapsiblePanel title="Ponte (clic)" badge="{formatNumber(expected)}" accentClass="gp-accent-amber" defaultOpen={false}>
<CollapsiblePanel title="Ponte (clic)" badge="{formatNumber(b.total)}" accentClass="gp-accent-amber" defaultOpen={false}>
<!-- Gain par clic -->
<div class="gp-row gp-row--active">
<div class="flex flex-col flex-1">
<span class="gp-value">Gain par clic</span>
<span class="gp-label">
x{b.prestigeMult.toFixed(1)} prestige · x{b.treeMult.toFixed(0)} arbre · x{b.genBonus.toFixed(1)} infra
</span>
<span class="gp-label" style="opacity: 0.5;">
{b.genTypes} types (+{b.genTypes * 2}) · {b.genTotal} unites (+{(b.genTotal * 0.05).toFixed(1)})
base {b.base} × x{b.prestigeMult.toFixed(1)} × x{b.treeMult.toFixed(0)} × x{b.genBonus.toFixed(1)}
</span>
</div>
<span class="gp-value gp-accent-amber text-lg!">{formatNumber(b.total)}</span>
</div>
<!-- Click contribution estimate -->
<!-- Click contribution -->
<div class="gp-row" style="border-color: rgba(251,191,36,0.15); background: rgba(251,191,36,0.04);">
<div class="flex flex-col flex-1">
<span class="gp-value text-[0.7rem]!">Contribution clics</span>
@@ -40,67 +42,80 @@
<span class="gp-label gp-accent-amber">{clickShare.toFixed(0)}%</span>
</div>
<!-- Double ponte -->
<!-- Click upgrades shop -->
{#if (game.state.clickUpgrades ?? []).length > 0}
<span class="gp-zone-label mt-1">Ameliorations de ponte</span>
{#each game.state.clickUpgrades ?? [] as upgrade}
{@const gen = game.state.generators.find((g) => g.id === upgrade.generatorId)}
{@const hasGen = gen && gen.owned > 0}
{@const cost = clickUpgradeCost(upgrade)}
{@const canAfford = hasGen && game.state.resources >= cost}
<div class="gp-row {canAfford ? 'gp-row--active' : 'gp-row--locked'}">
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span class="gp-value text-[0.7rem]!">{upgrade.name}</span>
{#if upgrade.level > 0}
<span
class="gp-label px-1.5 py-0 rounded-full text-[0.6rem]!"
style="background: rgba(251,191,36,0.15); color: var(--color-gp-accent-amber);"
>
nv.{upgrade.level}
</span>
{/if}
</div>
<span class="gp-label">
{#if hasGen}
+{upgrade.baseClickPower}/clic par niveau ({GEN_NAMES[upgrade.generatorId]})
{:else}
Necessite un {GEN_NAMES[upgrade.generatorId]}
{/if}
</span>
</div>
{#if hasGen}
<button
onclick={() => game.buyClickUpgrade(upgrade.id)}
disabled={!canAfford}
class="gp-btn {canAfford ? 'gp-btn--buy' : 'gp-btn--disabled'}"
>
{formatNumber(cost)}
</button>
{:else}
<span class="gp-label"></span>
{/if}
</div>
{/each}
{/if}
<!-- Double/Crit/Auto -->
<span class="gp-zone-label mt-1">Bonus (arbre)</span>
<div class="gp-row {b.doubleChance > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
<div class="flex flex-col">
<span class="gp-value text-[0.7rem]!">Double Ponte</span>
<span class="gp-label">
{#if b.doubleChance > 0}
{(b.doubleChance * 100).toFixed(0)}% chance de doubler
{:else}
Branche Ponte — "Double Ponte" (5 ADN)
{/if}
{b.doubleChance > 0 ? `${(b.doubleChance * 100).toFixed(0)}% chance de doubler` : 'Branche Ponte — 5 ADN'}
</span>
</div>
{#if b.doubleChance > 0}
<span class="gp-label gp-accent-purple">{(b.doubleChance * 100).toFixed(0)}%</span>
{:else}
<span class="gp-label"></span>
{/if}
<span class="gp-label {b.doubleChance > 0 ? 'gp-accent-purple' : ''}">{b.doubleChance > 0 ? `${(b.doubleChance * 100).toFixed(0)}%` : '—'}</span>
</div>
<!-- Crit -->
<div class="gp-row {b.critChance > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
<div class="flex flex-col">
<span class="gp-value text-[0.7rem]!">Ponte Critique</span>
<span class="gp-label">
{#if b.critChance > 0}
{(b.critChance * 100).toFixed(0)}% chance de x10
{:else}
Branche Ponte — "Ponte Critique" (20 ADN)
{/if}
{b.critChance > 0 ? `${(b.critChance * 100).toFixed(0)}% chance de x10` : 'Branche Ponte — 20 ADN'}
</span>
</div>
{#if b.critChance > 0}
<span class="gp-label gp-accent-amber">{(b.critChance * 100).toFixed(0)}%</span>
{:else}
<span class="gp-label"></span>
{/if}
<span class="gp-label {b.critChance > 0 ? 'gp-accent-amber' : ''}">{b.critChance > 0 ? `${(b.critChance * 100).toFixed(0)}%` : '—'}</span>
</div>
<!-- Auto-click -->
<div class="gp-row {b.autoClicksPerSec > 0 ? 'gp-row--unlocked' : 'gp-row--locked'}">
<div class="flex flex-col">
<span class="gp-value text-[0.7rem]!">Auto-Ponte</span>
<span class="gp-label">
{#if b.autoClicksPerSec > 0}
{b.autoClicksPerSec.toFixed(1)} clics/s auto → {formatNumber(b.effectivePerSec)}/s
{:else}
Capstone Ponte — "Ponte Automatique" (200 ADN)
{/if}
{b.autoClicksPerSec > 0 ? `${b.autoClicksPerSec.toFixed(1)} clics/s ${formatNumber(b.effectivePerSec)}/s` : 'Capstone Ponte — 200 ADN'}
</span>
</div>
{#if b.autoClicksPerSec > 0}
<span class="gp-label gp-accent-green">{formatNumber(b.effectivePerSec)}/s</span>
{:else}
<span class="gp-label"></span>
{/if}
<span class="gp-label {b.autoClicksPerSec > 0 ? 'gp-accent-green' : ''}">{b.autoClicksPerSec > 0 ? `${formatNumber(b.effectivePerSec)}/s` : '—'}</span>
</div>
<!-- Hint -->
{#if b.treeMult <= 1}
<div class="text-center py-1">
<span class="gp-label gp-accent-amber">Depense ton ADN dans la branche Ponte pour booster tes clics</span>
</div>
{/if}
</CollapsiblePanel>

View File

@@ -86,11 +86,39 @@ export interface RunStats {
} | null;
}
// --- Click Upgrades (achetables en têtards, liés aux générateurs) ---
export interface ClickUpgrade {
id: string;
name: string;
generatorId: string; // lié à quel générateur
baseClickPower: number; // bonus clic par niveau
baseCost: number; // coût de base
level: number; // niveaux achetés
}
export const DEFAULT_CLICK_UPGRADES: ClickUpgrade[] = [
{ id: "nid_douillet", name: "Nid Douillet", generatorId: "nid", baseClickPower: 1, baseCost: 50, level: 0 },
{ id: "eau_fertile", name: "Eau Fertile", generatorId: "mare", baseClickPower: 3, baseCost: 500, level: 0 },
{ id: "spores_actives", name: "Spores Actives", generatorId: "marecage", baseClickPower: 8, baseCost: 5_000, level: 0 },
{ id: "courant_vital", name: "Courant Vital", generatorId: "etang", baseClickPower: 20, baseCost: 50_000, level: 0 },
{ id: "source_ancestrale", name: "Source Ancestrale", generatorId: "lac", baseClickPower: 50, baseCost: 500_000, level: 0 },
];
export function clickUpgradeCost(upgrade: ClickUpgrade): number {
return Math.floor(upgrade.baseCost * Math.pow(1.2, upgrade.level));
}
export function totalClickUpgradePower(clickUpgrades: ClickUpgrade[]): number {
return clickUpgrades.reduce((sum, u) => sum + u.baseClickPower * u.level, 0);
}
export interface GameState {
saveVersion: number;
resources: number;
clickMultiplier: number;
generators: Generator[];
clickUpgrades: ClickUpgrade[];
lastTick: number; // timestamp ms — lazy calc reference
lastOnline: number; // timestamp ms — dernière activité réelle (tick actif)
prestigeCount: number;
@@ -618,11 +646,32 @@ export function getGeneratorClickBonus(generators: Generator[]): number {
return 1 + typesOwned * 2 + totalOwned * 0.05;
}
// Gain par clic — scaling propre : base × prestige × arbre × generateurs
// Gain par clic — base + upgrades, le tout multiplié par prestige × arbre × infra
export function getClickGain(state: GameState): number {
const treeClickMult = getClickMultiplierFromTree(state.evolutionTree);
const genBonus = getGeneratorClickBonus(state.generators);
return Math.floor(state.clickMultiplier * state.prestigeMultiplier * treeClickMult * genBonus);
const upgradePower = totalClickUpgradePower(state.clickUpgrades ?? []);
const base = state.clickMultiplier + upgradePower;
return Math.floor(base * state.prestigeMultiplier * treeClickMult * genBonus);
}
// Achat d'un click upgrade (coûte des têtards)
export function buyClickUpgrade(state: GameState, upgradeId: string): GameState | null {
const idx = (state.clickUpgrades ?? []).findIndex((u) => u.id === upgradeId);
if (idx === -1) return null;
const upgrade = state.clickUpgrades[idx];
// Requires owning the linked generator
const gen = state.generators.find((g) => g.id === upgrade.generatorId);
if (!gen || gen.owned === 0) return null;
const cost = clickUpgradeCost(upgrade);
if (state.resources < cost) return null;
const updatedUpgrades = [...state.clickUpgrades];
updatedUpgrades[idx] = { ...upgrade, level: upgrade.level + 1 };
return { ...state, resources: state.resources - cost, clickUpgrades: updatedUpgrades };
}
// Breakdown complet du clic (pour affichage cockpit)
@@ -641,7 +690,8 @@ export interface ClickBreakdown {
}
export function getClickBreakdown(state: GameState): ClickBreakdown {
const base = state.clickMultiplier;
const upgradePower = totalClickUpgradePower(state.clickUpgrades ?? []);
const base = state.clickMultiplier + upgradePower;
const prestigeMult = state.prestigeMultiplier;
const treeMult = getClickMultiplierFromTree(state.evolutionTree);
const genBonus = getGeneratorClickBonus(state.generators);
@@ -817,6 +867,8 @@ export function applyPrestige(state: GameState): GameState {
},
freeResetAvailable: true, // 1 reset gratuit offert par prestige
extraResetsUsed: 0,
// Click upgrades reset au prestige (comme les générateurs)
clickUpgrades: (state.clickUpgrades ?? DEFAULT_CLICK_UPGRADES).map((u) => ({ ...u, level: 0 })),
// evolutionTree persiste — jamais reset
};
}
@@ -835,6 +887,7 @@ export const DEFAULT_STATE: GameState = {
resources: 0,
clickMultiplier: 1,
generators: DEFAULT_GENERATORS,
clickUpgrades: DEFAULT_CLICK_UPGRADES,
lastTick: Date.now(),
lastOnline: Date.now(),
prestigeCount: 0,

View File

@@ -3,8 +3,8 @@
// Chaque sprint ajoute un step (v2→v3, etc.)
import { CURRENT_SAVE_VERSION } from "./balance";
import type { GameState } from "./economy";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS } from "./economy";
import type { GameState, ClickUpgrade } from "./economy";
import { DEFAULT_EVOLUTION_TREE, DEFAULT_GENERATORS, DEFAULT_CLICK_UPGRADES } from "./economy";
/**
* Détecte la version d'une save et applique les migrations nécessaires.
@@ -32,6 +32,11 @@ export function migrateSave(raw: Record<string, unknown>): GameState {
state.generators as Array<Record<string, unknown>> | undefined
);
// Click upgrades — merge with defaults (preserves levels, adds new upgrades)
state.clickUpgrades = mergeClickUpgrades(
state.clickUpgrades as Array<Record<string, unknown>> | undefined
);
return state as unknown as GameState;
}
@@ -149,3 +154,25 @@ function mergeGenerators(
return { ...defaultGen };
});
}
/**
* Merge les click upgrades sauvegardés avec DEFAULT_CLICK_UPGRADES.
* Conserve le level, met à jour les stats de base.
*/
function mergeClickUpgrades(
saved: Array<Record<string, unknown>> | undefined
): ClickUpgrade[] {
if (!saved || !Array.isArray(saved)) {
return DEFAULT_CLICK_UPGRADES.map((u) => ({ ...u }));
}
const savedById = new Map(saved.map((u) => [u.id as string, u]));
return DEFAULT_CLICK_UPGRADES.map((def) => {
const s = savedById.get(def.id);
if (s) {
return { ...def, level: typeof s.level === "number" ? s.level : 0 };
}
return { ...def };
});
}

View File

@@ -21,6 +21,7 @@ import {
totalProductionPerSecond,
generatorCost as genCost,
computeOfflineGains,
buyClickUpgrade as buyClickUpgradeFn,
} from '$lib/core/economy';
import { migrateSave } from '$lib/core/migrateSave';
import { toast } from './toast.svelte';
@@ -133,6 +134,12 @@ class Game {
if (updated) this.applyState(updated);
}
buyClickUpgrade(upgradeId: string) {
if (!this.ready) return;
const updated = buyClickUpgradeFn(this.state, upgradeId);
if (updated) this.applyState(updated);
}
buyNode(nodeId: string) {
if (!this.ready) return;
const updated = buyEvolutionNode(this.state, nodeId);