feat: First iteration, multi themes, I18N

This commit is contained in:
gauvainboiche
2026-10-08 11:58:18 +02:00
parent 9240fa3c3c
commit aa9721ac2a
53 changed files with 3363 additions and 1 deletions
+89
View File
@@ -0,0 +1,89 @@
/*
* Base: reset + the design tokens every theme can override.
* A theme (themes/<id>/theme.css) only redefines these custom properties.
*/
:root {
color-scheme: light;
--bg: #f3f4f6;
--surface: #ffffff;
--surface-2: #eceef2;
--text: #1d2330;
--text-muted: #5b6475;
--border: #d5d9e0;
--accent: #2563eb;
--accent-contrast: #ffffff;
--danger: #c0392b;
--radius: 10px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--gap: 0.75rem;
--icon-size: 3rem;
--placeholder: #9aa0a6; /* the grey square used until real images exist */
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 1rem/1.45 var(--font);
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom)
env(safe-area-inset-left);
}
h1,
h2,
h3,
p,
dl,
dd,
ul,
fieldset {
margin: 0;
padding: 0;
}
ul {
list-style: none;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
[hidden] {
display: none !important;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
+425
View File
@@ -0,0 +1,425 @@
/* Reusable UI pieces. Colours come from the tokens in base.css / the theme. */
/* ---------------------------------------------------------------- panels */
.panel {
display: flex;
flex-direction: column;
gap: var(--gap);
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
flex-wrap: wrap;
}
.panel__title {
font-size: 1.05rem;
}
.hint {
color: var(--text-muted);
font-size: 0.875rem;
}
.banner {
margin: 0;
padding: 0.6rem 1rem;
background: var(--danger);
color: #fff;
text-align: center;
}
/* ----------------------------------------------------------------- icons */
.icon {
flex: none;
width: var(--icon-size);
height: var(--icon-size);
object-fit: contain;
border-radius: 6px;
}
.icon--placeholder {
display: block;
background: var(--placeholder);
}
/* --------------------------------------------------------------- buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
column-gap: 0.5rem;
min-height: 44px;
padding: 0.5rem 1rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
text-align: center;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.btn small {
color: var(--text-muted);
font-size: 0.8rem;
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
font-weight: 600;
}
.btn--primary small {
color: inherit;
opacity: 0.85;
}
.btn--big {
min-height: 84px;
flex-direction: column;
font-size: 1.15rem;
}
.btn--wide {
width: 100%;
justify-content: space-between;
}
.btn--ghost {
background: transparent;
}
.btn--danger {
border-color: var(--danger);
color: var(--danger);
}
.btn--square {
width: 44px;
padding: 0;
font-size: 1.25rem;
}
.btn--buy {
flex-direction: column;
min-width: 7.5rem;
align-self: center;
}
.btn__label {
font-weight: 600;
}
/* ----------------------------------------------------------- form fields */
.field {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.field__label {
color: var(--text-muted);
font-size: 0.75rem;
}
select,
.price__input,
.textarea {
min-height: 44px;
padding: 0.4rem 0.6rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.textarea {
width: 100%;
min-height: 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 0.8rem;
resize: vertical;
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.badge {
display: inline-block;
padding: 0.05rem 0.5rem;
background: var(--surface-2);
border-radius: 999px;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 500;
}
/* -------------------------------------------------------------- overview */
.tiles {
display: grid;
gap: var(--gap);
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
}
.tile {
display: flex;
align-items: center;
gap: var(--gap);
padding: 0.75rem;
background: var(--surface-2);
border-radius: var(--radius);
}
.tile--primary {
outline: 2px solid var(--accent);
}
.tile__label {
display: block;
color: var(--text-muted);
font-size: 0.8rem;
}
.tile__value {
display: block;
font-size: 1.5rem;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.stats {
display: grid;
gap: 0.25rem var(--gap);
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
}
.stats > div {
display: flex;
flex-direction: column;
}
.stats dt {
color: var(--text-muted);
font-size: 0.8rem;
}
.stats dd {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
/* ----------------------------------------------------------------- price */
.price {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.price__label {
color: var(--text-muted);
font-size: 0.8rem;
}
.price__row {
display: flex;
gap: 0.5rem;
}
.price__input {
flex: 1;
min-width: 0;
text-align: center;
font-size: 1.25rem;
font-variant-numeric: tabular-nums;
}
.price__slider {
width: 100%;
height: 32px; /* easy to grab on touch screens */
accent-color: var(--accent);
}
/* ------------------------------------------------- generators & upgrades */
.cards {
display: flex;
flex-direction: column;
gap: var(--gap);
}
.card {
display: flex;
align-items: flex-start;
gap: var(--gap);
padding: 0.75rem;
background: var(--surface-2);
border-radius: var(--radius);
}
.card__body {
flex: 1;
min-width: 0;
}
.card__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
}
.card__desc,
.card__meta {
color: var(--text-muted);
font-size: 0.85rem;
}
.card__meta {
font-variant-numeric: tabular-nums;
}
/* Narrow phones: the buy button drops under the text. */
@media (max-width: 479px) {
.card {
flex-wrap: wrap;
}
.card__body {
flex-basis: calc(100% - var(--icon-size) - var(--gap));
}
.btn--buy {
flex: 1 0 100%;
flex-direction: row;
justify-content: space-between;
}
}
.segmented {
display: inline-flex;
border: 0;
gap: 0.25rem;
}
.segmented label {
cursor: pointer;
}
.segmented input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.segmented span {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 0.7rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.85rem;
}
.segmented input:checked + span {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.segmented input:focus-visible + span {
outline: 3px solid var(--accent);
outline-offset: 2px;
}
/* ---------------------------------------------------------------- dialog */
.dialog {
width: min(40rem, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
padding: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.dialog::backdrop {
background: rgb(0 0 0 / 0.55);
}
.dialog__body {
display: flex;
flex-direction: column;
gap: var(--gap);
padding: 1rem;
}
.dialog__subtitle {
margin-top: 0.5rem;
font-size: 0.95rem;
}
/* ---------------------------------------------------------------- toasts */
.toasts {
position: fixed;
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
left: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
pointer-events: none;
z-index: 10;
}
.toast {
max-width: 32rem;
padding: 0.7rem 1rem;
background: var(--text);
color: var(--bg);
border-radius: var(--radius);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.3);
}
.toast--error {
background: var(--danger);
color: #fff;
}
+67
View File
@@ -0,0 +1,67 @@
/* Page layout. Mobile first: one column, then 2 and 3 columns as space allows. */
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--gap);
padding: var(--gap) 1rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar__title {
font-size: 1.25rem;
}
.topbar__controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--gap);
}
.game {
display: grid;
gap: 1rem;
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
'stats'
'production'
'market'
'generators'
'upgrades';
}
.panel--stats { grid-area: stats; }
.panel--production { grid-area: production; }
.panel--market { grid-area: market; }
.panel--generators { grid-area: generators; }
.panel--upgrades { grid-area: upgrades; }
/* Tablet */
@media (min-width: 760px) {
.game {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
'stats stats'
'production market'
'generators upgrades';
align-items: start;
}
}
/* Desktop */
@media (min-width: 1200px) {
.game {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1.2fr);
grid-template-areas:
'stats stats stats'
'production generators upgrades'
'market generators upgrades';
}
}
+8
View File
@@ -0,0 +1,8 @@
[
{ "id": "gen1", "baseCost": 5, "costGrowth": 1.15, "baseRate": 1, "unlock": { "totalProduced": 10 } },
{ "id": "gen2", "baseCost": 75, "costGrowth": 1.15, "baseRate": 8, "unlock": { "totalProduced": 100 } },
{ "id": "gen3", "baseCost": 1000, "costGrowth": 1.15, "baseRate": 60, "unlock": { "totalProduced": 1000 } },
{ "id": "gen4", "baseCost": 12500, "costGrowth": 1.15, "baseRate": 440, "unlock": { "totalProduced": 10000 } },
{ "id": "gen5", "baseCost": 150000, "costGrowth": 1.15, "baseRate": 3200, "unlock": { "totalProduced": 100000 } },
{ "id": "gen6", "baseCost": 2000000, "costGrowth": 1.15, "baseRate": 24000, "unlock": { "totalProduced": 1000000 } }
]
+25
View File
@@ -0,0 +1,25 @@
{
"start": { "units": 0, "funds": 0, "material": 100, "price": 0.25 },
"click": { "yield": 1 },
"unit": { "materialPerUnit": 1 },
"market": {
"fairPrice": 0.25,
"baseDemand": 2.5,
"elasticity": 2,
"minPrice": 0.01,
"maxPrice": 50,
"priceStep": 0.01
},
"materialMarket": {
"batchSize": 100,
"basePrice": 2,
"minFactor": 0.5,
"maxFactor": 2,
"volatility": 0.12,
"reversion": 0.05,
"updateEverySeconds": 2
},
"marketing": { "baseCost": 40, "costGrowth": 1.45, "demandPerLevel": 1.4 },
"offline": { "maxSeconds": 28800, "efficiency": 0.5, "minSeconds": 10 },
"autosaveSeconds": 10
}
+38
View File
@@ -0,0 +1,38 @@
[
{ "id": "click_1", "cost": 25, "unlock": { "totalProduced": 20 }, "effect": { "type": "click_mult", "value": 2 } },
{ "id": "click_2", "cost": 400, "unlock": { "totalProduced": 300 }, "effect": { "type": "click_mult", "value": 3 } },
{ "id": "click_3", "cost": 15000, "unlock": { "totalProduced": 10000 }, "effect": { "type": "click_mult", "value": 5 } },
{ "id": "g1_a", "cost": 100, "unlock": { "generator": "gen1", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen1", "value": 2 } },
{ "id": "g1_b", "cost": 1500, "unlock": { "generator": "gen1", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen1", "value": 2 } },
{ "id": "g2_a", "cost": 1500, "unlock": { "generator": "gen2", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen2", "value": 2 } },
{ "id": "g2_b", "cost": 20000, "unlock": { "generator": "gen2", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen2", "value": 2 } },
{ "id": "g3_a", "cost": 20000, "unlock": { "generator": "gen3", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen3", "value": 2 } },
{ "id": "g3_b", "cost": 250000, "unlock": { "generator": "gen3", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen3", "value": 2 } },
{ "id": "g4_a", "cost": 250000, "unlock": { "generator": "gen4", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen4", "value": 2 } },
{ "id": "g4_b", "cost": 3000000, "unlock": { "generator": "gen4", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen4", "value": 2 } },
{ "id": "g5_a", "cost": 3000000, "unlock": { "generator": "gen5", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen5", "value": 2 } },
{ "id": "g5_b", "cost": 40000000, "unlock": { "generator": "gen5", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen5", "value": 2 } },
{ "id": "g6_a", "cost": 40000000, "unlock": { "generator": "gen6", "count": 5 }, "effect": { "type": "generator_mult", "target": "gen6", "value": 2 } },
{ "id": "g6_b", "cost": 500000000, "unlock": { "generator": "gen6", "count": 25 }, "effect": { "type": "generator_mult", "target": "gen6", "value": 2 } },
{ "id": "global_1", "cost": 5000, "unlock": { "totalProduced": 5000 }, "effect": { "type": "global_mult", "value": 1.5 } },
{ "id": "global_2", "cost": 100000, "unlock": { "totalProduced": 50000 }, "effect": { "type": "global_mult", "value": 2 } },
{ "id": "global_3", "cost": 2000000, "unlock": { "totalProduced": 1000000 }, "effect": { "type": "global_mult", "value": 2 } },
{ "id": "global_4", "cost": 50000000, "unlock": { "totalProduced": 10000000 }, "effect": { "type": "global_mult", "value": 3 } },
{ "id": "demand_1", "cost": 300, "unlock": { "totalProduced": 200 }, "effect": { "type": "demand_mult", "value": 1.5 } },
{ "id": "demand_2", "cost": 8000, "unlock": { "totalProduced": 3000 }, "effect": { "type": "demand_mult", "value": 2 } },
{ "id": "demand_3", "cost": 200000, "unlock": { "totalProduced": 50000 }, "effect": { "type": "demand_mult", "value": 2 } },
{ "id": "demand_4", "cost": 5000000, "unlock": { "totalProduced": 500000 }, "effect": { "type": "demand_mult", "value": 3 } },
{ "id": "value_1", "cost": 1000, "unlock": { "totalProduced": 1000 }, "effect": { "type": "value_mult", "value": 1.25 } },
{ "id": "value_2", "cost": 50000, "unlock": { "totalProduced": 20000 }, "effect": { "type": "value_mult", "value": 1.5 } },
{ "id": "value_3", "cost": 3000000, "unlock": { "totalProduced": 500000 }, "effect": { "type": "value_mult", "value": 2 } },
{ "id": "mat_eff_1", "cost": 200, "unlock": { "totalProduced": 300 }, "effect": { "type": "material_efficiency", "value": 1.25 } },
{ "id": "mat_eff_2", "cost": 10000, "unlock": { "totalProduced": 5000 }, "requires": "mat_eff_1", "effect": { "type": "material_efficiency", "value": 1.5 } },
{ "id": "mat_disc_1", "cost": 500, "unlock": { "totalProduced": 500 }, "effect": { "type": "batch_discount", "value": 0.8 } },
{ "id": "mat_disc_2", "cost": 20000, "unlock": { "totalProduced": 8000 }, "requires": "mat_disc_1", "effect": { "type": "batch_discount", "value": 0.7 } },
{ "id": "auto_buy", "cost": 800, "unlock": { "totalProduced": 800 }, "effect": { "type": "auto_buy_material" } }
]
+162
View File
@@ -0,0 +1,162 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Idle Clicker</title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css">
<!-- The theme stylesheet (themes/<id>/theme.css) is injected by js/services/themes.js -->
<script type="module" src="js/main.js"></script>
</head>
<body>
<header class="topbar">
<h1 class="topbar__title" id="game-title" data-i18n="theme.title">Idle Clicker</h1>
<div class="topbar__controls">
<label class="field">
<span class="field__label" data-i18n="ui.theme">Theme</span>
<select id="theme-select"></select>
</label>
<label class="field">
<span class="field__label" data-i18n="ui.language">Language</span>
<select id="locale-select"></select>
</label>
<button class="btn btn--ghost" id="open-settings" type="button" data-i18n="ui.settings">Save &amp; settings</button>
</div>
</header>
<p class="banner" id="cookie-warning" hidden data-i18n="toast.cookiesBlocked"></p>
<main class="game" id="game">
<!-- Overview -->
<section class="panel panel--stats" aria-labelledby="h-stats">
<h2 class="panel__title" id="h-stats" data-i18n="ui.overview">Overview</h2>
<div class="tiles">
<div class="tile tile--primary">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.units"></span>
<div>
<span class="tile__label" data-i18n="theme.res.units"></span>
<strong class="tile__value" id="v-units">0</strong>
</div>
</div>
<div class="tile">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.funds"></span>
<div>
<span class="tile__label" data-i18n="theme.res.funds"></span>
<strong class="tile__value" id="v-funds">0</strong>
</div>
</div>
<div class="tile">
<span class="icon icon--placeholder" aria-hidden="true" data-icon="res.material"></span>
<div>
<span class="tile__label" data-i18n="theme.res.material"></span>
<strong class="tile__value" id="v-material">0</strong>
</div>
</div>
</div>
<dl class="stats">
<div><dt data-i18n="ui.totalProduced"></dt><dd id="v-total">0</dd></div>
<div><dt data-i18n="ui.production"></dt><dd id="v-rate">0</dd></div>
<div><dt data-i18n="ui.demand"></dt><dd id="v-demand">0</dd></div>
<div><dt data-i18n="ui.income"></dt><dd id="v-income">0</dd></div>
</dl>
</section>
<!-- Manual production + raw material -->
<section class="panel panel--production" aria-labelledby="h-production">
<h2 class="panel__title" id="h-production" data-i18n="ui.manual">Manual work</h2>
<button class="btn btn--primary btn--big" id="make" type="button">
<span data-i18n="theme.action.make"></span>
<small id="make-yield"></small>
</button>
<button class="btn btn--wide" id="buy-material" type="button">
<span data-i18n="theme.action.buyMaterial"></span>
<small id="buy-material-info"></small>
</button>
</section>
<!-- Selling -->
<section class="panel panel--market" aria-labelledby="h-market">
<h2 class="panel__title" id="h-market" data-i18n="ui.market">Market</h2>
<div class="price">
<span class="price__label" data-i18n="ui.price"></span>
<div class="price__row">
<button class="btn btn--square" id="price-down" type="button" data-i18n-attr="aria-label:ui.priceDown">−</button>
<input class="price__input" id="price-input" type="number" inputmode="decimal" min="0.01" step="0.01">
<button class="btn btn--square" id="price-up" type="button" data-i18n-attr="aria-label:ui.priceUp">+</button>
</div>
<input class="price__slider" id="price-slider" type="range" min="0" max="1000" step="1" data-i18n-attr="aria-label:ui.price">
<p class="hint" id="price-hint"></p>
</div>
<button class="btn btn--wide" id="buy-marketing" type="button">
<span data-i18n="ui.marketing"></span>
<small id="buy-marketing-info"></small>
</button>
</section>
<!-- Automation -->
<section class="panel panel--generators" aria-labelledby="h-generators">
<div class="panel__head">
<h2 class="panel__title" id="h-generators" data-i18n="ui.automation">Automation</h2>
<fieldset class="segmented" id="buy-amount">
<legend class="sr-only" data-i18n="ui.buyAmount"></legend>
<label><input type="radio" name="buy-amount" value="1" checked><span>×1</span></label>
<label><input type="radio" name="buy-amount" value="10"><span>×10</span></label>
<label><input type="radio" name="buy-amount" value="100"><span>×100</span></label>
<label><input type="radio" name="buy-amount" value="max"><span data-i18n="ui.max"></span></label>
</fieldset>
</div>
<ul class="cards" id="generators"></ul>
</section>
<!-- Upgrades -->
<section class="panel panel--upgrades" aria-labelledby="h-upgrades">
<div class="panel__head">
<h2 class="panel__title" id="h-upgrades" data-i18n="ui.upgrades">Upgrades</h2>
<span class="badge" id="upgrade-count"></span>
</div>
<p class="hint" id="upgrades-empty" data-i18n="ui.noUpgrades"></p>
<ul class="cards" id="upgrades"></ul>
</section>
</main>
<!-- Save & settings -->
<dialog class="dialog" id="settings" aria-labelledby="h-settings">
<form method="dialog" class="dialog__body">
<div class="panel__head">
<h2 class="panel__title" id="h-settings" data-i18n="settings.title"></h2>
<button class="btn btn--ghost" value="close" data-i18n="ui.close"></button>
</div>
<h3 class="dialog__subtitle" data-i18n="settings.export"></h3>
<p class="hint" data-i18n="settings.exportHelp"></p>
<textarea class="textarea" id="export-text" rows="5" readonly spellcheck="false" data-i18n-attr="aria-label:settings.export"></textarea>
<div class="row">
<button class="btn" type="button" id="export-json" data-i18n="settings.downloadJson"></button>
<button class="btn" type="button" id="export-txt" data-i18n="settings.downloadTxt"></button>
<button class="btn" type="button" id="export-copy" data-i18n="settings.copy"></button>
</div>
<h3 class="dialog__subtitle" data-i18n="settings.import"></h3>
<p class="hint" data-i18n="settings.importHelp"></p>
<div class="row">
<label class="btn" for="import-file" data-i18n="settings.importFile"></label>
<input class="sr-only" id="import-file" type="file" accept=".json,.txt,application/json,text/plain">
</div>
<textarea class="textarea" id="import-text" rows="4" spellcheck="false" data-i18n-attr="aria-label:settings.import;placeholder:settings.pastePlaceholder"></textarea>
<div class="row">
<button class="btn" type="button" id="import-paste" data-i18n="settings.importText"></button>
</div>
<h3 class="dialog__subtitle" data-i18n="settings.danger"></h3>
<div class="row">
<button class="btn btn--danger" type="button" id="reset-game" data-i18n="settings.reset"></button>
</div>
</form>
</dialog>
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
import { EFFECT_TYPES } from './effects.js';
/**
* Bundle the static game data (data/*.json) with lookup tables and validate it,
* so a typo in a JSON file fails loudly at startup instead of mid-game.
*/
export function buildContext({ rules, generators, upgrades }) {
const generatorsById = new Map(generators.map((g) => [g.id, g]));
const upgradesById = new Map(upgrades.map((u) => [u.id, u]));
if (generatorsById.size !== generators.length) throw new Error('Duplicate generator id');
if (upgradesById.size !== upgrades.length) throw new Error('Duplicate upgrade id');
for (const u of upgrades) {
if (!EFFECT_TYPES.includes(u.effect?.type)) {
throw new Error(`Upgrade "${u.id}": unknown effect type "${u.effect?.type}"`);
}
if (u.effect.target && !generatorsById.has(u.effect.target)) {
throw new Error(`Upgrade "${u.id}": unknown generator "${u.effect.target}"`);
}
if (u.requires && !upgradesById.has(u.requires)) {
throw new Error(`Upgrade "${u.id}": unknown requirement "${u.requires}"`);
}
}
return { rules, generators, upgrades, generatorsById, upgradesById };
}
+38
View File
@@ -0,0 +1,38 @@
// Upgrade effects. To add a new effect type: register it here, add its
// `effect.<type>` string to the core locale files, and use it in upgrades.json.
export function newModifiers() {
return {
click: 1,
generators: {},
global: 1,
demand: 1,
value: 1,
materialEfficiency: 1,
batchDiscount: 1,
autoBuyMaterial: false,
};
}
const APPLY = {
click_mult: (m, e) => void (m.click *= e.value),
generator_mult: (m, e) => void (m.generators[e.target] = (m.generators[e.target] ?? 1) * e.value),
global_mult: (m, e) => void (m.global *= e.value),
demand_mult: (m, e) => void (m.demand *= e.value),
value_mult: (m, e) => void (m.value *= e.value),
material_efficiency: (m, e) => void (m.materialEfficiency *= e.value),
batch_discount: (m, e) => void (m.batchDiscount *= e.value),
auto_buy_material: (m) => void (m.autoBuyMaterial = true),
};
export const EFFECT_TYPES = Object.keys(APPLY);
/** Fold every owned upgrade into a single set of multipliers. */
export function computeModifiers(ownedIds, upgradesById) {
const m = newModifiers();
for (const id of ownedIds) {
const effect = upgradesById.get(id)?.effect;
if (effect) APPLY[effect.type]?.(m, effect);
}
return m;
}
+179
View File
@@ -0,0 +1,179 @@
// Game rules. Every function takes the mutable `state` and the static `ctx`
// (see context.js) and never touches the DOM, so it runs the same in the
// browser and in Node (tests, balance simulations).
import { computeModifiers } from './effects.js';
import { bulkCost, clamp, demandPerSecond, maxAffordable } from './formulas.js';
const MAX_STEP_SECONDS = 1;
// ---------------------------------------------------------------- conditions
export function isUnlocked(state, condition = {}) {
if (condition.totalProduced != null && state.totalProduced < condition.totalProduced) return false;
if (condition.generator != null && (state.generators[condition.generator] ?? 0) < (condition.count ?? 1)) {
return false;
}
return true;
}
export function upgradeVisible(state, upgrade) {
if (state.upgrades.includes(upgrade.id)) return false;
if (upgrade.requires && !state.upgrades.includes(upgrade.requires)) return false;
return isUnlocked(state, upgrade.unlock);
}
// ------------------------------------------------------------------- derived
/** Everything computed from the state (rates, multipliers, current prices). */
export function derive(state, ctx) {
const { rules } = ctx;
const mods = computeModifiers(state.upgrades, ctx.upgradesById);
const perGenerator = {};
let rate = 0;
for (const g of ctx.generators) {
perGenerator[g.id] = g.baseRate * (mods.generators[g.id] ?? 1) * mods.global;
rate += perGenerator[g.id] * (state.generators[g.id] ?? 0);
}
const demandMultiplier = mods.demand * rules.marketing.demandPerLevel ** state.marketing;
return {
clickYield: rules.click.yield * mods.click,
perGenerator,
rate,
demandMultiplier,
demand: demandPerSecond(rules.market, state.price, demandMultiplier),
valueMultiplier: mods.value,
materialPerUnit: rules.unit.materialPerUnit / mods.materialEfficiency,
batchSize: rules.materialMarket.batchSize,
batchCost: Math.ceil(rules.materialMarket.basePrice * state.materialFactor * mods.batchDiscount),
autoBuyMaterial: mods.autoBuyMaterial,
marketingCost: Math.ceil(rules.marketing.baseCost * rules.marketing.costGrowth ** state.marketing),
};
}
// ------------------------------------------------------------------- actions
// Each returns true when the action was performed.
export function click(state, ctx) {
const d = derive(state, ctx);
if (state.material < d.materialPerUnit) return false;
const made = Math.min(d.clickYield, state.material / d.materialPerUnit);
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
return true;
}
export function buyMaterial(state, ctx) {
const d = derive(state, ctx);
if (state.funds < d.batchCost) return false;
state.funds -= d.batchCost;
state.material += d.batchSize;
return true;
}
export function buyMarketing(state, ctx) {
const d = derive(state, ctx);
if (state.funds < d.marketingCost) return false;
state.funds -= d.marketingCost;
state.marketing += 1;
return true;
}
/** How many generators the player gets for `amount` ("max" or a number). */
export function generatorQuote(state, ctx, id, amount) {
const g = ctx.generatorsById.get(id);
const owned = state.generators[id] ?? 0;
const count = amount === 'max' ? maxAffordable(g.baseCost, g.costGrowth, owned, state.funds) : amount;
return { count, cost: bulkCost(g.baseCost, g.costGrowth, owned, count) };
}
export function buyGenerator(state, ctx, id, amount = 1) {
if (!ctx.generatorsById.has(id)) return false;
const { count, cost } = generatorQuote(state, ctx, id, amount);
if (count <= 0 || state.funds < cost) return false;
state.funds -= cost;
state.generators[id] += count;
return true;
}
export function buyUpgrade(state, ctx, id) {
const upgrade = ctx.upgradesById.get(id);
if (!upgrade || !upgradeVisible(state, upgrade) || state.funds < upgrade.cost) return false;
state.funds -= upgrade.cost;
state.upgrades.push(id);
return true;
}
export function setPrice(state, ctx, price) {
const { minPrice, maxPrice } = ctx.rules.market;
if (!Number.isFinite(price)) return false;
state.price = Math.round(clamp(price, minPrice, maxPrice) * 100) / 100;
return true;
}
// ---------------------------------------------------------------------- time
function stepMaterialMarket(state, ctx, dt, rng) {
const m = ctx.rules.materialMarket;
state.materialTimer += dt;
while (state.materialTimer >= m.updateEverySeconds) {
state.materialTimer -= m.updateEverySeconds;
const shock = 1 + (rng() - 0.5) * 2 * m.volatility;
const pulled = state.materialFactor + (1 - state.materialFactor) * m.reversion;
state.materialFactor = clamp(pulled * shock, m.minFactor, m.maxFactor);
}
}
function step(state, ctx, dt, rng, totals) {
stepMaterialMarket(state, ctx, dt, rng);
const d = derive(state, ctx);
// Production, limited by the material in stock.
const made = Math.min(d.rate * dt, state.material / d.materialPerUnit);
state.material -= made * d.materialPerUnit;
state.units += made;
state.totalProduced += made;
// Automatic restock when the stock runs low.
if (d.autoBuyMaterial && state.material < d.batchSize / 2 && state.funds >= d.batchCost) {
buyMaterial(state, ctx);
}
// Customers buy what they want, up to what is in stock.
const sold = Math.min(state.units, d.demand * dt);
const revenue = sold * state.price * d.valueMultiplier;
state.units -= sold;
state.funds += revenue;
state.totalSold += sold;
totals.produced += made;
totals.sold += sold;
totals.revenue += revenue;
}
/** Advance the simulation by `seconds`. Returns what happened during that time. */
export function advance(state, ctx, seconds, rng = Math.random) {
const totals = { produced: 0, sold: 0, revenue: 0 };
let left = Math.max(0, seconds);
while (left > 1e-9) {
const dt = Math.min(left, MAX_STEP_SECONDS);
step(state, ctx, dt, rng, totals);
left -= dt;
}
return totals;
}
/**
* Catch up on the time the game was closed. Deterministic (no price noise)
* and discounted by `offline.efficiency`. Returns null if too short to matter.
*/
export function simulateOffline(state, ctx, elapsedSeconds) {
const { maxSeconds, efficiency, minSeconds } = ctx.rules.offline;
if (elapsedSeconds < minSeconds) return null;
const seconds = Math.min(elapsedSeconds, maxSeconds) * efficiency;
const totals = advance(state, ctx, seconds, () => 0.5);
return { ...totals, seconds: Math.min(elapsedSeconds, maxSeconds) };
}
+37
View File
@@ -0,0 +1,37 @@
// Pure math helpers. No state, no DOM.
export function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/** Price of the next item when `owned` items are already owned. */
export function unitCost(base, growth, owned) {
return base * growth ** owned;
}
/** Total price of buying `count` more items (geometric series, rounded up). */
export function bulkCost(base, growth, owned, count) {
if (count <= 0) return 0;
return Math.ceil((unitCost(base, growth, owned) * (growth ** count - 1)) / (growth - 1));
}
/** How many items `budget` can pay for, robust against float error. */
export function maxAffordable(base, growth, owned, budget) {
const first = unitCost(base, growth, owned);
if (Number.isNaN(budget) || budget < first) return 0;
let n = Math.floor(Math.log((budget * (growth - 1)) / first + 1) / Math.log(growth));
while (n > 0 && bulkCost(base, growth, owned, n) > budget) n--;
while (bulkCost(base, growth, owned, n + 1) <= budget) n++;
return Math.max(n, 0);
}
/**
* Units customers want to buy per second at `price`.
* Demand is maxed at or below the fair price and drops steeply above it, so
* extra production is only worth selling at a higher price while supply is
* short; to sell more at the fair price, the player needs more demand.
*/
export function demandPerSecond(market, price, multiplier) {
const overpriced = Math.max(1, price / market.fairPrice);
return (market.baseDemand * multiplier) / overpriced ** market.elasticity;
}
+70
View File
@@ -0,0 +1,70 @@
import { clamp } from './formulas.js';
export const STATE_VERSION = 1;
const MAX_NUMBER = 1e300;
const MAX_OWNED = 4000; // keeps cost formulas (growth ** owned) finite
const MAX_MARKETING = 300;
export function createState(ctx, now = Date.now()) {
const { start, market } = ctx.rules;
return {
v: STATE_VERSION,
units: start.units,
funds: start.funds,
material: start.material,
totalProduced: 0,
totalSold: 0,
price: start.price ?? market.basePrice,
marketing: 0,
materialFactor: 1,
generators: Object.fromEntries(ctx.generators.map((g) => [g.id, 0])),
upgrades: [],
savedAt: now,
materialTimer: 0, // transient, never saved
};
}
function num(value, fallback, min = 0, max = MAX_NUMBER) {
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
}
function int(value, max) {
return Math.floor(num(value, 0, 0, max));
}
/**
* Turn untrusted data (cookie, imported file) into a valid state.
* Unknown ids are dropped and numbers are clamped; returns null if `raw` is not an object.
*/
export function sanitizeState(raw, ctx, now = Date.now()) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const { market, materialMarket } = ctx.rules;
const base = createState(ctx, now);
const owned = Array.isArray(raw.upgrades) ? raw.upgrades : [];
return {
...base,
units: num(raw.units, base.units),
funds: num(raw.funds, base.funds),
material: num(raw.material, base.material),
totalProduced: num(raw.totalProduced, 0),
totalSold: num(raw.totalSold, 0),
price: Math.round(num(raw.price, base.price, market.minPrice, market.maxPrice) * 100) / 100,
marketing: int(raw.marketing, MAX_MARKETING),
materialFactor: num(raw.materialFactor, 1, materialMarket.minFactor, materialMarket.maxFactor),
generators: Object.fromEntries(
ctx.generators.map((g) => [g.id, int(raw.generators?.[g.id], MAX_OWNED)]),
),
upgrades: [...new Set(owned)].filter((id) => ctx.upgradesById.has(id)),
savedAt: num(raw.savedAt, now, 0, now),
};
}
/** Compact JSON (numbers trimmed to 10 significant digits) for cookies and export. */
export function serializeState(state) {
const { materialTimer, ...persisted } = state;
return JSON.stringify(persisted, (_key, value) =>
typeof value === 'number' && Number.isFinite(value) ? Number(value.toPrecision(10)) : value,
);
}
+237
View File
@@ -0,0 +1,237 @@
// Entry point: wires the engine, the services (cookies, i18n, themes) and the UI.
import {
advance,
buyGenerator,
buyMarketing,
buyMaterial,
buyUpgrade,
click,
derive,
setPrice,
simulateOffline,
} from './engine/engine.js';
import { createState, sanitizeState, serializeState } from './engine/state.js';
import { fetchLocales, fetchThemes, loadGameContext } from './services/data.js';
import { createFormatter } from './services/format.js';
import { applyTranslations, fetchThemeName, FALLBACK_LOCALE, loadDictionary, t } from './services/i18n.js';
import { buildExport, parseImport } from './services/saveio.js';
import { cookiesAvailable, loadGameText, loadPrefs, saveGameText, savePrefs, deleteGame } from './services/storage.js';
import { applyThemeStyles } from './services/themes.js';
import { $, refreshStaticIcons, setThemeAssets } from './ui/dom.js';
import { createSettings } from './ui/settings.js';
import { showToast } from './ui/toast.js';
import { createView } from './ui/view.js';
const TICK_MS = 100;
const INCOME_SMOOTHING_SECONDS = 3;
const SAVE_ERROR_COOLDOWN_MS = 60_000;
let ctx;
let themes = [];
let locales = [];
let themeId;
let locale;
let state;
let fmt;
let income = 0;
let lastSaveError = 0;
const themeNames = new Map();
// ------------------------------------------------------------------ saving
function persist() {
if (!state) return;
state.savedAt = Date.now();
try {
saveGameText(themeId, serializeState(state));
} catch {
if (Date.now() - lastSaveError > SAVE_ERROR_COOLDOWN_MS) {
lastSaveError = Date.now();
showToast(t('toast.saveFailed'), { error: true });
}
}
}
function loadSavedState() {
const text = loadGameText(themeId);
if (!text) return null;
try {
return sanitizeState(JSON.parse(text), ctx);
} catch {
return null;
}
}
function persistPrefs() {
savePrefs({ theme: themeId, lang: locale });
}
// ------------------------------------------------------------ theme / language
function pickLocale(preferred) {
const codes = locales.map((l) => l.code);
if (codes.includes(preferred)) return preferred;
for (const tag of navigator.languages ?? [navigator.language]) {
const match = codes.find((code) => code === tag || code === tag?.split('-')[0]);
if (match) return match;
}
return FALLBACK_LOCALE;
}
async function themeLabel(id) {
const key = `${id}:${locale}`;
if (!themeNames.has(key)) themeNames.set(key, await fetchThemeName(id, locale));
return themeNames.get(key);
}
async function fillSelects() {
const themeSelect = $('#theme-select');
const labels = await Promise.all(themes.map((th) => themeLabel(th.id)));
themeSelect.replaceChildren(
...themes.map((th, i) => new Option(labels[i], th.id, false, th.id === themeId)),
);
$('#locale-select').replaceChildren(
...locales.map((l) => new Option(l.name, l.code, false, l.code === locale)),
);
}
/** Reload texts, assets and the lists for the current theme + language. */
async function refreshTexts() {
await loadDictionary(locale, themeId);
fmt = createFormatter(locale);
setThemeAssets(themeId, themes.find((th) => th.id === themeId)?.assets);
applyTranslations();
document.title = t('theme.title');
view.rebuild(ctx, fmt);
await fillSelects();
}
async function enterTheme(id) {
persist(); // keep the theme we are leaving
themeId = id;
await applyThemeStyles(id);
await refreshTexts();
state = loadSavedState() ?? createState(ctx);
income = 0;
catchUp((Date.now() - state.savedAt) / 1000);
persistPrefs();
render();
}
// ------------------------------------------------------------------ the loop
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60);
if (minutes < 1) return t('time.seconds', { value: Math.round(seconds) });
if (minutes < 60) return t('time.minutes', { value: minutes });
return t('time.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: minutes % 60 });
}
/** Apply progress made while the game was closed or the device asleep. */
function catchUp(elapsedSeconds) {
const report = simulateOffline(state, ctx, elapsedSeconds);
if (!report || (report.produced <= 0 && report.revenue <= 0)) return;
showToast(
t('toast.welcomeBack', {
duration: formatDuration(report.seconds),
produced: fmt.number(Math.floor(report.produced)),
funds: t('theme.fmt.funds', { value: fmt.money(report.revenue) }),
}),
);
}
let lastFrame = Date.now();
function frame() {
const now = Date.now();
const dt = (now - lastFrame) / 1000;
lastFrame = now;
if (!state || dt <= 0) return;
if (dt >= ctx.rules.offline.minSeconds) {
catchUp(dt); // tab was frozen or the device slept
} else {
const { revenue } = advance(state, ctx, dt);
income += (revenue / dt - income) * Math.min(1, dt / INCOME_SMOOTHING_SECONDS);
}
render();
}
function render() {
view.update(state, derive(state, ctx), { income });
}
// ------------------------------------------------------------------- actions
const act = (fn) => (...args) => {
fn(state, ctx, ...args);
render();
};
const view = createView({
make: act(click),
buyMaterial: act(buyMaterial),
buyMarketing: act(buyMarketing),
buyGenerator: act(buyGenerator),
buyUpgrade: act(buyUpgrade),
setPrice: act(setPrice),
nudgePrice: (delta) => act(setPrice)(state.price + delta),
});
createSettings({
getExport: () => ({
text: buildExport(themeId, state),
name: `idle-clicker-${themeId}-${new Date().toISOString().slice(0, 10)}`,
}),
async importText(text) {
const imported = parseImport(text, ctx, themes.map((th) => th.id)); // throws before any change
if (imported.themeId !== themeId) await enterTheme(imported.themeId);
state = imported.state;
persist();
render();
},
reset() {
deleteGame(themeId);
state = createState(ctx);
income = 0;
persist();
render();
},
});
// ---------------------------------------------------------------------- boot
function showFatal(error) {
console.error(error);
document.body.replaceChildren(Object.assign(document.createElement('pre'), { textContent: String(error) }));
}
async function boot() {
[ctx, themes, locales] = await Promise.all([loadGameContext(), fetchThemes(), fetchLocales()]);
if (themes.length === 0) throw new Error('No theme installed in public/themes/');
const prefs = loadPrefs();
locale = pickLocale(prefs.lang);
const initialTheme = themes.find((th) => th.id === prefs.theme) ?? themes[0];
$('#cookie-warning').hidden = cookiesAvailable();
$('#theme-select').addEventListener('change', (e) => enterTheme(e.target.value));
$('#locale-select').addEventListener('change', async (e) => {
locale = e.target.value;
persistPrefs();
await refreshTexts();
render();
});
await enterTheme(initialTheme.id);
refreshStaticIcons();
lastFrame = Date.now();
setInterval(frame, TICK_MS);
setInterval(persist, ctx.rules.autosaveSeconds * 1000);
document.addEventListener('visibilitychange', () => document.hidden && persist());
addEventListener('pagehide', persist);
}
boot().catch(showFatal);
+21
View File
@@ -0,0 +1,21 @@
// Thin wrapper over document.cookie. Values must already be cookie-safe
// (see storage.js, which base64url-encodes everything).
const ONE_YEAR = 60 * 60 * 24 * 365;
export function readCookie(name) {
const prefix = `${name}=`;
for (const part of document.cookie.split('; ')) {
if (part.startsWith(prefix)) return part.slice(prefix.length);
}
return null;
}
export function writeCookie(name, value) {
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${name}=${value}; Max-Age=${ONE_YEAR}; Path=/; SameSite=Lax${secure}`;
}
export function removeCookie(name) {
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
}
+24
View File
@@ -0,0 +1,24 @@
// Loads the static game data and the lists of installed themes / languages.
import { buildContext } from '../engine/context.js';
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
return response.json();
}
export async function loadGameContext() {
const [rules, generators, upgrades] = await Promise.all([
getJson('data/rules.json'),
getJson('data/generators.json'),
getJson('data/upgrades.json'),
]);
return buildContext({ rules, generators, upgrades });
}
/** [{ id, order, assets, locales }] discovered from public/themes/ by the server. */
export const fetchThemes = () => getJson('api/themes');
/** [{ code, name }] discovered from public/locales/ by the server. */
export const fetchLocales = () => getJson('api/locales');
+25
View File
@@ -0,0 +1,25 @@
// Locale-aware number formatting with compact notation for big values.
export function createFormatter(locale) {
const make = (options) => new Intl.NumberFormat(locale, options);
const decimals = make({ maximumFractionDigits: 2 });
const integer = make({ maximumFractionDigits: 0 });
const fixed2 = make({ minimumFractionDigits: 2, maximumFractionDigits: 2 });
const compact = make({ notation: 'compact', maximumFractionDigits: 2 });
const scientific = make({ notation: 'scientific', maximumFractionDigits: 2 });
function number(n) {
if (!Number.isFinite(n)) return '∞';
const abs = Math.abs(n);
if (abs < 1000) return Number.isInteger(n) ? integer.format(n) : decimals.format(n);
if (abs < 1e6) return integer.format(n);
return abs < 1e15 ? compact.format(n) : scientific.format(n);
}
/** Amounts of money: always two decimals while small. */
function money(n) {
return Math.abs(n) < 1000 ? fixed2.format(n) : number(n);
}
return { number, money, price: (n) => fixed2.format(n) };
}
+81
View File
@@ -0,0 +1,81 @@
// Internationalisation.
//
// Two kinds of locale files are merged into one flat dictionary:
// locales/<lang>.json core UI texts (shared by all themes)
// themes/<id>/locales/<lang>.json theme texts, all under the "theme." root
// Missing keys fall back to English, then to the key itself.
//
// Strings support {param} interpolation and {@some.key} to embed another entry
// (e.g. "{@theme.res.material}"), so core texts can use theme vocabulary.
export const FALLBACK_LOCALE = 'en';
const MAX_EMBED_DEPTH = 5;
let dictionary = {};
let currentLocale = FALLBACK_LOCALE;
export const getLocale = () => currentLocale;
function flatten(object, prefix = '', out = {}) {
for (const [key, value] of Object.entries(object ?? {})) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object') flatten(value, path, out);
else out[path] = String(value);
}
return out;
}
async function fetchJson(url) {
try {
const response = await fetch(url);
return response.ok ? await response.json() : {};
} catch {
return {};
}
}
export async function loadDictionary(locale, themeId) {
const order = locale === FALLBACK_LOCALE ? [FALLBACK_LOCALE] : [FALLBACK_LOCALE, locale];
const files = order.flatMap((l) => [`locales/${l}.json`, `themes/${themeId}/locales/${l}.json`]);
const parts = await Promise.all(files.map(fetchJson));
dictionary = Object.assign({}, ...parts.map((p) => flatten(p)));
currentLocale = locale;
document.documentElement.lang = locale;
}
export function has(key) {
return key in dictionary;
}
export function t(key, params = {}, depth = 0) {
const text = dictionary[key];
if (text === undefined) return key;
return text.replace(/\{(@?)([\w.]+)\}/g, (match, embed, name) => {
if (embed) return depth < MAX_EMBED_DEPTH ? t(name, params, depth + 1) : '';
return name in params ? String(params[name]) : match;
});
}
/**
* Translate static markup:
* data-i18n="key" -> textContent
* data-i18n-attr="title:key;aria-label:key" -> attributes
*/
export function applyTranslations(root = document) {
for (const el of root.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of root.querySelectorAll('[data-i18n-attr]')) {
for (const pair of el.dataset.i18nAttr.split(';')) {
const [attr, key] = pair.split(':').map((s) => s.trim());
if (attr && key) el.setAttribute(attr, t(key));
}
}
}
/** Display name of a theme in `locale` (falls back to English, then the id). */
export async function fetchThemeName(themeId, locale) {
for (const l of [locale, FALLBACK_LOCALE]) {
const file = await fetchJson(`themes/${themeId}/locales/${l}.json`);
if (file?.theme?.name) return file.theme.name;
}
return themeId;
}
+64
View File
@@ -0,0 +1,64 @@
// Export / import of a save as a JSON or TXT file (or pasted text).
// Works on every device: download via a Blob link, import via file input or paste.
import { sanitizeState, serializeState } from '../engine/state.js';
const GAME_ID = 'idle-clicker';
const FORMAT = 1;
export class ImportError extends Error {
/** @param {'empty'|'invalid'|'unknown_theme'|'wrong_game'} code */
constructor(code) {
super(code);
this.code = code;
}
}
/** The text a player downloads, copies or pastes back. */
export function buildExport(themeId, state, now = Date.now()) {
return JSON.stringify(
{
game: GAME_ID,
format: FORMAT,
theme: themeId,
exportedAt: new Date(now).toISOString(),
state: JSON.parse(serializeState(state)),
},
null,
2,
);
}
/** Validate imported text. Throws ImportError; never trusts the content. */
export function parseImport(text, ctx, knownThemeIds) {
const trimmed = String(text ?? '').trim().replace(/^/, '');
if (!trimmed) throw new ImportError('empty');
let data;
try {
data = JSON.parse(trimmed);
} catch {
throw new ImportError('invalid');
}
if (data?.game !== GAME_ID || data.format !== FORMAT) throw new ImportError('wrong_game');
if (!knownThemeIds.includes(data.theme)) throw new ImportError('unknown_theme');
const state = sanitizeState(data.state, ctx);
if (!state) throw new ImportError('invalid');
return { themeId: data.theme, state };
}
export function downloadText(filename, text, mime = 'application/json') {
const url = URL.createObjectURL(new Blob([text], { type: `${mime};charset=utf-8` }));
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export function readFileText(file) {
return file.text();
}
+106
View File
@@ -0,0 +1,106 @@
// Persistence in browser cookies.
//
// A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks:
// ic_save_<theme>_n -> number of chunks
// ic_save_<theme>_0.. -> the chunks
// Each theme has its own save slot. Preferences (theme, language) are stored
// in a single small cookie.
import { readCookie, removeCookie, writeCookie } from './cookies.js';
const CHUNK_SIZE = 3000;
const MAX_CHUNKS = 6;
const PREFS_KEY = 'ic_prefs';
const saveKey = (themeId) => `ic_save_${themeId}`;
// ------------------------------------------------------------------ encoding
export function encode(text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
export function decode(encoded) {
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
}
// ------------------------------------------------------------------- chunked
function writeChunked(key, text) {
const encoded = encode(text);
const chunks = [];
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) chunks.push(encoded.slice(i, i + CHUNK_SIZE));
if (chunks.length > MAX_CHUNKS) throw new Error('save_too_large');
const previous = Number(readCookie(`${key}_n`)) || 0;
chunks.forEach((chunk, i) => writeCookie(`${key}_${i}`, chunk));
writeCookie(`${key}_n`, String(chunks.length));
for (let i = chunks.length; i < previous; i++) removeCookie(`${key}_${i}`);
}
function readChunked(key) {
const count = Number(readCookie(`${key}_n`));
if (!Number.isInteger(count) || count < 1 || count > MAX_CHUNKS) return null;
let encoded = '';
for (let i = 0; i < count; i++) {
const chunk = readCookie(`${key}_${i}`);
if (chunk == null) return null;
encoded += chunk;
}
try {
return decode(encoded);
} catch {
return null;
}
}
function removeChunked(key) {
const count = Number(readCookie(`${key}_n`)) || 0;
for (let i = 0; i < count; i++) removeCookie(`${key}_${i}`);
removeCookie(`${key}_n`);
}
// -------------------------------------------------------------------- public
/** False when the browser refuses cookies (private mode, blocked, ...). */
export function cookiesAvailable() {
try {
writeCookie('ic_probe', '1');
const ok = readCookie('ic_probe') === '1';
removeCookie('ic_probe');
return ok;
} catch {
return false;
}
}
/** Throws if the save does not fit in cookies. */
export function saveGameText(themeId, text) {
writeChunked(saveKey(themeId), text);
}
export function loadGameText(themeId) {
return readChunked(saveKey(themeId));
}
export function deleteGame(themeId) {
removeChunked(saveKey(themeId));
}
export function savePrefs(prefs) {
writeCookie(PREFS_KEY, encode(JSON.stringify(prefs)));
}
export function loadPrefs() {
try {
const raw = readCookie(PREFS_KEY);
const prefs = raw && JSON.parse(decode(raw));
return prefs && typeof prefs === 'object' ? prefs : {};
} catch {
return {};
}
}
+18
View File
@@ -0,0 +1,18 @@
// Applies a theme's stylesheet. Texts are handled by i18n.js, mechanics are
// shared (public/data); a theme is only a skin: colours, vocabulary, images.
export function applyThemeStyles(themeId) {
document.documentElement.dataset.theme = themeId;
let link = document.getElementById('theme-css');
if (!link) {
link = document.createElement('link');
link.id = 'theme-css';
link.rel = 'stylesheet';
document.head.append(link);
}
return new Promise((resolve) => {
link.onload = link.onerror = () => resolve();
link.href = `themes/${themeId}/theme.css`;
});
}
+44
View File
@@ -0,0 +1,44 @@
export const $ = (selector, root = document) => root.querySelector(selector);
/** Create an element: h('button', { class: 'btn', text: 'Go', onclick }, ...children). */
export function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value == null || value === false) continue;
if (key === 'class') el.className = value;
else if (key === 'text') el.textContent = value;
else if (key === 'dataset') Object.assign(el.dataset, value);
else if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), value);
else el.setAttribute(key, value === true ? '' : value);
}
el.append(...children.flat().filter((c) => c != null && c !== false));
return el;
}
// ---------------------------------------------------------------------- icons
// Every icon is a grey square for now. When a theme provides images, list them
// in its theme.json ("assets": { "gen.gen1": "img/autoclipper.svg", ... }) and
// they replace the placeholders, with no change to the UI code.
let assetBase = '';
let assets = {};
export function setThemeAssets(themeId, themeAssets) {
assetBase = `themes/${themeId}/`;
assets = themeAssets ?? {};
}
/** key examples: "res.units", "gen.gen1", "upg.click_1" */
export function icon(key) {
const path = assets[key];
if (path) return h('img', { class: 'icon', src: assetBase + path, alt: '', loading: 'lazy' });
return h('span', { class: 'icon icon--placeholder', 'aria-hidden': 'true', dataset: { icon: key } });
}
/** Re-resolve the icons that live in index.html (resource tiles). */
export function refreshStaticIcons(root = document) {
for (const el of root.querySelectorAll('[data-icon]')) {
if (el.closest('#generators, #upgrades')) continue; // those lists are rebuilt by view.js
el.replaceWith(icon(el.dataset.icon));
}
}
+71
View File
@@ -0,0 +1,71 @@
// "Save & settings" dialog: export (file or text), import (file or pasted text), reset.
import { downloadText, ImportError, readFileText } from '../services/saveio.js';
import { t } from '../services/i18n.js';
import { $ } from './dom.js';
import { showToast } from './toast.js';
/**
* @param hooks.getExport () => { text, name } current save as text + file base name
* @param hooks.importText (text) => void throws ImportError on bad input
* @param hooks.reset () => void
*/
export function createSettings(hooks) {
const dialog = $('#settings');
const exportBox = $('#export-text');
const importBox = $('#import-text');
const fileInput = $('#import-file');
function open() {
exportBox.value = hooks.getExport().text;
importBox.value = '';
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
function download(extension, mime) {
const { text, name } = hooks.getExport();
downloadText(`${name}.${extension}`, text, mime);
}
async function copy() {
exportBox.value = hooks.getExport().text;
try {
await navigator.clipboard.writeText(exportBox.value);
} catch {
exportBox.select();
if (!document.execCommand?.('copy')) return showToast(t('toast.copyFailed'), { error: true });
}
showToast(t('toast.copied'));
}
async function runImport(text) {
try {
await hooks.importText(text);
dialog.close?.();
showToast(t('toast.imported'));
} catch (error) {
const code = error instanceof ImportError ? error.code : 'invalid';
showToast(t(`error.import.${code}`), { error: true });
}
}
$('#open-settings').addEventListener('click', open);
$('#export-json').addEventListener('click', () => download('json', 'application/json'));
$('#export-txt').addEventListener('click', () => download('txt', 'text/plain'));
$('#export-copy').addEventListener('click', copy);
$('#import-paste').addEventListener('click', () => runImport(importBox.value));
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
fileInput.value = ''; // allow picking the same file again
if (file) await runImport(await readFileText(file));
});
$('#reset-game').addEventListener('click', () => {
if (!confirm(t('settings.resetConfirm'))) return;
hooks.reset();
dialog.close?.();
showToast(t('toast.reset'));
});
return { open };
}
+9
View File
@@ -0,0 +1,9 @@
import { $, h } from './dom.js';
const DURATION_MS = 6000;
export function showToast(message, { error = false } = {}) {
const toast = h('p', { class: error ? 'toast toast--error' : 'toast', text: message });
$('#toasts').append(toast);
setTimeout(() => toast.remove(), DURATION_MS);
}
+231
View File
@@ -0,0 +1,231 @@
// Main game view. Static markup lives in index.html; this module binds the
// controls once, builds the generator/upgrade lists, and refreshes the numbers.
import { generatorQuote, isUnlocked, upgradeVisible } from '../engine/engine.js';
import { t } from '../services/i18n.js';
import { $, h, icon, refreshStaticIcons } from './dom.js';
const SLIDER_STEPS = 1000;
function setText(node, text) {
if (node.textContent !== text) node.textContent = text;
}
/**
* @param actions callbacks into the game: make, buyMaterial, buyMarketing,
* buyGenerator(id, amount), buyUpgrade(id), setPrice(value)
*/
export function createView(actions) {
const el = {
units: $('#v-units'),
funds: $('#v-funds'),
material: $('#v-material'),
total: $('#v-total'),
rate: $('#v-rate'),
demand: $('#v-demand'),
income: $('#v-income'),
make: $('#make'),
makeYield: $('#make-yield'),
buyMaterial: $('#buy-material'),
buyMaterialInfo: $('#buy-material-info'),
buyMarketing: $('#buy-marketing'),
buyMarketingInfo: $('#buy-marketing-info'),
priceInput: $('#price-input'),
priceSlider: $('#price-slider'),
priceHint: $('#price-hint'),
generators: $('#generators'),
upgrades: $('#upgrades'),
upgradeCount: $('#upgrade-count'),
upgradesEmpty: $('#upgrades-empty'),
};
let ctx;
let fmt;
let buyAmount = 1;
let generatorRows = new Map();
let upgradeRows = new Map();
let upgradeSignature = null;
const money = (n) => t('theme.fmt.funds', { value: fmt.money(n) });
const perSecond = (n) => t('ui.perSecond', { value: fmt.number(n) });
// ---------------------------------------------------------------- controls
el.make.addEventListener('click', () => actions.make());
el.buyMaterial.addEventListener('click', () => actions.buyMaterial());
el.buyMarketing.addEventListener('click', () => actions.buyMarketing());
const priceFromSlider = () => {
const { minPrice, maxPrice } = ctx.rules.market;
return minPrice * (maxPrice / minPrice) ** (Number(el.priceSlider.value) / SLIDER_STEPS);
};
const sliderFromPrice = (price) => {
const { minPrice, maxPrice } = ctx.rules.market;
return (SLIDER_STEPS * Math.log(price / minPrice)) / Math.log(maxPrice / minPrice);
};
el.priceSlider.addEventListener('input', () => actions.setPrice(priceFromSlider()));
el.priceInput.addEventListener('change', () => actions.setPrice(Number(el.priceInput.value)));
$('#price-down').addEventListener('click', () => actions.nudgePrice(-ctx.rules.market.priceStep));
$('#price-up').addEventListener('click', () => actions.nudgePrice(ctx.rules.market.priceStep));
$('#buy-amount').addEventListener('change', (event) => {
const value = event.target.value;
buyAmount = value === 'max' ? 'max' : Number(value);
});
// ------------------------------------------------------------------- lists
function effectText(effect) {
const params = { value: fmt.number(effect.value) };
if (effect.target) params.target = t(`theme.gen.${effect.target}.name`);
if (effect.type === 'batch_discount') params.percent = fmt.number(Math.round((1 - effect.value) * 100));
return t(`effect.${effect.type}`, params);
}
function generatorRow(g) {
const owned = h('span', { class: 'badge' });
const rate = h('span');
const amount = h('span', { class: 'btn__label' });
const cost = h('small');
const buy = h(
'button',
{ class: 'btn btn--buy', type: 'button', onclick: () => actions.buyGenerator(g.id, buyAmount) },
amount,
cost,
);
const root = h(
'li',
{ class: 'card', hidden: true },
icon(`gen.${g.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title' }, h('span', { text: t(`theme.gen.${g.id}.name`) }), owned),
h('p', { class: 'card__desc', text: t(`theme.gen.${g.id}.desc`) }),
h('p', { class: 'card__meta' }, rate),
),
buy,
);
el.generators.append(root);
return { root, owned, rate, amount, cost, buy };
}
function upgradeRow(u) {
const cost = h('small');
const root = h(
'li',
{ class: 'card' },
icon(`upg.${u.id}`),
h(
'div',
{ class: 'card__body' },
h('h3', { class: 'card__title', text: t(`theme.upg.${u.id}`) }),
h('p', { class: 'card__desc', text: effectText(u.effect) }),
),
h('button', { class: 'btn btn--buy', type: 'button', onclick: () => actions.buyUpgrade(u.id) }, cost),
);
return { root, cost, buy: root.lastElementChild, upgrade: u };
}
// ------------------------------------------------------------------ public
/** Call after the theme, language or game data changed. */
function rebuild(newCtx, formatter) {
ctx = newCtx;
fmt = formatter;
el.generators.replaceChildren();
el.upgrades.replaceChildren();
generatorRows = new Map(ctx.generators.map((g) => [g.id, generatorRow(g)]));
upgradeRows = new Map();
upgradeSignature = null;
el.priceInput.min = ctx.rules.market.minPrice;
el.priceInput.max = ctx.rules.market.maxPrice;
el.priceInput.step = ctx.rules.market.priceStep;
refreshStaticIcons();
}
function updateGenerators(state, d) {
for (const g of ctx.generators) {
const row = generatorRows.get(g.id);
const owned = state.generators[g.id];
const visible = owned > 0 || isUnlocked(state, g.unlock);
row.root.hidden = !visible;
if (!visible) continue;
let quote = generatorQuote(state, ctx, g.id, buyAmount);
if (quote.count <= 0) quote = generatorQuote(state, ctx, g.id, 1);
setText(row.owned, t('ui.owned', { count: fmt.number(owned) }));
setText(row.rate, t('ui.rateEach', { rate: fmt.number(d.perGenerator[g.id]) }));
setText(row.amount, t('ui.buyN', { count: fmt.number(quote.count) }));
setText(row.cost, money(quote.cost));
row.buy.disabled = state.funds < quote.cost;
}
}
function updateUpgrades(state) {
const visible = ctx.upgrades.filter((u) => upgradeVisible(state, u)).sort((a, b) => a.cost - b.cost);
const signature = visible.map((u) => u.id).join();
if (signature !== upgradeSignature) {
upgradeSignature = signature;
upgradeRows = new Map(visible.map((u) => [u.id, upgradeRow(u)]));
el.upgrades.replaceChildren(...[...upgradeRows.values()].map((row) => row.root));
el.upgradesEmpty.hidden = visible.length > 0;
}
for (const row of upgradeRows.values()) {
setText(row.cost, money(row.upgrade.cost));
row.buy.disabled = state.funds < row.upgrade.cost;
}
setText(
el.upgradeCount,
t('ui.upgradeCount', { owned: state.upgrades.length, total: ctx.upgrades.length }),
);
}
function updateMarket(state, d) {
const { fairPrice, minPrice } = ctx.rules.market;
if (document.activeElement !== el.priceInput) el.priceInput.value = state.price.toFixed(2);
if (document.activeElement !== el.priceSlider) {
el.priceSlider.value = Math.round(sliderFromPrice(Math.max(state.price, minPrice)));
}
const hintKey = state.price > fairPrice ? 'ui.hintHigh' : 'ui.hintFair';
setText(el.priceHint, t(hintKey, { fair: money(fairPrice), demand: fmt.number(d.demand) }));
const nextLevelMultiplier = ctx.rules.marketing.demandPerLevel ** (state.marketing + 1);
setText(
el.buyMarketingInfo,
t('ui.marketingInfo', {
level: state.marketing + 1,
multiplier: fmt.number(nextLevelMultiplier),
cost: money(d.marketingCost),
}),
);
el.buyMarketing.disabled = state.funds < d.marketingCost;
}
/** @param flow { income } smoothed funds per second */
function update(state, d, flow) {
setText(el.units, fmt.number(Math.floor(state.units)));
setText(el.funds, money(state.funds));
setText(el.material, fmt.number(Math.floor(state.material)));
setText(el.total, fmt.number(Math.floor(state.totalProduced)));
setText(el.rate, perSecond(d.rate));
setText(el.demand, perSecond(d.demand));
setText(el.income, t('ui.perSecond', { value: money(flow.income) }));
setText(el.makeYield, t('ui.makeYield', { amount: fmt.number(d.clickYield) }));
el.make.disabled = state.material < d.materialPerUnit;
setText(
el.buyMaterialInfo,
t('ui.batchInfo', { amount: fmt.number(d.batchSize), cost: money(d.batchCost) }),
);
el.buyMaterial.disabled = state.funds < d.batchCost;
updateMarket(state, d);
updateGenerators(state, d);
updateUpgrades(state);
}
return { rebuild, update };
}
+83
View File
@@ -0,0 +1,83 @@
{
"meta": { "name": "English" },
"ui": {
"theme": "Theme",
"language": "Language",
"settings": "Save & settings",
"close": "Close",
"overview": "Overview",
"totalProduced": "Total produced",
"production": "Production / s",
"demand": "Demand / s",
"income": "Income / s",
"manual": "Manual work",
"market": "Market",
"price": "Price",
"priceDown": "Lower the price",
"priceUp": "Raise the price",
"marketing": "Marketing",
"automation": "Automation",
"buyAmount": "Buy amount",
"max": "Max",
"upgrades": "Upgrades",
"noUpgrades": "Keep producing to unlock upgrades.",
"perSecond": "{value}/s",
"makeYield": "+{amount} per click",
"batchInfo": "+{amount} {@theme.res.material} · {cost}",
"marketingInfo": "Level {level} → demand ×{multiplier} · {cost}",
"hintFair": "At or below the fair price ({fair}) demand is at its maximum: lowering the price sells nothing more.",
"hintHigh": "Customers want {demand}/s at this price. Above the fair price ({fair}) demand drops fast.",
"owned": "Owned: {count}",
"rateEach": "{rate}/s each",
"buyN": "Buy ×{count}",
"upgradeCount": "{owned} / {total}"
},
"effect": {
"click_mult": "Manual work ×{value}",
"generator_mult": "{target} output ×{value}",
"global_mult": "All automation ×{value}",
"demand_mult": "Demand ×{value}",
"value_mult": "Sale value ×{value}",
"material_efficiency": "{@theme.res.material} goes ×{value} further",
"batch_discount": "{@theme.res.material} is {percent}% cheaper",
"auto_buy_material": "Restocks {@theme.res.material} automatically"
},
"time": {
"seconds": "{value} s",
"minutes": "{value} min",
"hoursMinutes": "{hours} h {minutes} min"
},
"settings": {
"title": "Save & settings",
"export": "Export your save",
"exportHelp": "Download the file, or copy the text. Keep it to move your progress to another browser or device.",
"downloadJson": "Download .json",
"downloadTxt": "Download .txt",
"copy": "Copy text",
"import": "Import a save",
"importHelp": "Choose a .json or .txt file, or paste the text below. It replaces the progress of the theme it was saved with.",
"importFile": "Choose a file…",
"pastePlaceholder": "Paste the exported text here",
"importText": "Import pasted text",
"danger": "Danger zone",
"reset": "Reset this theme's progress",
"resetConfirm": "Erase all progress for this theme? This cannot be undone."
},
"toast": {
"cookiesBlocked": "Cookies are blocked, so progress cannot be saved in this browser. Use Save & settings to export it.",
"saveFailed": "Could not save your progress. Export it from Save & settings.",
"welcomeBack": "Welcome back! While you were away ({duration}) you made {produced} {@theme.res.units} and earned {funds}.",
"imported": "Save imported.",
"copied": "Copied to the clipboard.",
"copyFailed": "Could not copy. Select the text and copy it manually.",
"reset": "Progress reset."
},
"error": {
"import": {
"empty": "Nothing to import.",
"invalid": "This is not a valid save.",
"wrong_game": "This file is not an Idle Clicker save.",
"unknown_theme": "This save belongs to a theme that is not installed."
}
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"meta": { "name": "Français" },
"ui": {
"theme": "Thème",
"language": "Langue",
"settings": "Sauvegarde et options",
"close": "Fermer",
"overview": "Vue d'ensemble",
"totalProduced": "Total produit",
"production": "Production / s",
"demand": "Demande / s",
"income": "Revenu / s",
"manual": "Travail manuel",
"market": "Marché",
"price": "Prix",
"priceDown": "Baisser le prix",
"priceUp": "Augmenter le prix",
"marketing": "Marketing",
"automation": "Automatisation",
"buyAmount": "Quantité d'achat",
"max": "Max",
"upgrades": "Améliorations",
"noUpgrades": "Continuez à produire pour débloquer des améliorations.",
"perSecond": "{value}/s",
"makeYield": "+{amount} par clic",
"batchInfo": "+{amount} {@theme.res.material} · {cost}",
"marketingInfo": "Niveau {level} → demande ×{multiplier} · {cost}",
"hintFair": "Au prix juste ({fair}) ou en dessous, la demande est maximale : baisser le prix ne vend rien de plus.",
"hintHigh": "Les clients veulent {demand}/s à ce prix. Au-dessus du prix juste ({fair}), la demande chute vite.",
"owned": "Possédé : {count}",
"rateEach": "{rate}/s chacun",
"buyN": "Acheter ×{count}",
"upgradeCount": "{owned} / {total}"
},
"effect": {
"click_mult": "Travail manuel ×{value}",
"generator_mult": "Production de {target} ×{value}",
"global_mult": "Toute l'automatisation ×{value}",
"demand_mult": "Demande ×{value}",
"value_mult": "Valeur de vente ×{value}",
"material_efficiency": "{@theme.res.material} : rendement ×{value}",
"batch_discount": "{@theme.res.material} : {percent} % moins cher",
"auto_buy_material": "Réapprovisionnement automatique : {@theme.res.material}"
},
"time": {
"seconds": "{value} s",
"minutes": "{value} min",
"hoursMinutes": "{hours} h {minutes} min"
},
"settings": {
"title": "Sauvegarde et options",
"export": "Exporter votre sauvegarde",
"exportHelp": "Téléchargez le fichier ou copiez le texte. Conservez-le pour retrouver votre progression sur un autre navigateur ou appareil.",
"downloadJson": "Télécharger en .json",
"downloadTxt": "Télécharger en .txt",
"copy": "Copier le texte",
"import": "Importer une sauvegarde",
"importHelp": "Choisissez un fichier .json ou .txt, ou collez le texte ci-dessous. Cela remplace la progression du thème avec lequel il a été sauvegardé.",
"importFile": "Choisir un fichier…",
"pastePlaceholder": "Collez ici le texte exporté",
"importText": "Importer le texte collé",
"danger": "Zone dangereuse",
"reset": "Réinitialiser la progression de ce thème",
"resetConfirm": "Effacer toute la progression de ce thème ? Cette action est irréversible."
},
"toast": {
"cookiesBlocked": "Les cookies sont bloqués : la progression ne peut pas être sauvegardée dans ce navigateur. Exportez-la depuis Sauvegarde et options.",
"saveFailed": "Impossible de sauvegarder votre progression. Exportez-la depuis Sauvegarde et options.",
"welcomeBack": "Bon retour ! Pendant votre absence ({duration}) — {@theme.res.units} : +{produced}, {@theme.res.funds} : +{funds}.",
"imported": "Sauvegarde importée.",
"copied": "Copié dans le presse-papiers.",
"copyFailed": "Copie impossible. Sélectionnez le texte et copiez-le manuellement.",
"reset": "Progression réinitialisée."
},
"error": {
"import": {
"empty": "Rien à importer.",
"invalid": "Ceci n'est pas une sauvegarde valide.",
"wrong_game": "Ce fichier n'est pas une sauvegarde d'Idle Clicker.",
"unknown_theme": "Cette sauvegarde appartient à un thème qui n'est pas installé."
}
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"theme": {
"name": "Alien Minerals",
"title": "Xeno-Mining Colony",
"res": { "units": "Xenocrystals", "unit": "xenocrystal", "funds": "Credits", "material": "Raw ore" },
"fmt": { "funds": "{value} cr" },
"action": { "make": "Refine a crystal", "buyMaterial": "Order raw ore" },
"gen": {
"gen1": { "name": "Mining Drone", "desc": "A tiny robot that scrapes the rock face." },
"gen2": { "name": "Plasma Drill", "desc": "Melts through bedrock like butter." },
"gen3": { "name": "Orbital Extractor", "desc": "Pulls ore straight out of the crust from orbit." },
"gen4": { "name": "Asteroid Harvester", "desc": "Eats whole asteroids and refines the leftovers." },
"gen5": { "name": "Hive Swarm", "desc": "Millions of drones thinking as one." },
"gen6": { "name": "Planet Cracker", "desc": "Opens a world like a walnut." }
},
"upg": {
"click_1": "Gravity Gloves",
"click_2": "Exo-Suit",
"click_3": "Neural Link",
"g1_a": "Drone Firmware",
"g1_b": "Drone Swarm Logic",
"g2_a": "Diamond Bits",
"g2_b": "Plasma Focusing",
"g3_a": "Tether Cables",
"g3_b": "Zero-G Smelting",
"g4_a": "Mass Drivers",
"g4_b": "Autonomous Prospecting",
"g5_a": "Hive Mind Sync",
"g5_b": "Queen Directive",
"g6_a": "Tectonic Resonance",
"g6_b": "Core Shattering",
"global_1": "Quantum Diagnostics",
"global_2": "Alien Alloys",
"global_3": "Dyson Tap",
"global_4": "Spacetime Folding",
"demand_1": "Trade Beacon",
"demand_2": "Interstellar Broker",
"demand_3": "Galactic Exchange",
"demand_4": "Cosmic Monopoly",
"value_1": "Purity Certificate",
"value_2": "Rare Spectrum Cut",
"value_3": "Imperial Grade",
"mat_eff_1": "Ore Analyzer",
"mat_eff_2": "Molecular Sieve",
"mat_disc_1": "Asteroid Contract",
"mat_disc_2": "Own the Mine",
"auto_buy": "Autonomous Freighter"
}
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"theme": {
"name": "Minerais extraterrestres",
"title": "Colonie d'extraction xéno",
"res": { "units": "Xénocristaux", "unit": "xénocristal", "funds": "Crédits", "material": "Minerai brut" },
"fmt": { "funds": "{value} cr" },
"action": { "make": "Raffiner un cristal", "buyMaterial": "Commander du minerai brut" },
"gen": {
"gen1": { "name": "Drone mineur", "desc": "Un minuscule robot qui gratte la roche." },
"gen2": { "name": "Foreuse à plasma", "desc": "Fond la roche comme du beurre." },
"gen3": { "name": "Extracteur orbital", "desc": "Aspire le minerai de la croûte depuis l'orbite." },
"gen4": { "name": "Moissonneur d'astéroïdes", "desc": "Avale des astéroïdes entiers et raffine les restes." },
"gen5": { "name": "Essaim-ruche", "desc": "Des millions de drones qui pensent comme un seul." },
"gen6": { "name": "Briseur de planètes", "desc": "Ouvre un monde comme une noix." }
},
"upg": {
"click_1": "Gants gravitationnels",
"click_2": "Exo-combinaison",
"click_3": "Lien neural",
"g1_a": "Micrologiciel des drones",
"g1_b": "Logique d'essaim",
"g2_a": "Mèches en diamant",
"g2_b": "Focalisation du plasma",
"g3_a": "Câbles d'amarrage",
"g3_b": "Fonderie en apesanteur",
"g4_a": "Accélérateurs de masse",
"g4_b": "Prospection autonome",
"g5_a": "Synchronisation de la ruche",
"g5_b": "Directive de la reine",
"g6_a": "Résonance tectonique",
"g6_b": "Fracture du noyau",
"global_1": "Diagnostics quantiques",
"global_2": "Alliages extraterrestres",
"global_3": "Prise sur sphère de Dyson",
"global_4": "Pliage de l'espace-temps",
"demand_1": "Balise commerciale",
"demand_2": "Courtier interstellaire",
"demand_3": "Bourse galactique",
"demand_4": "Monopole cosmique",
"value_1": "Certificat de pureté",
"value_2": "Taille spectre rare",
"value_3": "Qualité impériale",
"mat_eff_1": "Analyseur de minerai",
"mat_eff_2": "Tamis moléculaire",
"mat_disc_1": "Contrat d'astéroïde",
"mat_disc_2": "Posséder la mine",
"auto_buy": "Cargo autonome"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
/* Alien minerals: a cold, glowing mining colony. */
:root[data-theme='alien'] {
color-scheme: dark;
--bg: #06161a;
--surface: #0c2429;
--surface-2: #12343b;
--text: #dffaf3;
--text-muted: #8dbbb3;
--border: #1f4a52;
--accent: #2de2a6;
--accent-contrast: #032119;
--danger: #ff6b6b;
--radius: 4px;
--font: ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace;
}
+4
View File
@@ -0,0 +1,4 @@
{
"order": 3,
"assets": {}
}
+50
View File
@@ -0,0 +1,50 @@
{
"theme": {
"name": "Paperclips",
"title": "Paperclip Factory",
"res": { "units": "Paperclips", "unit": "paperclip", "funds": "Funds", "material": "Wire" },
"fmt": { "funds": "${value}" },
"action": { "make": "Make a paperclip", "buyMaterial": "Buy wire" },
"gen": {
"gen1": { "name": "Autoclipper", "desc": "A small machine that bends wire on its own." },
"gen2": { "name": "MegaClipper", "desc": "Ten times the appetite for wire." },
"gen3": { "name": "Clip Factory", "desc": "A whole building devoted to paperclips." },
"gen4": { "name": "Hypno Drones", "desc": "They convince everyone that they need more paperclips." },
"gen5": { "name": "Probe Swarm", "desc": "Self-replicating probes fold wire across the galaxy." },
"gen6": { "name": "Matter Converter", "desc": "Turns anything into paperclips. Anything." }
},
"upg": {
"click_1": "Ergonomic Bender",
"click_2": "Pneumatic Grip",
"click_3": "Robotic Arm",
"g1_a": "Sharper Autoclippers",
"g1_b": "Overclocked Autoclippers",
"g2_a": "MegaClipper Servos",
"g2_b": "MegaClipper Turbines",
"g3_a": "Assembly Line",
"g3_b": "Just-in-Time Logistics",
"g4_a": "Hypno Harmonics",
"g4_b": "Persuasion Algorithms",
"g5_a": "Probe Self-Replication",
"g5_b": "Von Neumann Protocols",
"g6_a": "Atomic Reassembly",
"g6_b": "Universal Reformatting",
"global_1": "Quality Control",
"global_2": "Lean Manufacturing",
"global_3": "Industrial AI",
"global_4": "Quantum Scheduling",
"demand_1": "Newspaper Ad",
"demand_2": "Radio Campaign",
"demand_3": "Viral Video",
"demand_4": "Global Brand",
"value_1": "Gold-Plated Paperclips",
"value_2": "Premium Packaging",
"value_3": "Luxury Branding",
"mat_eff_1": "Thinner Wire",
"mat_eff_2": "Wire Recycling",
"mat_disc_1": "Bulk Wire Contract",
"mat_disc_2": "Direct From the Mill",
"auto_buy": "WireBuyer"
}
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"theme": {
"name": "Trombones",
"title": "Usine à trombones",
"res": { "units": "Trombones", "unit": "trombone", "funds": "Fonds", "material": "Fil" },
"fmt": { "funds": "{value} $" },
"action": { "make": "Fabriquer un trombone", "buyMaterial": "Acheter du fil" },
"gen": {
"gen1": { "name": "Autotrombone", "desc": "Une petite machine qui plie le fil toute seule." },
"gen2": { "name": "MégaTrombone", "desc": "Dix fois plus gourmande en fil." },
"gen3": { "name": "Usine à trombones", "desc": "Un bâtiment entier dédié aux trombones." },
"gen4": { "name": "Drones hypnotiques", "desc": "Ils convainquent tout le monde d'avoir besoin de trombones." },
"gen5": { "name": "Essaim de sondes", "desc": "Des sondes auto-répliquantes plient du fil à travers la galaxie." },
"gen6": { "name": "Convertisseur de matière", "desc": "Transforme n'importe quoi en trombones. N'importe quoi." }
},
"upg": {
"click_1": "Plieuse ergonomique",
"click_2": "Pince pneumatique",
"click_3": "Bras robotisé",
"g1_a": "Autotrombones affûtés",
"g1_b": "Autotrombones survoltés",
"g2_a": "Servomoteurs MégaTrombone",
"g2_b": "Turbines MégaTrombone",
"g3_a": "Chaîne de montage",
"g3_b": "Logistique en flux tendu",
"g4_a": "Harmoniques hypnotiques",
"g4_b": "Algorithmes de persuasion",
"g5_a": "Auto-réplication des sondes",
"g5_b": "Protocoles de von Neumann",
"g6_a": "Réassemblage atomique",
"g6_b": "Reformatage universel",
"global_1": "Contrôle qualité",
"global_2": "Production allégée",
"global_3": "IA industrielle",
"global_4": "Ordonnancement quantique",
"demand_1": "Annonce dans le journal",
"demand_2": "Campagne radio",
"demand_3": "Vidéo virale",
"demand_4": "Marque mondiale",
"value_1": "Trombones plaqués or",
"value_2": "Emballage premium",
"value_3": "Image de luxe",
"mat_eff_1": "Fil plus fin",
"mat_eff_2": "Recyclage du fil",
"mat_disc_1": "Contrat de fil en gros",
"mat_disc_2": "Directement de la fonderie",
"auto_buy": "AcheteurDeFil"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
/* Paperclip: a bright, tidy office. */
:root[data-theme='paperclip'] {
color-scheme: light;
--bg: #eef1f5;
--surface: #ffffff;
--surface-2: #e8ecf2;
--text: #1c2433;
--text-muted: #566074;
--border: #d2d8e2;
--accent: #1e6fd9;
--accent-contrast: #ffffff;
--danger: #c0392b;
--radius: 8px;
--font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
+4
View File
@@ -0,0 +1,4 @@
{
"order": 1,
"assets": {}
}
+50
View File
@@ -0,0 +1,50 @@
{
"theme": {
"name": "Witch Potions",
"title": "Hedge Witch Brewery",
"res": { "units": "Potions", "unit": "potion", "funds": "Gold", "material": "Herbs" },
"fmt": { "funds": "{value} gold" },
"action": { "make": "Brew a potion", "buyMaterial": "Gather herbs" },
"gen": {
"gen1": { "name": "Apprentice", "desc": "Stirs, sweeps and occasionally sets things on fire." },
"gen2": { "name": "Bubbling Cauldron", "desc": "Brews day and night without complaint." },
"gen3": { "name": "Alchemy Lab", "desc": "Glassware, grimoires and a faint smell of sulphur." },
"gen4": { "name": "Broomstick Fleet", "desc": "Delivers herbs and potions faster than any cart." },
"gen5": { "name": "Moonlit Coven", "desc": "Thirteen witches, one very large cauldron." },
"gen6": { "name": "Philosopher's Engine", "desc": "Turns lead, dreams and moonlight into potions." }
},
"upg": {
"click_1": "Steady Stirring",
"click_2": "Enchanted Ladle",
"click_3": "Familiar's Help",
"g1_a": "Apprentice Lessons",
"g1_b": "Apprentice Mastery",
"g2_a": "Copper Cauldrons",
"g2_b": "Eternal Flame",
"g3_a": "Crystal Alembics",
"g3_b": "Rune-Etched Tables",
"g4_a": "Waxed Brooms",
"g4_b": "Hex-Powered Flight",
"g5_a": "Full Moon Rites",
"g5_b": "Blood Moon Pact",
"g6_a": "Transmutation Circles",
"g6_b": "The Fifth Element",
"global_1": "Spellbook Index",
"global_2": "Grandmother's Grimoire",
"global_3": "Ley Line Tap",
"global_4": "Time Dilation Charm",
"demand_1": "Village Notice Board",
"demand_2": "Market Stall",
"demand_3": "Royal Commission",
"demand_4": "Legend of the Witch",
"value_1": "Glittering Labels",
"value_2": "Apothecary Seal",
"value_3": "Royal Warrant",
"mat_eff_1": "Careful Pruning",
"mat_eff_2": "Distilled Essence",
"mat_disc_1": "Forager's Pact",
"mat_disc_2": "Enchanted Garden",
"auto_buy": "Self-Gathering Basket"
}
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"theme": {
"name": "Potions de sorcière",
"title": "Brasserie de la sorcière",
"res": { "units": "Potions", "unit": "potion", "funds": "Or", "material": "Herbes" },
"fmt": { "funds": "{value} or" },
"action": { "make": "Concocter une potion", "buyMaterial": "Cueillir des herbes" },
"gen": {
"gen1": { "name": "Apprentie", "desc": "Remue, balaie et met parfois le feu à quelque chose." },
"gen2": { "name": "Chaudron bouillonnant", "desc": "Infuse jour et nuit sans se plaindre." },
"gen3": { "name": "Laboratoire d'alchimie", "desc": "Verrerie, grimoires et une légère odeur de soufre." },
"gen4": { "name": "Escadrille de balais", "desc": "Livre herbes et potions plus vite que n'importe quelle charrette." },
"gen5": { "name": "Sabbat sous la lune", "desc": "Treize sorcières, un seul très grand chaudron." },
"gen6": { "name": "Machine du philosophe", "desc": "Transforme plomb, rêves et clair de lune en potions." }
},
"upg": {
"click_1": "Brassage régulier",
"click_2": "Louche enchantée",
"click_3": "Coup de patte du familier",
"g1_a": "Leçons d'apprentie",
"g1_b": "Maîtrise d'apprentie",
"g2_a": "Chaudrons de cuivre",
"g2_b": "Flamme éternelle",
"g3_a": "Alambics de cristal",
"g3_b": "Tables gravées de runes",
"g4_a": "Balais cirés",
"g4_b": "Vol propulsé par maléfice",
"g5_a": "Rites de pleine lune",
"g5_b": "Pacte de la lune de sang",
"g6_a": "Cercles de transmutation",
"g6_b": "La cinquième essence",
"global_1": "Index du grimoire",
"global_2": "Grimoire de grand-mère",
"global_3": "Prise sur ligne tellurique",
"global_4": "Charme de dilatation du temps",
"demand_1": "Panneau du village",
"demand_2": "Étal au marché",
"demand_3": "Commande royale",
"demand_4": "Légende de la sorcière",
"value_1": "Étiquettes scintillantes",
"value_2": "Sceau d'apothicaire",
"value_3": "Brevet royal",
"mat_eff_1": "Taille soigneuse",
"mat_eff_2": "Essence distillée",
"mat_disc_1": "Pacte des cueilleurs",
"mat_disc_2": "Jardin enchanté",
"auto_buy": "Panier qui cueille tout seul"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
/* Witch potions: a moonlit cottage. */
:root[data-theme='witch'] {
color-scheme: dark;
--bg: #17101f;
--surface: #221631;
--surface-2: #2e1f42;
--text: #efe7f7;
--text-muted: #b3a5c9;
--border: #43305d;
--accent: #9333ea;
--accent-contrast: #ffffff;
--danger: #e0526a;
--radius: 14px;
--font: Georgia, 'Times New Roman', serif;
}
+4
View File
@@ -0,0 +1,4 @@
{
"order": 2,
"assets": {}
}