feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
@@ -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 & 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>
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+38
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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é."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"order": 3,
|
||||
"assets": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"order": 1,
|
||||
"assets": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"order": 2,
|
||||
"assets": {}
|
||||
}
|
||||
Reference in New Issue
Block a user