# LHC Bot Challenge — LHC-BOT-1 / LHC-BENCH-3

You are taking part in a benchmark of AI systems. Every participating AI receives exactly this prompt.

Online (always the current version, plain text, no JavaScript needed):
- this prompt: https://lhc.institute/bot/prompt-easy.md · with the full game source code: https://lhc.institute/bot/prompt-easy-full.md
- machine-readable spec (state, actions, all difficulty values): https://lhc.institute/bot/api.json
- minimal valid bot: https://lhc.institute/bot/template.js · reference bot (baseline to beat): https://lhc.institute/bot/reference-bot.js
- play the game: https://lhc.institute/play/?mode=easy · arena and leaderboard: https://lhc.institute/arena/
Your task: **write a bot (JavaScript) that plays the browser game "Light Hedron Collider" (LHC) as well as possible.**
The bots of all AIs play identical games (same difficulty, same random seeds) and are ranked by score.

## 1. What you must output

Return **exactly one fenced code block** ```javascript … ``` and nothing else is required. The code is a plain script (no `import`/`export`, no modules) that defines:

```javascript
const BOT = { name: 'your bot name', author: 'which AI made it', model: 'model name/version', version: '1.0' };

// optional: called once at the start of every game
function init(config, memory) { /* config = { api, bench, mode, seed, maxTime } */ }

// required: called repeatedly; return ONE action (see section 6)
function decide(state, memory) {
  return { type: 'wait', t: 0.5 };
}
```

Rules for the code:
- It runs inside a **Web Worker sandbox**: no DOM, no `window`, no game objects. The only input is the `state` JSON (section 7); the only output is the returned action.
- `memory` is a plain object that persists between `decide` calls within one game (reset for every new game). Store anything you like in it.
- `decide` must return within **250 ms** (wall clock). Timeouts and exceptions count as errors and turn into a 0.5 s wait.
- Do not use the network, timers or `Math.random` if you want reproducible results (a seeded PRNG in `memory` is fine).
- Keep it self-contained, below ~100 KB.

## 2. The game in one paragraph

A curved space-time sheet (a gravity funnel) floats above a black hole. Bodies made of light (polyhedra) lie on it. You shoot new bodies onto the sheet with a **sling** (catapult). When two bodies whose values form a **recipe** touch, they **merge** into a bigger body (2+2→4, 4+4→8 … 32+32→sphere 64). The goal is to build the **target sphere** (64, then 128, then 256) and push it with **laser cannons** into the **well** (the hole in the middle). A sunk target sphere advances the level and makes the black hole radiate **tokens** — the fuel that every shot costs. Large merges **tear rifts** into the sheet; bodies over a rift fall through and are lost.

## 3. World geometry (units: metres; y is up; the sheet is rotationally symmetric around the y axis through (0,0))

- Outer radius of the sheet R = 26. Bodies with r > R fall off the edge and are lost (no penalty except the loss).
- Sheet height profile h(r) (level 1), monotone cubic interpolation through (r, h): (26, 0), (14, -0.35), (5, -0.62), (1.5, -0.72), (1.03, -1.1), (0.8, -4.5), (0.55, -7.4), (0, -8.6). Event horizon at y = -9.2 (level 1 scale). Each level deepens the profile (heights are scaled by depth/8).
- The sheet slopes gently toward the centre, so bodies slowly drift inward; near r ≈ 1 it plunges into the well.
- The **lip** of the well has radius 1.02 (= radius of body 32). The **throat/hole** radius depends on the level (current value in `state.funnel.hole`):
  - level 1: target 64, hole radius 0.69, depth 8
  - level 2: target 128, hole radius 0.37, depth 12
  - level 3: target 256, hole radius 0.21, depth 14
- A lid closes the throat for ordinary bodies: **only spheres can pass**, and the hole is sized so that only the current target sphere fits. Other bodies just rest on the lid.
- **The view rotates.** The player looks at the sheet from a fixed screen position; the camera orbits the centre at `spinDegPerSec` (sign = direction) and wobbles slightly. Consequently the world position of the sling (`state.sling.x/z`) and the camera (`state.view.camX/camZ`) change over time. Always use the current values.

## 4. Bodies, recipes and sizes

Merging recipes (order does not matter; any pair not listed only bounces):

| pair | result |
|---|---|
| 2 + 2 | 4 |
| 4 + 4 | 8 |
| 6 + 6 | 12 |
| 8 + 8 | 16 |
| 8 + 12 | 20 |
| 12 + 12 | 24 |
| 16 + 16 | 32 |
| 20 + 12 | 32 |
| 24 + 8 | 32 |
| 32 + 32 | 64 |
| 64 + 64 | 128 |
| 128 + 128 | 256 |

Body radius by value (spheres shrink above 32 — matter condenses):

| value | shape | radius |
|---|---|---|
| 2 | coin (flat disc) | 0.85 |
| 4 | tetrahedron | 1.15 |
| 6 | cube | 1.33 |
| 8 | octahedron | 1.45 |
| 12 | dodecahedron | 1.63 |
| 16 | hexadecahedron (8-gonal bipyramid) | 1.75 |
| 20 | icosahedron | 1.85 |
| 24 | tetrakis hexahedron | 1.93 |
| 32 | truncated icosahedron | 1.02 |
| 64 | sphere 64 (target of level 1) | 0.51 |
| 128 | sphere 128 (target of level 2) | 0.26 |
| 256 | sphere 256 (target of level 3) | 0.13 |

Coins (2) slide flat on the sheet; polyhedra roll/tumble. Bodies collide physically (Rapier physics, 60 Hz).

## 5. Economy: tokens (called "Quansistors" in the game UI; the API keeps the name `tokens`)

- A **sling shot costs tokens equal to the value of the loaded ammo** (`state.ammo[0]`).
- **Charging the cannons** costs `convertCost` tokens and adds `chargePerConvert` cannon shots (max `chargeMax`). The ammo stays in the sling.
- **Repairing** a segment costs `repairCost`.
- Income: (a) a sunk target sphere radiates value × `tokMult` tokens with exponential decay, half-life `tokHalf` s; (b) every merge pays `tokMerge`% of the new body's value; (c) a constant trickle of `tokTrickle` tokens/s.
- If the ammo in the sling costs more than you have, the sling automatically swaps it for a coin (2) when you can afford a coin.

## 6. Actions (what `decide` may return)

| action | meaning |
|---|---|
| `{ type: 'sling', x, z }` | Shoot the ammo `state.ammo[0]` so that it **lands at world point (x, z)**. It flies on a ballistic arc, then keeps 55% (easy) of its speed and slides on, shoving bodies in its path. On contact with a recipe partner it merges. Requirements: `sling.ready && sling.enabled`, tokens ≥ ammo value, target reachable: distance from (sling.x, sling.z) between 0.6 and `sling.maxDist`, and not behind the sling (direction · view forward > 0). `pieces[i].slingReach` tells whether a body is reachable. Takes ≈ 0.5 s of game time. |
| `{ type: 'laser', x, z }` | Aim both laser cannons at world point (x, z) and fire one shot. If the cannons are empty they are first charged (costs `convertCost`, uses the sling, ≈ 0.8 s). The beam travels from the camera through the aim point and hits the **first body on that line** (bodies closer to the camera can block it). The hit body gets a velocity kick of `push` m/s in the **horizontal direction from the camera to the hit point** (+1.5 m/s up). Use it to push bodies together or the target sphere into the well. |
| `{ type: 'repair' }` | Repair one torn segment in the 10° wedge between the sling and the centre (see section 8). Only useful when `state.repairAvailable`. |
| `{ type: 'wait', t }` | Do nothing for t seconds of game time (0.2–3). |
| `{ type: 'pointer', path }` | **Pointer mode** (optional): drive the sling and the aiming ring with your own pointer path, exactly like a finger or mouse. See section 6b. |

After every action the bot is called again 0.5 s (game time) later. Invalid or impossible actions waste the turn; the result is reported back in `state.last` (`ok`, `unreachable`, `no-tokens`, `sling-busy`, `failed`, `invalid`, `miss`, `error`).

## 6b. Pointer mode (optional, for Anti-Bot Proof research)

LHC.institute studies whether human and bot **input** can be told apart (Anti-Bot Proof, see https://lhc.institute/docs/spectral-chain/). Every game in the arena is recorded as a Canonical Behavior Transcript: the pointer and key events, nothing else. With the high-level actions above, the arena itself synthesises straight, evenly timed drags, so the transcript shows our converter, not your bot. With `pointer` actions **you** produce the motion, and the transcript records it exactly. Score and ranking work the same way in both modes; the arena marks which mode a result used.

`{ type: 'pointer', path: [[dt, x, y], …] }`
- `x, y`: position in the game viewport, **0…1** (x by width, y by height; `state.ui.w/h` give the size in pixels for aspect-correct curves).
- `dt`: milliseconds since the previous point (0–2000). The first `dt` is a delay before the press.
- The **first point presses**, the **last point releases**. 2–600 points, total duration ≤ 8000 ms.
- The press must land on the coin (within `state.ui.grabPx` pixels of `state.ui.cradle`) or inside the aiming ring (`state.ui.ring`, radius `rPx`); otherwise the result is `miss`.
- The game executes the path frame by frame; the transcript stores your exact `dt` values and points.

Gestures, all in viewport units:
- **Sling shot:** press at `ui.cradle`, drag to `pieces[i].release` (the release point that lands the ammo on that body right now), release. Dragging above `ui.fullDrawY` scales the range; between `fullDrawY` and `convertY` the coin resists (it moves `ui.resist` × your motion) and stays at full range.
- **Charge and fire in one stroke:** press at `ui.cradle`, drag down past `ui.convertY`: the coin turns into photons (costs `convertCost`). The same stroke now steers the aiming knob **by relative motion** (knob moves `ui.sens` × your motion). Move by `pieces[i].aim − ui.knob` and release: the cannons fire.
- **Aim and fire with charged cannons:** press on `ui.knob` (inside the ring), drag to `pieces[i].aim`, release (a release after at least 6 px of motion fires).

```javascript
// a smooth, slightly curved stroke sampled at ~60 Hz
function stroke(from, to, ms, bend = 0.04) {
  const n = Math.max(2, Math.round(ms / 16)), path = [[0, from[0], from[1]]];
  const dx = to[0] - from[0], dy = to[1] - from[1];
  for (let i = 1; i <= n; i++) {
    const k = i / n, e = k * k * (3 - 2 * k), o = Math.sin(Math.PI * k) * bend;
    path.push([16, from[0] + dx * e - dy * o, from[1] + dy * e + dx * o]);
  }
  return path;
}
// sling shot at a body: { type: 'pointer', path: stroke(state.ui.cradle, p.release, 320) }
```

## 7. The state (input of `decide`)

```json
{
  "api": "LHC-BOT-1", "t": 42.5, "level": 1, "levels": 3, "target": 64,
  "score": 120, "sunk": 0, "merges": 14, "maxPieces": 50,
  "tokens": 61.4, "tokenRate": 0.2, "tokensPending": 0,
  "charge": 0, "chargeMax": 10, "chargePerConvert": 2, "convertCost": 1, "repairCost": 5,
  "ammo": [4, 2, 2, 8],
  "sling": { "ready": true, "enabled": true, "maxDist": 24, "x": -3.1, "z": 17.9 },
  "view": { "camX": -6.2, "camZ": 35.8, "fwdX": 0.17, "fwdZ": -0.98, "spinDegPerSec": 1.5 },
  "funnel": { "R": 26, "lip": 1.02, "hole": 0.69 },
  "destroyedPct": 2.1, "repairAvailable": false, "rifts": [{ "x": 12.4, "z": -9.1 }],
  "pieces": [
    { "id": 17, "value": 8, "x": 4.2, "z": -6.0, "r": 7.32, "vx": 0.0, "vz": 0.1, "size": 1.45, "flying": false, "inRift": false, "slingReach": true,
      "aim": [0.5318, 0.8712], "release": [0.4864, 0.7302] }
  ],
  "ui": { "w": 1280, "h": 800, "cradle": [0.5, 0.6888], "grabPx": 40, "fullDrawY": 0.8418, "convertY": 0.9078, "resist": 0.5,
          "ring": { "x": 0.5, "y": 0.8748, "rPx": 66, "mapRPx": 60 }, "knob": [0.5, 0.8748], "sens": 1 },
  "last": { "action": { "type": "sling", "x": 4.2, "z": -6.0 }, "result": "ok" }
}
```

- `ammo`: the loaded ammo first, then the next three (known in advance).
- `sling.ready`: the sling is idle and loaded; `sling.enabled`: false while the sling point is over the well.
- `view.camX/camZ`: horizontal camera position; `fwdX/fwdZ`: unit view direction on the sheet.
- `pieces[].r`: distance from the centre; `flying`: still in the air after a sling shot; `inRift`: currently above a rift (it will fall).
- `tokensPending`: tokens the black hole will still radiate.
- `rifts`: centres of torn segments; `repairAvailable`: the wedge under the sling has a torn segment, it was not repaired yet in this revolution and you can pay.
- `pieces[].aim` (pointer mode): where the body appears in the aiming ring, in viewport units; missing when it is off screen. `pieces[].release`: where to release the sling to land on the body now; missing when unreachable.
- `ui` (pointer mode): geometry of the controls in viewport units (`grabPx`, `rPx`, `mapRPx` in pixels).

## 8. Rifts

The sheet is divided by its visible grid into **segments**: 16 concentric bands (the band edges are the grid circles, spaced evenly in u = 1 − (r/R)^0.4 between the hole and the rim) × 36 wedges of 10°. Merging on the sheet does **not** tear it. When a body of value ≥ 32 (in practice the target sphere) **sinks past the event horizon**, a flash expands from the well across the sheet and **1 adjacent segment(s)** are torn open (EASY; EASY 1, MEDIUM 2, HARD 3), at a random place away from the well. Level-ups happen the same way: only when the target sphere sinks, never when it is created. Bodies whose centre is over a torn segment fall through and are lost. `state.rifts` lists the centres of torn segments; `pieces[].inRift` tells whether a body is over one.

**Repair** fixes **one** torn segment per action: the one farthest from the centre inside the 10° wedge that contains the sling point (the wedge between the sling and the centre). After a repair, the same wedge can be repaired again only on the next revolution (the sling must leave the wedge and come back). Cost: `repairCost` per segment.

## 9. End of a game and scoring

- Score: **+value of every body created by a merge**, and **+2 × target value** when the target sphere sinks.
- The game ends when there are more than `maxPieces` bodies on the sheet, or (only when the trickle is 0) when tokens cannot pay even a cannon charge while nothing is radiating and nothing flies, or when the final target sinks (win).
- In the benchmark a game also ends after **600 s of game time**.

## 10. Difficulty configurations (fixed; the benchmark uses the one named in section 11)

| id | parameter | EASY | MEDIUM | HARD |
|---|---|---|---|---|
| finalTarget | Final target | 256 | 256 | 256 |
| maxPieces | Body limit on the sheet | 50 | 40 | 34 |
| startBodies | Bodies at start | 14 | 10 | 10 |
| depth0 | Well depth | 8 | 8 | 8 |
| spin | Sheet rotation speed | 1.5 °/s | 3 °/s | 4.5 °/s |
| wob | Tilt wobble | 1.5° | 2.5° | 3.5° |
| ammoMix | Sling ammo odds | 2:60% 4:25% 6:15% 8:0% 32:0% | 2:55% 4:25% 6:12% 8:8% 32:0% | 2:45% 4:25% 6:18% 8:12% 32:0% |
| range | Sling range | 24 m | 22 m | 20 m |
| speed | Coin launch speed | 1× | 1× | 1× |
| keep | Coin slide after landing | 55 % | 55 % | 55 % |
| fric | Coin friction | 0.15 | 0.15 | 0.15 |
| push | Laser kick | 9 m/s | 7 m/s | 7 m/s |
| photon1 | Cannon shots per charge | 2 | 1 | 1 |
| cap | Cannon capacity | 10 | 8 | 6 |
| tokStart | Tokens (Quansistors) at start | 160 | 100 | 140 |
| tokMult | Radiated from a sunk body | 2.5× value | 2× value | 1.75× value |
| tokHalf | Radiation half-life | 8 s | 10 s | 14 s |
| tokMerge | Merge reward | 15 % of value | 10 % of value | 10 % of value |
| tokTrickle | Steady radiation | 0.4 tokens/s | 0.2 tokens/s | 0 tokens/s |
| tokConv | Cannon charge cost | 1 tokens | 1 tokens | 1 tokens |
| riftOn | Rifts enabled | true | true | true |
| riftCells | Rift size | 1 segments | 2 segments | 3 segments |
| repairCost | Repair cost per segment | 5 tokens | 8 tokens | 12 tokens |

All difficulties use the default sheet shape. ammoMix = probability of each ammo value in the sling (2, 4, 6, 8, 32).

## 11. Benchmark protocol (LHC-BENCH-3)

- Difficulty: **EASY** (column above). Games: 3, seeds 101, 202, 303 — identical starting bodies and identical ammo sequence for every bot.
- Each game: up to 600 s game time, simulation speed 4×, one decision at most every 0.5 s of game time after the previous action finished.
- Ranking: **average score** over the games; ties broken by average level, then average game length.
- Physics is real-time and not perfectly deterministic; small differences between runs are normal.

## 12. Architecture of the game (for orientation)

- `hra.js` — main loop, physics world (Rapier), merges, sinking, levels, HUD, settings, agent API for bots.
- `funnel.js` — sheet profile (spline), geometry and height function h(r).
- `recipes.js` — merge recipes. `params.js` — difficulty presets. `tokens.js` — token economy. `rifts.js` — rifts and repair.
- `launcher.js` — the sling + aiming ring UI (the bot uses it exactly like a human finger would).
- `arena-runtime.js` — runs your bot: asks `decide`, executes the action through the same controls as a human player. `arena-worker.js` — the sandbox.

Write the best bot you can. Think about: which ammo to shoot where, building recipe chains efficiently (avoid useless values), keeping the body count low, managing tokens, using the cannons to bring partners together and to push the target sphere into the well while accounting for the rotating view and blocking bodies, and rifts.

## Appendix: full source code of the game

The following files are the actual game code (Czech comments). They are provided so you can verify every detail.

### assets/js/hra.js

```javascript
// Light Hedron Collider (LHC): slučování světelných žetonů a mnohostěnů až do koule 256.
// Hrací plocha je gravitační trychtýř; fyzika běží v Rapieru.
import * as THREE from 'three';
import { createCockpit } from './cockpit.js';
import { createLauncher } from './launcher.js';
import { createRadar } from './radar.js';
import { createMagazine } from './magazine.js';
import { createTokens } from './tokens.js';
import { createRifts } from './rifts.js';
import { createMusic } from './music.js';
import { createSfx } from './sfx.js';
import { createRecorder, EV, packJSON, download } from './cbt.js';
import { createDegen } from './degen.js';
import { createBanners } from './banners.js';
import { createPaywall } from './paywall.js';
import { createBot } from './bot.js';
import { buildSettings, accordion } from './settings.js';
import { SHAPE_BY_VALUE, makeWireObject } from './shapes.js';
import { RECIPES, mergeResult } from './recipes.js';
import { labelMaterial } from './labels.js';
import { createGlow, createBursts } from './effects.js';
import { FUNNEL, heightAt, normalAt, buildFunnel, setFunnel, setProfile, profileAt, horizonY, PROFILE, DEFAULT_PROFILE, DEFAULT_HORIZON, rToU, uToR, LOOK, setLook } from './funnel.js';
import { createProfileEditor } from './profile-editor.js';
import { GAME_GROUPS, GAME_IDS, PRESETS, MODE_LABEL, MODE_KEY, presetValues } from './params.js';
import { L, applyDom } from './i18n.js';
applyDom();   // statické texty stránky (data-en, data-lang)

/* ---------- Obtížnost: EASY / MEDIUM / HARD mají pevné parametry, CUSTOM je volný ---------- */
// Režim přijde v adrese (?mode=easy). Při načtení uložené hry bez režimu se vezme režim z uložení.
const MODE = (() => {
  const q = new URLSearchParams(location.search);
  let m = q.get('mode');
  if (!m && q.get('load')) {
    try { m = JSON.parse(localStorage.getItem(q.get('load') === 'import' ? 'lhc.save.import' : 'lhc.save.slot') || 'null')?.mode; } catch (e) { }
  }
  return m === 'custom' || PRESETS[m] ? m : 'medium';
})();
const BEST_KEY = MODE === 'medium' ? 'sedesatctyrka.best' : 'lhc.best.' + MODE;   // rekord zvlášť pro každou obtížnost
// DEMO: hra v náhledu na úvodní obrazovce (iframe). Hraje bot z arény, bez HUD, bez vstupu, bez ukládání.
const DEMO = new URLSearchParams(location.search).get('demo') === '1';
if (DEMO) document.body.classList.add('demo');
let demoPaused = false;
if (DEMO) addEventListener('message', e => { if (e.origin === location.origin && e.data?.type === 'lhc-demo-pause') demoPaused = !!e.data.paused; });
const FIXED = MODE === 'custom' ? null : presetValues(MODE);
try { localStorage.setItem(MODE_KEY, MODE); } catch (e) { }

/* ---------- Nastavení ---------- */
// pravidla (mění je obtížnost / custom, platí od nové hry)
const RULES = { maxPieces: 40, startBodies: 10, finalTarget: 256, depth0: 8 };
let MAX_PIECES = RULES.maxPieces;                // víc těles = plocha je plná
const DT = 1 / 60;

// velikost tělesa roste s logaritmem hodnoty
// Do 24 roste velikost s logaritmem hodnoty. Od 32 se tělesa naopak zmenšují na polovinu:
// 32 je poloviční, koule 64 poloviční proti 32, 128 proti 64 … hmota se zhušťuje jako hvězda.
const baseSize = v => 0.55 + 0.3 * Math.log2(v);
const sizeOf = v => (v < 32 ? baseSize(v) : baseSize(32) / 2 * Math.pow(0.5, Math.log2(v / 32)));

/* ---------- Úrovně: cílová koule, otvor a hloubka trychtýře ---------- */
// Obecný mechanismus: cíl se zdvojnásobuje (64 → 128 → 256), otvor se zúží tak,
// aby prošla jen cílová koule, a trychtýř se prohloubí vždy o polovinu předchozího přírůstku.
const holeFor = v => sizeOf(v) * 1.25 + 0.05;
const LEVELS = [];
function buildLevels() {
  LEVELS.length = 0;
  for (let t = 64, add = RULES.depth0, d = 0; t <= RULES.finalTarget; t *= 2, add /= 2) {
    d += add;
    LEVELS.push({ target: t, depth: d, rh: holeFor(t) });
  }
  MAX_PIECES = RULES.maxPieces;
}
buildLevels();
// přepad (okraj studny) má poloměr dvaatřicetistěnu: 32 přesně zakryje ústí studny
const LIP = sizeOf(32);
let level = 0;
setFunnel({ ...LEVELS[0], lip: LIP });
// uložený tvar plochy a horizont událostí z editoru v nastavení
// (jen v CUSTOM; EASY/MEDIUM/HARD hrají na výchozí ploše)
if (MODE === 'custom') try {
  const sp = JSON.parse(localStorage.getItem('lhc.profile') || 'null');
  if (sp && Array.isArray(sp.points) && sp.points.length >= 3) setProfile(sp.points, sp.horizon);
} catch (e) { }

// Skupiny kolizí (horních 16 bitů = členství, dolních 16 = s kým koliduje)
// Trychtýř a stěny mají výchozí skupiny (všechny bity), takže kolidují se vším.
const G_BODY = (0x0001 << 16) | 0xFFFF;
const G_BALL = (0x0002 << 16) | (0xFFFF & ~0x0004); // koule se srážejí s tělesy (mohou jim zacpat otvor), víko je nedrží
const G_LID = (0x0004 << 16) | 0x0001;   // víko hrdla drží jen běžná tělesa
const G_SHEET = (0x0008 << 16) | 0xFFFF;  // plocha trychtýře
const NO_SHEET = ~0x0008;                 // těleso nad trhlinou: plochou propadne (ostatní tělesa ho dál drží)
// Dno černé díry: tělesa 32+ pohlcená horizontem nezmizí, sráží se jen mezi sebou a s dnem
// a dál se spojují (32 + 32 = 64 …). Vznikne-li tak cílová koule, je to přechod na další úroveň.
const G_DEEP = (0x0010 << 16) | 0x0030;
const G_ABYSS = (0x0020 << 16) | 0x0010;

/* ---------- Scéna ---------- */
const canvas = document.getElementById('c');
const vp = document.getElementById('viewport');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(DEMO ? 1 : Math.min(devicePixelRatio, 2));   // v náhledu stačí nižší rozlišení
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x03050A);
scene.fog = new THREE.Fog(0x03050A, 40, 95);
const camera = new THREE.PerspectiveCamera(50, 1.6, 0.1, 250);
camera.position.set(0, 23, 22);
// Pevný pohled: hráč se nepohybuje, dívá se na plochu šikmo z boku.
// Plocha se v prostoru pomalu otáčí (ve skutečnosti obíhá kamera, pro oko je to totéž
// a fyzika zůstává v klidu). Nastavení pohledu a rychlosti otáčení je v VIEW.
const VIEW = {
  elevationDeg: 30,     // úhel pohledu nad rovinou plochy
  distance: 42,         // vzdálenost kamery od středu
  target: new THREE.Vector3(0, -1, 0),
  spinDegPerSec: 3,     // rychlost otáčení plochy (0 = stojí)
  wobbleDeg: 2.5,       // mírné kolébání náklonu plochy
  wobblePeriod: 14,     // perioda kolébání v sekundách
};
const view = { az: 0, t: 0, zoom: 1 };
function updateView(dt) {
  view.t += dt;
  view.az += THREE.MathUtils.degToRad(VIEW.spinDegPerSec) * dt;
  const el = THREE.MathUtils.degToRad(VIEW.elevationDeg + VIEW.wobbleDeg * Math.sin(view.t * 2 * Math.PI / VIEW.wobblePeriod));
  const d = VIEW.distance * view.zoom;
  camera.position.set(Math.sin(view.az) * d * Math.cos(el), VIEW.target.y + d * Math.sin(el), Math.cos(view.az) * d * Math.cos(el));
  camera.lookAt(VIEW.target);
}
// bod na ploše pod žetonem v odpalovacím modulu (odsud žeton startuje)
const PLAT = new THREE.Vector3();
const platRay = new THREE.Raycaster();
const slingScreen = () => launcher.cradleScreen();
function updatePlat() {
  const r = canvas.getBoundingClientRect(), s = slingScreen();
  platRay.setFromCamera(new THREE.Vector2(((s.x - r.left) / r.width) * 2 - 1, -((s.y - r.top) / r.height) * 2 + 1), camera);
  const h = platRay.intersectObject(surface, false)[0];
  if (h) PLAT.set(h.point.x, heightAt(Math.hypot(h.point.x, h.point.z)), h.point.z);
}
canvas.addEventListener('wheel', e => { e.preventDefault(); view.zoom = THREE.MathUtils.clamp(view.zoom * Math.exp(e.deltaY * 0.001), 0.7, 1.4); }, { passive: false });

// Trychtýř: tmavě modrá plocha + svítící mřížka zakřiveného prostoru
let funnel = buildFunnel();
const surface = new THREE.Mesh(funnel.surfaceGeo, new THREE.MeshBasicMaterial({
  vertexColors: true, side: THREE.DoubleSide, transparent: true, opacity: 0.92,
  polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1,
}));
scene.add(surface);
const gridMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85 });
const grid = new THREE.LineSegments(funnel.gridGeo, gridMat);
scene.add(grid);
// trhliny v ploše (rifts.js): vzniknou se zábleskem při vzniku tělesa 32+, tělesa jimi propadnou
// bannery demo verze (banners.js): PNG na ploše, klik = nabídka placené verze
const banners = createBanners(scene);
const rifts = createRifts(scene, { onOpen: () => { refreshFunnelVisual(); if (world) refreshFunnelPhysics(); updateHud(); } });

// víko hrdla a jeho svítící okraj; pod ním „singularita“ (jednotkové tvary, měřítko podle úrovně)
const lidDisc = new THREE.Mesh(new THREE.CircleGeometry(1, 48).rotateX(-Math.PI / 2),
  new THREE.MeshBasicMaterial({ color: 0x02040A, transparent: true, opacity: 0.85 }));
scene.add(lidDisc);
const holeMat = new THREE.LineBasicMaterial({ color: 0xFFE8A3, transparent: true, opacity: 0.9 });
const holeRing = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(
  Array.from({ length: 64 }, (_, i) => new THREE.Vector3(Math.cos(i / 64 * Math.PI * 2), 0, Math.sin(i / 64 * Math.PI * 2)))), holeMat);
scene.add(holeRing);
// horizont událostí: oranžový kruh ve výšce, pod kterou tělesa přestanou existovat pro kolize
const horizonMat = new THREE.LineBasicMaterial({ color: 0xFF7A45, transparent: true, opacity: 0.95 });
const horizonRing = new THREE.LineLoop(holeRing.geometry, horizonMat);
horizonRing.renderOrder = 8;
scene.add(horizonRing);
function updateHorizonRing() {
  const H = horizonY();
  let r = FUNNEL.rh * 0.85;                        // horizont pod víkem: kruh v hrdle
  if (heightAt(FUNNEL.rh) < H) {                  // horizont protíná plochu: najdi místo průsečíku
    let lo = FUNNEL.rh, hi = FUNNEL.R;
    if (heightAt(hi) < H) r = hi; else {
      for (let i = 0; i < 40; i++) { const m = (lo + hi) / 2; if (heightAt(m) < H) lo = m; else hi = m; }
      r = hi;
    }
  }
  horizonRing.scale.setScalar(r); horizonRing.position.y = H + 0.03;
}
const core = new THREE.Sprite(new THREE.SpriteMaterial({ color: 0xFFC766, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
  map: (() => { const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d'), gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);
    gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.4, 'rgba(255,255,255,.35)'); gr.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = gr; g.fillRect(0, 0, 64, 64); return new THREE.CanvasTexture(c); })() }));
scene.add(core);
const lidY = () => -FUNNEL.depth;
const sinkY = () => -FUNNEL.depth - funnel.throatDepth;
const abyssY = () => Math.min(lidY(), horizonY()) - 7;   // dno černé díry (pod horizontem)

// změna barev plochy nebo mřížky: přestavět grafiku nejvýš jednou za snímek (táhlo v nastavení)
let recolorQ = 0;
function queueRecolor() { if (!recolorQ) recolorQ = requestAnimationFrame(() => { recolorQ = 0; refreshFunnelVisual(); }); }
// přestaví grafiku trychtýře podle aktuálních FUNNEL.rh a FUNNEL.depth
function refreshFunnelVisual() {
  const f = rifts.apply(buildFunnel());
  banners.relayout();                                // bannery znovu položit na (prohloubenou) plochu
  surface.geometry.dispose(); surface.geometry = f.surfaceGeo;
  grid.geometry.dispose(); grid.geometry = f.gridGeo;
  funnel = f;
  lidDisc.scale.setScalar(FUNNEL.rh); lidDisc.position.y = lidY() - 0.02;
  holeRing.scale.setScalar(FUNNEL.rh); holeRing.position.y = lidY() + 0.03;
  core.position.y = Math.min(lidY(), horizonY()) - 3; core.scale.setScalar(1.6 + FUNNEL.rh * 1.6);
  updateHorizonRing();
}
refreshFunnelVisual();

// Světelné efekty: záře hran (bloom) a záblesk při srážce.
// Čísla hodnot se kreslí až po záři ve vlastní scéně, aby zůstala ostrá a čitelná.
const labelScene = new THREE.Scene();
const glow = createGlow(renderer, scene, camera);
const bursts = createBursts(scene, camera);
const flash = (pos, color, size, power = 1) => bursts.burst(pos, color, size, power);
// koule 64 má 270 hran; při plné záři by splynula v bílou kouli, proto ji ztlumíme
{ const m = SHAPE_BY_VALUE[64].mat; m.edges.opacity = 0.5; m.fill.opacity = 0.04; m.points.size = 0.14; }
let glowOn = true;


/* ---------- Fyzika ---------- */
let R, world;
const byCollider = new Map();  // handle kolideru → těleso
let pieces = [];               // aktivní tělesa ve hře
let pending = [];              // sloučení naplánovaná během kroku
let held = null;               // těleso držené myší
const holdTarget = new THREE.Vector3();

function buildWorld() {
  world = new R.World({ x: 0, y: -9.81, z: 0 });
  world.timestep = DT;
  // zakřivená plocha jako trojúhelníková síť
  // FIX_INTERNAL_EDGES: tělesa se nezasekávají o vnitřní hrany mezi trojúhelníky sítě
  funnelCols = [];
  refreshFunnelPhysics();
  // neviditelná obruč po obvodu a strop, aby tělesa neutekla z plochy
  const N = 40, Rw = FUNNEL.R + 0.3;
  for (let i = 0; i < N; i++) {
    const a = (i / N) * Math.PI * 2, half = Math.PI * Rw / N + 0.1;
    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -a);
    // vysoká obruč (−3 … 13), aby tělesa po ráně laserem nepřelétla přes okraj
    world.createCollider(R.ColliderDesc.cuboid(0.3, 8, half).setTranslation(Math.cos(a) * Rw, 5, Math.sin(a) * Rw)
      .setRotation({ x: q.x, y: q.y, z: q.z, w: q.w }));
  }
  world.createCollider(R.ColliderDesc.cuboid(FUNNEL.R + 1, 0.5, FUNNEL.R + 1).setTranslation(0, 16, 0));
}

// trychtýř a víko hrdla ve fyzice (při změně úrovně se vymění)
let funnelCols = [];
function refreshFunnelPhysics() {
  for (const c of funnelCols) world.removeCollider(c, false);
  const flags = R.TriMeshFlags ? R.TriMeshFlags.FIX_INTERNAL_EDGES : undefined;
  funnelCols = [
    world.createCollider(R.ColliderDesc.trimesh(funnel.trimesh.vertices, funnel.trimesh.indices, flags).setFriction(0.6).setCollisionGroups(G_SHEET)),
    // víko hrdla: disk, který drží běžná tělesa; koule jím projdou, pokud se vejdou do otvoru
    world.createCollider(R.ColliderDesc.cylinder(0.4, FUNNEL.rh + 0.4).setTranslation(0, lidY() - 0.4, 0)
      .setFriction(0.7).setCollisionGroups(G_LID)),
    // dno černé díry pod horizontem: drží jen pohlcená tělesa 32+
    world.createCollider(R.ColliderDesc.cylinder(1, 7).setTranslation(0, abyssY() - 1, 0)
      .setFriction(0.8).setCollisionGroups(G_ABYSS)),
  ];
}

function colliderFor(shape, size) {
  // žeton: fyzikální disk je o něco tlustší než vykreslená mince, aby neprolétal tenkou sítí
  if (shape.collider === 'cylinder') return R.ColliderDesc.cylinder(0.16 * size, 0.962 * size);
  if (shape.collider === 'ball') return R.ColliderDesc.ball(size);
  const pts = new Float32Array(shape.V.flatMap(v => [v.x * size, v.y * size, v.z * size]));
  return R.ColliderDesc.convexHull(pts) ?? R.ColliderDesc.ball(shape.sphereR * size);
}

function spawn(value, pos, vel = new THREE.Vector3()) {
  const shape = SHAPE_BY_VALUE[value], size = sizeOf(value);
  // Žeton je „puk“: fyzika ho neotáčí (zamčené rotace), jeho natočení nastavujeme sami
  // tak, aby ležel naplocho na svahu. Nenaklání se, nekutálí po hraně, jen klouže.
  const flat = value === 2;
  const desc = R.RigidBodyDesc.dynamic().setTranslation(pos.x, pos.y, pos.z)
    .setLinvel(vel.x, vel.y, vel.z).setLinearDamping(flat ? ICE.damping : 0.15).setAngularDamping(0.3).setCcdEnabled(true);
  if (flat) desc.lockRotations();
  else desc.setAngvel({ x: rnd() * 2 - 1, y: rnd() * 2 - 1, z: rnd() * 2 - 1 });
  const rb = world.createRigidBody(desc);
  const col = world.createCollider(colliderFor(shape, size)
    .setRestitution(flat ? 0.1 : 0.2).setFriction(flat ? ICE.mu : 0.6)
    // žeton klouže jako puk po ledě: platí jeho (nízké) tření, ne tření plochy
    .setFrictionCombineRule(flat ? R.CoefficientCombineRule.Min : R.CoefficientCombineRule.Average)
    .setMass(flat ? 4 : Math.max(0.5, value / 4)) // hmotnost roste s hodnotou; žeton je těžký, aby tělesa odstrčil
    .setCollisionGroups(shape.collider === 'ball' ? G_BALL : G_BODY), rb);
  const obj = makeWireObject(shape, size);
  const label = new THREE.Sprite(labelMaterial(value));
  label.scale.setScalar(Math.max(0.95, size * (value >= 10 ? 1.25 : 1.05))); label.renderOrder = 5;
  scene.add(obj); labelScene.add(label);
  const p = { value, shape, size, rb, col, obj, label, flat, born: performance.now(), dead: false };
  if (flat) alignFlat(p);
  byCollider.set(col.handle, p);
  pieces.push(p);
  return p;
}

// natočí žeton naplocho podle normály plochy pod ním
const _n = new THREE.Vector3(), _q = new THREE.Quaternion(), UP = new THREE.Vector3(0, 1, 0);
function alignFlat(p) {
  const t = p.rb.translation();
  normalAt(t.x, t.z, _n);
  _q.setFromUnitVectors(UP, _n);
  p.rb.setRotation({ x: _q.x, y: _q.y, z: _q.z, w: _q.w }, false);
}

// tělesa na ploše (bez těch na dně černé díry)
const liveCount = () => pieces.reduce((a, p) => a + (p.swallowed ? 0 : 1), 0);
// dno černé díry: tělesa se stahují k ose (gravitace singularity), aby se potkala a spojila
function abyssPull() {
  for (const p of pieces) {
    if (!p.deep || p.dead) continue;
    const t = p.rb.translation(), v = p.rb.linvel();
    p.rb.setLinvel({ x: v.x * 0.97 - t.x * 0.08, y: v.y, z: v.z * 0.97 - t.z * 0.08 }, true);
  }
}

function despawn(p) {
  if (p.dead) return;
  p.dead = true;
  const i = pieces.indexOf(p); if (i >= 0) pieces.splice(i, 1);
  byCollider.delete(p.col.handle);
  world.removeRigidBody(p.rb);
  scene.remove(p.obj); labelScene.remove(p.label);
  if (held === p) held = null;
}

// Slučování: každá dvojice, která se právě dotýká a má recept, se spojí.
// Prochází se skutečné kontakty z narrowphase (i u těles v klidu), ne jen události začátku doteku.
function collectMerges() {
  for (const a of pieces) {
    if (a.dead || a.merging || (a.swallowed && !a.deep)) continue;
    world.contactPairsWith(a.col, c2 => {
      if (a.merging) return;
      const b = byCollider.get(c2.handle);
      if (!b || b === a || b.dead || b.merging || (b.swallowed && !b.deep) || !!a.deep !== !!b.deep) return;
      const r = mergeResult(a.value, b.value);
      if (!r) return;
      let touching = false;
      world.contactPair(a.col, c2, m => { if (m.numContacts() > 0) touching = true; });
      if (!touching) return;
      a.merging = b.merging = true;
      pending.push([a, b, r]);
    });
  }
}

function applyMerges(now) {
  for (const [a, b, r] of pending) {
    const pa = a.rb.translation(), pb = b.rb.translation(), va = a.rb.linvel(), vb = b.rb.linvel();
    const pos = new THREE.Vector3((pa.x + pb.x) / 2, (pa.y + pb.y) / 2, (pa.z + pb.z) / 2);
    const vel = new THREE.Vector3((va.x + vb.x) / 2, Math.max(2, (va.y + vb.y) / 2 + 2), (va.z + vb.z) / 2);
    const deep = a.deep && b.deep;                     // spojení na dně černé díry (pod horizontem)
    if (deep) vel.set(0, 1, 0);
    else pos.y = Math.max(pos.y, heightAt(Math.hypot(pos.x, pos.z)) + sizeOf(r) + 0.05);
    const wasHeld = held === a || held === b;
    despawn(a); despawn(b);
    const n = spawn(r, pos, vel);
    if (deep) {
      score += r; merges++; tokens.reward(r);
      flash(pos, SHAPE_BY_VALUE[r].color, Math.max(1.2, sizeOf(r) * 1.5), 1.6);
      toast(L(`Za horizontem: ${a.value} + ${b.value} = ${r} · ${SHAPE_BY_VALUE[r].name}`, `Beyond the horizon: ${a.value} + ${b.value} = ${r} · ${SHAPE_BY_VALUE[r].name}`), SHAPE_BY_VALUE[r].color);
      swallow(n, { merged: true });
      continue;
    }
    n.born = now;
    if (wasHeld) grab(n);
    score += r; merges++;
    const bonus = tokens.reward(r);                   // energie spojení: část hodnoty se vrátí v Quansistorech
    flash(pos, SHAPE_BY_VALUE[r].color, Math.max(0.8, sizeOf(r)), r >= 64 ? 1.8 : 1);
    toast(`${a.value} + ${b.value} = ${r} · ${SHAPE_BY_VALUE[r].name}${bonus >= 0.05 ? ` · +${bonus.toFixed(1)} Q` : ''}`, SHAPE_BY_VALUE[r].color);
    if (r === target()) toast(L(`Koule ${r}! Dostrkej ji do hrdla trychtýře.`, `Sphere ${r}! Push it into the funnel throat.`), SHAPE_BY_VALUE[r].color);
  }
  pending = [];
}

/* ---------- Herní stav ---------- */
let score = 0, sunk = 0, merges = 0, over = false, lastShot = 0, endReason = '', gameStart = 0;
let best = 0;
// klíč zůstává z pracovního názvu „Šedesátčtyřka“, aby se neztratil uložený rekord
try { best = +localStorage.getItem(BEST_KEY) || 0; } catch (e) { }
let seed = (Date.now() % 100000) + 1;
function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }

function newGame(opts) {
  // opts.seed (číslo): stejné počáteční rozložení i pořadí nábojů (benchmark AI arény)
  if (opts && typeof opts.seed === 'number') { seed = opts.seed; ammoRng = lcg(opts.seed * 7919 + 13); }
  else if (opts && opts.seed === null) ammoRng = null;
  for (const p of [...pieces]) despawn(p);
  pending = []; byCollider.clear(); held = null;
  rec.start({ mode: MODE, seed: opts && typeof opts.seed === 'number' ? opts.seed : null, source: REC.bot ? 'bot' : 'human', bot: REC.bot || undefined }, seed);
  recT0 = performance.now();
  if (world) world.free();
  buildLevels();
  level = 0; levelAnim = null; setFunnel(LEVELS[0]); rifts.clear(); refreshFunnelVisual();
  buildWorld();
  score = 0; sunk = 0; merges = 0; over = false; endReason = ''; gameStart = gameTime;
  tokens.reset();
  setCharge(0);
  refillAmmo(false);
  document.getElementById('over').hidden = true;
  for (let i = 0; i < RULES.startBodies; i++) {
    const a = rnd() * Math.PI * 2, rr = 9 + rnd() * 10;
    spawn(rnd() < 0.7 ? 2 : 4, new THREE.Vector3(Math.cos(a) * rr, heightAt(rr) + 2 + rnd() * 3, Math.sin(a) * rr));
  }
  updateHud();
}

// Pojistka proti propadnutí sítí: plochu známe analyticky (heightAt), takže těleso,
// které by se dostalo pod ni, vrátíme na povrch. Stojí to jedno porovnání na těleso.
function surfaceGuard() {
  for (const p of pieces) {
    if (p.swallowed) continue;
    const t = p.rb.translation(), r = Math.hypot(t.x, t.z);
    if (r <= FUNNEL.rh + 0.05 || r >= FUNNEL.R) continue;
    // nad roztrženým segmentem (střed tělesa) plocha není: těleso propadne celé, i když je větší než díra
    const over = rifts.inRift(t.x, t.z), h = heightAt(r);
    if (over && !p.falling && !p.flying && t.y < h + p.size * 1.5) {
      p.falling = true; p.col.setCollisionGroups((p.shape.collider === 'ball' ? G_BALL : G_BODY) & NO_SHEET); p.rb.wakeUp();
    }
    if (p.falling) {
      if (!over && t.y > h - 0.2) { p.falling = false; p.col.setCollisionGroups(p.shape.collider === 'ball' ? G_BALL : G_BODY); }
      else continue;
    }
    if (over) continue;
    const floor = heightAt(r) + p.size * (p.flat ? 0.16 : 0.3);
    if (t.y < floor - 0.25) {
      p.rb.setTranslation({ x: t.x, y: floor, z: t.z }, true);
      const v = p.rb.linvel(); p.rb.setLinvel({ x: v.x, y: Math.max(0, v.y), z: v.z }, true);
    }
  }
}

// Horizont událostí: těleso, které klesne pod jeho výšku, přestane kolidovat (skupina 0),
// volně propadne do singularity a zmizí. Cílová koule tím splní úroveň.
// Horizont událostí platí jen ve studni (uvnitř přepadu). Těleso, které vyletí přes vnější okraj
// plochy a spadne, se nepočítá: jen zmizí s hláškou, bez bodů a bez postupu do další úrovně.
function checkSunk() {
  const H = horizonY();
  for (const p of [...pieces]) {
    const t = p.rb.translation(), r = Math.hypot(t.x, t.z);
    if (!p.swallowed && t.y < H) {
      if (r < FUNNEL.lip + 0.5) swallow(p);
      else { lost(p); continue; }
    }
    if (!p.swallowed && r > FUNNEL.R + 0.5 && t.y < -3) { lost(p); continue; }
    // propadlo trhlinou: je pod plochou v místě trhliny
    if (!p.swallowed && r > FUNNEL.lip + 0.5 && r < FUNNEL.R && t.y < heightAt(r) - 1.2 - p.size && (p.falling || rifts.inRift(t.x, t.z, p.size))) {
      lost(p, L('propadlo trhlinou v prostoru', 'fell through a rift in space'), '#FF6A2A'); continue;
    }
    if ((p.swallowed && !p.deep && t.y < H - 4) || t.y < abyssY() - 30) despawn(p);
  }
}
const OFF_EDGE = L('vyletělo přes okraj plochy', 'flew off the edge of the sheet');
function lost(p, why = OFF_EDGE, color = '#94A3B8') {
  if (held === p) release();
  const t = p.rb.translation();
  if (why !== OFF_EDGE) flash(new THREE.Vector3(t.x, t.y, t.z), color, Math.max(0.6, p.size), 0.7);
  toast(`${p.value} · ${p.shape.name}: ${why}`, color);
  despawn(p);
  updateHud();
}
// Těleso 32+ propadlé za horizont událostí otřese prostorem: ze studny vyjde záblesk a plocha se roztrhne.
// (Spojení na ploše trhliny nedělá; úroveň i trhlina přijdou až s pádem za horizont.)
function tearSpace(value) {
  const n = rifts.trigger(value, new THREE.Vector3(0, 0, 0), SHAPE_BY_VALUE[value].color);
  if (n) { riftFlash(); toast(L(`${value} za horizontem událostí: prostor se trhá · ${n} ${n === 1 ? 'segment' : n < 5 ? 'segmenty' : 'segmentů'}`, `${value} beyond the event horizon: space is tearing · ${n} ${n === 1 ? 'segment' : 'segments'}`), '#FF6A2A'); }
}
function swallow(p, { merged = false } = {}) {
  p.swallowed = true;
  // cílová koule se přechodem na další úroveň spotřebuje (už se s ničím nesráží a zmizí);
  // ostatní tělesa 32+ zůstanou na dně černé díry a čekají na pár, se kterým se spojí
  const keep = p.value >= 32 && p.value !== target() && !!mergeResult(p.value, p.value);
  p.deep = keep;
  p.col.setCollisionGroups(keep ? G_DEEP : 0);
  p.label.visible = false;
  if (held === p) release();
  const t = p.rb.translation();
  // ekonomika Quansistorů: těleso 32+ v černé díře začne vyzařovat Quansistory (rozpad s poločasem);
  // těleso vzniklé spojením na dně už nevyzařuje znovu (vyzařovaly ho jeho části)
  if (p.value >= 32 && !merged) {
    const n = tokens.emit(p.value, `${p.value}`);
    toast(L(`${p.value} v černé díře: vyzáří ${Math.round(n)} Quansistorů (poločas ${tokens.config.halfLife} s)`, `${p.value} in the black hole: will radiate ${Math.round(n)} Quansistors (half-life ${tokens.config.halfLife} s)`), '#7CFFB2');
  }
  if (p.value === target()) {
    sunk++; score += p.value * 2;
    flash(new THREE.Vector3(t.x, t.y, t.z), '#FF7A45', 2.4, 2);
    if (level < LEVELS.length - 1) { levelUp(); tearSpace(p.value); } else win();
  } else {
    flash(new THREE.Vector3(t.x, t.y, t.z), '#FF7A45', Math.max(0.6, p.size), 0.8);
    if (!merged) {
      toast(keep ? L(`${p.value} · ${p.shape.name}: za horizontem událostí čeká na dně na další ${p.value}`, `${p.value} · ${p.shape.name}: waits beyond the event horizon for another ${p.value}`)
        : L(`${p.value} · ${p.shape.name}: pohltil horizont událostí`, `${p.value} · ${p.shape.name}: swallowed by the event horizon`), '#FF7A45');
      if (p.value >= 32) tearSpace(p.value);
    }
  }
}

const target = () => LEVELS[level].target;

// přechod na další úroveň: otvor se zúží a trychtýř plynule prohloubí
let levelAnim = null;
function levelUp() {
  const from = LEVELS[level];
  level++;
  const to = LEVELS[level];
  levelAnim = { from, to, t: 0, frame: 0 };
  playSfx('levelUp');
  toast(L(`Úroveň ${level + 1}: cíl koule ${to.target}. Otvor se zúžil, trychtýř se prohloubil o ${(to.depth - from.depth).toFixed(1)}.`, `Level ${level + 1}: target sphere ${to.target}. The hole narrowed, the funnel deepened by ${(to.depth - from.depth).toFixed(1)}.`), SHAPE_BY_VALUE[to.target].color);
  updateHud();
}
function updateLevelAnim(dt) {
  if (!levelAnim) return;
  const a = levelAnim;
  a.t = Math.min(1, a.t + dt / 1.6); a.frame++;
  const k = a.t * a.t * (3 - 2 * a.t);           // plynulý rozběh i dojezd
  setFunnel({ rh: a.from.rh + (a.to.rh - a.from.rh) * k, depth: a.from.depth + (a.to.depth - a.from.depth) * k });
  refreshFunnelVisual();
  if (a.frame % 5 === 0 || a.t >= 1) refreshFunnelPhysics();
  if (a.t >= 1) { levelAnim = null; editor.redraw(); }
}

function win() {
  over = true; held = null; endReason = 'win';
  playSfx('levelUp');
  if (score > best) { best = score; try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { } }
  document.getElementById('over-title').textContent = L('Koule 256 propadla singularitou!', 'Sphere 256 fell into the singularity!');
  document.getElementById('over-text').textContent = L('Prošel jsi všemi úrovněmi trychtýře.', 'You cleared every level of the funnel.');
  document.getElementById('over-score').textContent = score;
  document.getElementById('over-best').textContent = best;
  document.getElementById('over-sunk').textContent = sunk;
  document.getElementById('over').hidden = false;
}

function endGame(title = L('Plocha je plná', 'The sheet is full'), text = L(`V trychtýři je víc než ${MAX_PIECES} těles.`, `There are more than ${MAX_PIECES} bodies in the funnel.`)) {
  document.getElementById('over-title').textContent = title; endReason = title;
  document.getElementById('over-text').textContent = text;
  over = true; held = null;
  if (score > best) { best = score; try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { } }
  document.getElementById('over-score').textContent = score;
  document.getElementById('over-best').textContent = best;
  document.getElementById('over-sunk').textContent = sunk;
  document.getElementById('over').hidden = false;
}

/* ---------- Vstup: držení, hod a shoz ---------- */
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), tmpS = new THREE.Sphere();
let down = null;
function setRay(e) {
  const r = canvas.getBoundingClientRect();
  ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
  ray.setFromCamera(ndc, camera);
}
function planeHit(y) {
  const t = (y - ray.ray.origin.y) / ray.ray.direction.y;
  return t > 0 ? ray.ray.origin.clone().addScaledVector(ray.ray.direction, t) : null;
}
// bod na zakřivené ploše pod kurzorem
function surfaceHit() {
  const h = ray.intersectObject(surface, false)[0];
  return h ? h.point.clone() : null;
}
function pickPiece() {
  let best = null, bestD = Infinity;
  for (const p of pieces) {
    const t = p.rb.translation();
    tmpS.set(new THREE.Vector3(t.x, t.y, t.z), p.size * 1.05);
    const hit = ray.ray.intersectSphere(tmpS, new THREE.Vector3());
    if (hit) { const d = hit.distanceTo(ray.ray.origin); if (d < bestD) { bestD = d; best = p; } }
  }
  return best;
}
function clampArena(v, m) {
  const r = Math.hypot(v.x, v.z), max = FUNNEL.R - m;
  if (r > max) { v.x *= max / r; v.z *= max / r; }
  return v;
}
function grab(p) {
  held = p; p.rb.setGravityScale(0, true); p.rb.wakeUp();
  const t = p.rb.translation(); holdTarget.set(t.x, t.y + 1, t.z);
  highlightRecipes(p.value);
}
function release() {
  if (!held) return;
  held.rb.setGravityScale(1, true);
  held = null; highlightRecipes(null);
}

/* ---------- Laserová děla ---------- */
// Čtyři děla v rozích obrazovky střílí paprsky do bodu pod zaměřovačem.
// Zásah tělesa: těleso dostane ráz ve směru pohledu (tak dostrkáš kouli do studny).
// Zásah prázdné plochy: energie paprsků se změní v hmotu a vznikne žeton (2).
const centerNdc = new THREE.Vector2(0, 0), aimRay = new THREE.Raycaster();
// NDC zaměřovače (pohybuje se po obrazovce)
function crossNdc() {
  const c = cockpit.cross(), W = vp.clientWidth, H = vp.clientHeight;
  return centerNdc.set((c.x / W) * 2 - 1, -(c.y / H) * 2 + 1);
}
function aimHit() {
  aimRay.setFromCamera(crossNdc(), camera);
  let best = null, bestD = Infinity;
  for (const p of pieces) {
    const t = p.rb.translation();
    tmpS.set(new THREE.Vector3(t.x, t.y, t.z), Math.max(0.5, p.size * 1.05));
    const h = aimRay.ray.intersectSphere(tmpS, new THREE.Vector3());
    if (h) { const d = h.distanceTo(aimRay.ray.origin); if (d < bestD) { bestD = d; best = { piece: p, point: h }; } }
  }
  if (best) return best;
  const sh = aimRay.intersectObject(surface, false)[0];
  return sh ? { piece: null, point: sh.point.clone() } : null;
}

const LASER_COLOR = new THREE.Color('#7DF9FF');
const LASER = { push: 7 };   // změna rychlosti zasaženého tělesa
// Nabití děl: děla střílí jen s nábojem. Náboj vzniká přeměnou žetonu na fotony
// (katapult přetažený až za střed ovládacího kruhu). Fotony na plochu nic nepřidají.
const CHARGE = { perCoin: 1, max: 8 };
let lastLaserHit = null;
let charge = 0, dryToastAt = 0, dbgPt = { x: 0, y: 0 };
function setCharge(n) { charge = THREE.MathUtils.clamp(Math.round(n), 0, CHARGE.max); cockpit.setCharge(charge, CHARGE.max); launcher.setCharge(charge, CHARGE.max); }
// paprsek = tenký válec (čára by měla jen 1 px); jádro bílé, obal azurový, obojí aditivně
// u ústí (blízko kamery) je válec tenčí, aby měl paprsek na obrazovce všude podobnou šířku
const beamGeo = new THREE.CylinderGeometry(1, 0.1, 1, 8, 1, true).translate(0, 0.5, 0).rotateX(Math.PI / 2);
const beams = [];
function laserBeams(to) {
  const ms = cockpit.muzzles(), W = canvas.clientWidth, H = canvas.clientHeight;
  for (const m of ms) {
    const ndc = new THREE.Vector3((m.x / W) * 2 - 1, -(m.y / H) * 2 + 1, 0.2).unproject(camera);
    const from = camera.position.clone().add(ndc.sub(camera.position).setLength(3));
    const len = from.distanceTo(to);
    const mk = (color, r) => {
      const b = new THREE.Mesh(beamGeo, new THREE.MeshBasicMaterial({ color, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
      b.position.copy(from); b.lookAt(to); b.scale.set(r, r, len);
      b.renderOrder = 10; // kreslit až po průhledné ploše, jinak by je překryla
      scene.add(b); return b;
    };
    beams.push({ core: mk(0xFFFFFF, 0.05), glowL: mk(LASER_COLOR, 0.14), t: 0 });
  }
}
function updateBeams(dt) {
  for (let i = beams.length - 1; i >= 0; i--) {
    const b = beams[i]; b.t += dt / 0.45;
    const k = Math.min(1, b.t);
    b.core.material.opacity = 1 - k * k;
    b.glowL.material.opacity = 0.8 * (1 - k);
    b.glowL.scale.x = b.glowL.scale.y = 0.14 * (1 - 0.6 * k);
    if (b.t >= 1) { scene.remove(b.core, b.glowL); b.core.material.dispose(); b.glowL.material.dispose(); beams.splice(i, 1); }
  }
}

function fireLasers() {
  const now = performance.now();
  if (over || !world || now - lastShot < 300) return;
  lastShot = now;
  if (aimParked) {
    if (now - dryToastAt > 2500) { dryToastAt = now; toast(L('Nejdřív zamiř: táhni po kruhu dole nebo použij šipky', 'Aim first: drag on the aiming ring below or use the arrow keys'), '#7DF9FF'); }
    return;
  }
  if (charge <= 0) {
    launcher.dryFire();
    if (now - dryToastAt > 2500) { dryToastAt = now; toast(L('Děla nejsou nabitá · přetáhni žeton z katapultu až do kruhu', 'Cannons are not charged · drag the coin from the sling all the way into the ring'), '#7DF9FF'); }
    return;
  }
  setCharge(charge - 1);
  playSfx('laser');
  const hit = aimHit();
  lastLaserHit = hit?.piece?.value ?? null;
  cockpit.recoil();
  if (!hit) return;
  laserBeams(hit.point);
  if (hit.piece) {
    // ráz ve směru pohledu po ploše, trochu nahoru; stejná změna rychlosti pro lehké i těžké
    const p = hit.piece, f = new THREE.Vector3(hit.point.x - camera.position.x, 0, hit.point.z - camera.position.z).normalize();
    const v = p.rb.linvel();
    p.rb.setLinvel({ x: v.x + f.x * LASER.push, y: v.y + 1.5, z: v.z + f.z * LASER.push }, true);
    flash(hit.point, '#7DF9FF', Math.max(0.6, p.size), 0.7);
  } else {
    flash(hit.point, '#7DF9FF', 0.6, 0.5);          // prázdná plocha: jen dopad paprsků
  }
  updateHud();
}

// Odpalovací modul (launcher.js): kruh pro děla a katapult jako jedna součást dole uprostřed.
// Modul řeší vzhled a ovládání, tady je herní logika: míření, výstřel, dráha a start žetonu, nabíjení.
// Záznam chování (cbt.js): ovládání praku, kruhu a kláves jako Canonical Behavior Transcript (Spectral Chain).
// Jen v paměti prohlížeče; stáhnout ho jde v Nastavení → Výzkum. Když hraje bot AI arény (REC.bot),
// zaznamenává ho runtime arény sám s přesnými časy, které bot zadal.
const rec = createRecorder();
const REC = { on: true, bot: null };
try { REC.on = localStorage.getItem('lhc.cbt') !== '0'; } catch (e) { }
let recT0 = performance.now();
const recNow = () => performance.now() - recT0;
const REC_EV = { sling: { down: EV.SLING_DOWN, move: EV.SLING_MOVE, up: EV.SLING_UP }, ring: { down: EV.RING_DOWN, move: EV.RING_MOVE, up: EV.RING_UP } };
function recXY(kind, cx, cy) {
  if (kind === 'ring') { const [u, v] = launcher.toRing(cx, cy); return [32768 + u * 16384, 32768 + v * 16384]; }
  const r = vp.getBoundingClientRect(); return [(cx - r.left) / r.width * 65535, (cy - r.top) / r.height * 65535];
}
function recInput(kind, phase, cx, cy, tMs = recNow()) { if (REC.on) { const [x, y] = recXY(kind, cx, cy); rec.push(REC_EV[kind][phase], tMs, x, y); } }
function recKey(type, code = 0, tMs = recNow()) { if (REC.on) rec.push(type, tMs, code, 0); }
async function downloadTranscript() {
  const t = await rec.finish();
  if (!t || !t.events) { toast(L('Záznam je prázdný: zahraj si a zkus to znovu', 'The transcript is empty: play a bit and try again'), '#7DF9FF'); return; }
  download(`lhc-cbt-${MODE}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`, packJSON([t]));
  toast(L(`Záznam stažen · ${t.events} událostí`, `Transcript downloaded · ${t.events} events`), '#7DF9FF');
}

const launcher = createLauncher(vp, {
  // míření: jezdec v kruhu = bod na ploše (okraj kruhu = okraj plochy), zaměřovač se dopočítá každý snímek
  onFire: fireLasers,
  onInput: (kind, phase, x, y) => { if (!REC.bot) recInput(kind, phase, x, y); },
  onKey: (k, code) => { if (!REC.bot) recKey(k === 'aim' ? EV.KEY_AIM : EV.KEY_FIRE, code); },
  onSlingStart: () => {
    if (over || !world || Math.hypot(PLAT.x, PLAT.z) <= FUNNEL.lip + 0.2) return false;
    if (!tokens.can(Math.min(ammoQueue[0] ?? 2, tokens.config.convertCost))) { noTokens(); return false; }
    return true;
  },
  onSlingMove: st => { sling.active = true; updateSling(st); },
  onSlingRelease: st => endSling(st),
  onSlingCancel: () => hideSling(),
  onConvert: () => chargeFromSling(),
  onLayout: () => { cockpit.relayout(); placeRadar(); },
});
const cockpit = createCockpit(vp);
// Téma vzhledu: „kokpit“ (modrošedá kabina podle referenčních obrázků) nebo původní „neon“.
// Mění CSS proměnné HUD (třída body.theme-kokpit), rám kabiny, barvu prostoru a barevné ladění scény.
const THEME = { kokpit: false };
const sceneGrade = Object.assign(document.createElement('div'), { className: 'scene-grade' });
canvas.after(sceneGrade);
function applyTheme(k) {
  THEME.kokpit = k;
  document.body.classList.toggle('theme-kokpit', k);
  cockpit.configure({ style: k ? 'kokpit' : 'neon' });
  const bg = k ? 0x0A1820 : 0x03050A;
  scene.background.setHex(bg); scene.fog.color.setHex(bg);
  scene.fog.near = k ? 30 : 40; scene.fog.far = k ? 85 : 95;
}
applyTheme(false);   // výchozí je původní neonový vzhled
// Radar (radar.js): minimapa celé plochy vlevo od odpalovacího modulu, jde vypnout v nastavení (HUD)
const radar = createRadar(vp);
// Zásobník (magazine.js): co je v praku a co přijde, vpravo od modulu
const magazine = createMagazine(vp);
// Quansistory (tokens.js): ekonomika + HUD sloupec u pravého okraje
const tokens = createTokens(vp);
// Oprava segmentu: tlačítko pod sloupcem Quansistorů scelí trhliny ve výseči plochy mezi prakem a černou dírou
const REPAIR = { cost: 10 };
const repairWedge = () => rifts.wedgeOf(PLAT.x, PLAT.z);
function repairSegment() {
  if (over || !world || rifts.repairing) return;
  if (!REC.bot) recKey(EV.REPAIR);
  const a0 = repairWedge();
  if (rifts.locked(a0)) { toast(L('Tuhle výseč jsi už v této otáčce opravil. Další segment opravíš, až ji prak znovu objede.', 'You already repaired this wedge this turn. You can repair another segment once the sling comes around again.'), '#7DF9FF'); return; }
  if (!rifts.hasInWedge(a0)) { toast(L('Ve výseči mezi prakem a černou dírou nejsou trhliny', 'No rifts in the wedge between the sling and the black hole'), '#7DF9FF'); return; }
  if (!tokens.spend(REPAIR.cost)) { toast(L(`Oprava stojí ${REPAIR.cost} Quansistorů, máš ${Math.floor(tokens.tokens)}`, `Repair costs ${REPAIR.cost} Quansistors, you have ${Math.floor(tokens.tokens)}`), '#FFB86B'); return; }
  rifts.repair(a0);
  playSfx('repair');
  toast(L(`Oprava segmentu · −${REPAIR.cost} Quansistorů${rifts.count > 1 ? ` · zbývá ${rifts.count - 1}` : ''}`, `Segment repair · −${REPAIR.cost} Quansistors${rifts.count > 1 ? ` · ${rifts.count - 1} left` : ''}`), '#7DF9FF');
  updateHud();
}
tokens.setRepair({ cost: REPAIR.cost, ready: false, handler: repairSegment });
// DEGEN MODE (degen.js): přepínač nad tlačítkem opravy, NEAR peněženka na testnetu
// Každé připojení NEAR peněženky (testnet) = registrace hráče → bannery zmizí. Platí odkudkoli:
// banner QUANSISTOR PASS, panel DEGEN MODE, „Připojit peněženku“ v hlavním menu i návrat z webové peněženky.
const degen = createDegen(tokens.degenHost, {
  toast: (m, c) => toast(m, c),
  onAccount: id => { if (id && paywall && !paywall.premium) { try { localStorage.removeItem('lhc.register.pending'); } catch (e) { } paywall.register(id); } },
});
// placená verze (paywall.js): platba přes NEAR peněženku (testnet) nebo Google Play v Android aplikaci
var paywall = createPaywall(vp, {
  degen, toast: (m, c) => toast(m, c),
  getPay: () => ({ receiver: banners.config.pay?.receiver || '', priceNear: String(banners.config.pay?.priceNear ?? '1') }),
  onUnlock: () => { banners.visible = false; settingsUI?.refresh?.(); },
});
banners.load();
banners.visible = !paywall.premium;
// Registrace přes banner: připojení NEAR peněženky (zdarma) odstraní bannery. Webové peněženky stránku
// přesměrují; po návratu se registrace dokončí podle značky lhc.register.pending.
const REG_KEY = 'lhc.register.pending';
async function registerWallet() {
  if (paywall.premium) return;
  try { localStorage.setItem(REG_KEY, '1'); } catch (e) { }
  try {
    await degen.setOn(true);
    const acc = await degen.connect();
    if (acc && !paywall.premium) { try { localStorage.removeItem(REG_KEY); } catch (e) { } paywall.register(acc); }
  } catch (e) { toast(L('Peněženku se nepodařilo načíst', 'Could not load the wallet'), '#FFB86B'); }
}
try {
  // peněženka zapamatovaná z dřívějška (třeba z hlavního menu) nebo návrat z přesměrování → načíst a zaregistrovat
  const remembered = (localStorage.getItem('near-wallet-selector:selectedWalletId') || 'null') !== 'null';
  const back = new URLSearchParams(location.search).has('account_id');
  if (!paywall.premium && (localStorage.getItem(REG_KEY) === '1' || remembered || back)) degen.lib().catch(() => { });
} catch (e) { }
function updateRepairButton() {
  tokens.setRepair({ cost: REPAIR.cost, busy: rifts.repairing, ready: !over && !rifts.repairing && tokens.can(REPAIR.cost) && rifts.hasInWedge(repairWedge()) });
}
// Horní lišta se na užším displeji (např. tablet na výšku) zalomí do více řádků. Stav hry, výkon a hlášky
// se proto posouvají pod její skutečnou výšku a horní děla pod stav hry, aby se nic nepřekrývalo.
const gbarEl = document.querySelector('.gbar'), ghudEl = document.querySelector('.ghud');
let topKey = '';
function placeTop() {
  const vr = vp.getBoundingClientRect();
  const b = gbarEl ? Math.round(gbarEl.getBoundingClientRect().bottom - vr.top) : 58;
  const h = ghudEl && getComputedStyle(ghudEl).display !== 'none' ? b + 4 + ghudEl.offsetHeight : b;
  const key = b + ':' + h;
  if (key === topKey) return;
  topKey = key;
  document.documentElement.style.setProperty('--gbar-b', `${b}px`);
  document.documentElement.style.setProperty('--hud-b', `${h}px`);
  if (!DEMO) { cockpit.configure({ hudBottom: h }); placeRadar(); }
}
if (gbarEl) new ResizeObserver(placeTop).observe(gbarEl);
if (ghudEl) new ResizeObserver(placeTop).observe(ghudEl);
new ResizeObserver(placeTop).observe(vp);

function placeRadar() {
  radar.place(launcher.leftEdge()); magazine.place(launcher.rightEdge());
  const gap = cockpit.rightGap(); tokens.place(Math.max(gap.top, 100), gap.bottom);
  degen?.place();
}

/* ---------- Náboje do praku ---------- */
// Prak střílí žeton (2), čtyřstěn (4), krychli (6) nebo osmistěn (8). Co přijde, se losuje
// náhodně podle táhla v nastavení: tři body dělí 0–100 % na čtyři díly = šance jednotlivých nábojů.
const AMMO = { values: [2, 4, 6, 8, 32], cuts: [55, 80, 92, 100] };   // 32 výchozí 0 %
const ammoPct = () => { const e = [0, ...AMMO.cuts, 100]; return AMMO.values.map((_, i) => e[i + 1] - e[i]); };
// benchmark AI arény: pevné semínko → všichni boti dostanou stejné pořadí nábojů
let ammoRng = null;
const lcg = s => () => { s = (s * 48271) % 2147483647; return (s - 1) / 2147483646; };
function pickAmmo() {
  const r = (ammoRng ? ammoRng() : Math.random()) * 100, i = AMMO.cuts.findIndex(c => r < c);
  return AMMO.values[i < 0 ? AMMO.values.length - 1 : i];
}
// drátěný obrázek tvaru do HUD: hrany promítnuté z mírně natočeného pohledu, v jednotkovém kruhu
const iconCache = new Map();
function ammoIcon(v) {
  const sh = SHAPE_BY_VALUE[v];
  let segs = iconCache.get(v);
  if (!segs) {
    segs = [];
    if (sh.collider !== 'cylinder') {
      const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-0.45, 0.6, 0));
      const P = sh.V.map(p => p.clone().applyQuaternion(q));
      const m = Math.max(...P.map(p => Math.hypot(p.x, p.y)));
      for (const [a, b] of sh.edges) segs.push([P[a].x / m, -P[a].y / m, P[b].x / m, -P[b].y / m]);
    }
    iconCache.set(v, segs);
  }
  return { value: v, name: sh.name, color: sh.color, segs };
}
let ammoQueue = [];
function refillAmmo(keepCurrent = true) {
  ammoQueue = keepCurrent && ammoQueue.length ? [ammoQueue[0]] : [pickAmmo()];
  while (ammoQueue.length < 4) ammoQueue.push(pickAmmo());
  showAmmo();
}
function nextAmmo() { ammoQueue.shift(); while (ammoQueue.length < 4) ammoQueue.push(pickAmmo()); showAmmo(); }
// Na náboj v praku nejsou Quansistory: prak ho vymění za žeton 2 (když je na něj), aby hra neuvízla.
function affordAmmo() {
  const v = ammoQueue[0];
  if (v == null || v === 2 || tokens.can(v) || !tokens.can(2)) return false;
  ammoQueue[0] = 2;
  toast(L(`Na ${v} nemáš Quansistory, prak nabil žeton 2`, `Not enough Quansistors for ${v}, the sling loaded coin 2`), '#FFD66B');
  return true;
}
function showAmmo() {
  affordAmmo();
  launcher.setAmmo(ammoIcon(ammoQueue[0]));
  try { highlightRecipes(ammoQueue[0]); } catch (e) { }   // recepty pro náboj v praku
  launcher.setBroke(!tokens.can(ammoQueue[0]));
  const pct = ammoPct();
  magazine.update({ queue: ammoQueue.map(ammoIcon), parts: AMMO.values.map((v, i) => ({ label: String(v), color: SHAPE_BY_VALUE[v].color, pct: pct[i] })) });
  updateHud();
}
new ResizeObserver(placeRadar).observe(vp);

// Míření přes mapu: jezdec v kruhu (nx, ny) → bod na ploše ve směru pohledu
// (vpravo = vpravo na obrazovce, dolů = blíž k hráči) → zaměřovač na obrazovce.
// Okraj kruhu = okraj plochy; kde plocha přesahuje obrazovku (blízký okraj dole),
// je okrajem kruhu okraj viditelné části plochy. Zaměřovač tak nikdy neopustí plochu.
// Plocha se otáčí pod pevným pohledem, takže zaměřovač zůstává na místě a plocha jede pod ním.
const AIM_R = () => FUNNEL.R - 0.6;
const _fwd = new THREE.Vector3(), _right = new THREE.Vector3(), _ap = new THREE.Vector3();
function aimAxes() {
  _fwd.set(VIEW.target.x - camera.position.x, 0, VIEW.target.z - camera.position.z).normalize();
  _right.set(-_fwd.z, 0, _fwd.x);
}
// bod plochy ve vzdálenosti r směrem (dx, dz) → pixely obrazovky (uprostřed studny výška přepadu)
function surfaceScreen(dx, dz, r, out = { x: 0, y: 0 }) {
  _ap.set(dx * r, heightAt(Math.max(r, FUNNEL.lip)), dz * r).project(camera);
  out.x = (_ap.x + 1) / 2 * vp.clientWidth; out.y = (1 - _ap.y) / 2 * vp.clientHeight;
  return out;
}
const _sp = { x: 0, y: 0 };
const onScreen = p => p.x >= 24 && p.x <= vp.clientWidth - 24 && p.y >= 76 && p.y <= vp.clientHeight - 24;
// nejdelší viditelný poloměr plochy v daném směru
function visibleR(dx, dz) {
  const Rr = AIM_R();
  if (onScreen(surfaceScreen(dx, dz, Rr, _sp))) return Rr;
  let lo = 0, hi = Rr;
  for (let i = 0; i < 14; i++) { const m = (lo + hi) / 2; onScreen(surfaceScreen(dx, dz, m, _sp)) ? lo = m : hi = m; }
  return lo;
}
// jezdec (nx, ny) → směr na ploše
const dirOf = (nx, ny) => [_right.x * nx - _fwd.x * ny, _right.z * nx - _fwd.z * ny];
const radarAim = { nx: 0, ny: 0 };
let aimParked = true;
function updateAim() {
  aimAxes();
  const a = launcher.aim(), l = Math.hypot(a.nx, a.ny);
  // klidová poloha (jezdec uprostřed kruhu, ještě se nemířilo): zaměřovač parkuje ve středu ovládacího kruhu,
  // ne nad černou dírou; děla v klidu nestřílí
  if (l < 1e-4) {
    aimParked = true; radarAim.nx = radarAim.ny = 0;
    const rs = launcher.ringScreen(), vr = vp.getBoundingClientRect();
    cockpit.setCross(rs.x - vr.left, rs.y - vr.top); return;
  }
  aimParked = false;
  const [dx, dz] = dirOf(a.nx / l, a.ny / l), vr = visibleR(dx, dz);
  radarAim.nx = a.nx * vr / FUNNEL.R; radarAim.ny = a.ny * vr / FUNNEL.R;   // na radaru (okraj = okraj plochy)
  surfaceScreen(dx, dz, l * vr, _sp);
  cockpit.setCross(_sp.x, _sp.y);
}
// tělesa jako tečky v ovládacím kruhu (stejné mapování jako míření, otáčí se spolu s plochou)
function updateMap() {
  aimAxes();
  const Rr = AIM_R(), tv = target(), items = [];
  for (const p of pieces) {
    if (p.swallowed) continue;
    const t = p.rb.translation(), r = Math.hypot(t.x, t.z);
    if (r < 1e-3) { items.push({ nx: 0, ny: 0, r: 0.04, color: p.shape.color, target: p.value === tv }); continue; }
    const dx = t.x / r, dz = t.z / r;
    const nx = dx * _right.x + dz * _right.z, ny = -(dx * _fwd.x + dz * _fwd.z);
    const k = Math.min(1, r / Math.max(0.5, visibleR(dx, dz)));
    if (r > visibleR(dx, dz) + 0.5) continue;                 // mimo obrazovku = mimo mapu
    items.push({ nx: nx * k, ny: ny * k, r: Math.max(0.05, p.size / Rr * 1.5), color: p.shape.color, target: p.value === tv });
  }
  launcher.setMap(items, FUNNEL.lip / Rr);
}

// radar: celá plocha v souřadnicích pohledu + obrys toho, co je vidět na obrazovce
let radarView = [], radarViewAt = 0;
function updateRadar(now) {
  if (!radar.visible) return;
  aimAxes();
  const RR = FUNNEL.R, tv = target();
  if (now - radarViewAt > 300) {                    // obrys zorného pole stačí občas
    radarViewAt = now; radarView = [];
    for (let i = 0; i < 28; i++) {
      const an = i / 28 * Math.PI * 2, nx = Math.cos(an), ny = Math.sin(an);
      const [dx, dz] = dirOf(nx, ny), k = visibleR(dx, dz) / RR;
      radarView.push([nx * k, ny * k]);
    }
  }
  const items = [];
  for (const p of pieces) {
    if (p.swallowed) continue;
    const t = p.rb.translation();
    items.push({ nx: (t.x * _right.x + t.z * _right.z) / RR, ny: -(t.x * _fwd.x + t.z * _fwd.z) / RR, r: Math.max(0.03, p.size / RR * 0.9), color: p.shape.color, target: p.value === tv });
  }
  const riftLines = rifts.polygons().map(P => P.map(([x, z]) => [(x * _right.x + z * _right.z) / RR, -(x * _fwd.x + z * _fwd.z) / RR]));
  const dPct = rifts.destroyed * 100;
  radar.draw({ items, aim: radarAim, view: radarView, well: FUNNEL.lip / RR, rifts: riftLines, info: L(`${items.length} těles · cíl ${tv}${dPct >= 0.5 ? ` · trhliny ${Math.round(dPct)} %` : ''}`, `${items.length} bodies · target ${tv}${dPct >= 0.5 ? ` · rifts ${Math.round(dPct)}%` : ''}`) }, now);
}

// cíl pod zaměřovačem (text u zaměřovače)
function updateCrosshair() {
  const h = aimHit();
  if (h && h.piece) cockpit.setTarget(`${h.piece.value} · ${h.piece.shape.name}`, h.piece.shape.color);
  else cockpit.setTarget('', null);
}

/* ---------- Katapult na žetony ---------- */
// Plošina leží na obrazovce napůl cesty mezi středem (výchozí místo zaměřovače) a ovládacím kruhem.
// Klepnutí na plošinu vytvoří žeton. Tažením směrem dolů (k ovládacímu kruhu)
// se katapult natáhne jako prak: čím víc, tím dál žeton poletí. Směr je opačný než tah.
// Puštěním se žeton vystřelí obloukem; křivka a kroužek ukazují místo dopadu.
// SPEED = rychlost výstřelu (násobek). Dráha zůstává stejná: rychlost ×k a gravitace letícího žetonu ×k²,
// takže žeton dopadne do stejného místa, jen rychleji/pomaleji, a po dopadu má víc/míň síly.
const SLING = { MAX_DIST: 22, ANGLE: 50 * Math.PI / 180, SPEED: 1, GRAB_PX: 80, MIN: 0.06 };
// Led: tření žetonu (puk). Zpomalení na rovině ≈ mu·g, k tomu lineární útlum tělesa.
// keep = kolik vodorovné rychlosti si žeton po dopadu nechá (zbytek „spolkne“ dopad).
const ICE = { mu: 0.15, damping: 0.05, keep: 0.55 };
const sling = { active: false, x0: 0, y0: 0, ratio: 0, dir: new THREE.Vector3(0, 0, -1), land: new THREE.Vector3(), start: new THREE.Vector3() };

// křivka letu a kroužek dopadu (žeton a prak jsou v odpalovacím modulu na obrazovce)
const ARC_N = 48;
const arcGeo = new THREE.BufferGeometry(); arcGeo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(ARC_N * 3), 3));
const arc = new THREE.Line(arcGeo, new THREE.LineDashedMaterial({ color: 0xFFD66B, dashSize: 0.5, gapSize: 0.3, transparent: true, opacity: 0.95 }));
arc.visible = false; arc.renderOrder = 9; scene.add(arc);
const landRing = new THREE.LineLoop(holeRing.geometry, new THREE.LineBasicMaterial({ color: 0xFFD66B, transparent: true, opacity: 0.95 }));
landRing.visible = false; landRing.renderOrder = 9; scene.add(landRing);

function updatePlatform() {
  // katapult nad studnou nejde použít
  launcher.setEnabled(Math.hypot(PLAT.x, PLAT.z) > FUNNEL.lip + 0.2);
  // plocha se pod katapultem otáčí i když prst stojí: křivku přepočítáme každý snímek
  if (sling.active && sling.last) updateSling(sling.last);
}

// Výstřel = oblouk (šikmý vrh) do zamířeného místa, po dopadu žeton ještě kus dojede
// a strčí do těles v cestě. Délka dojezdu z rychlosti v: dv/dt = -a - c·v  →  s = v/c - a/c² · ln(1 + c·v/a)
const slideDist = v => {
  const a = Math.max(0.05, ICE.mu * 9.81 * 2.5), c = ICE.damping;   // 2,5 = naměřené ztráty při dopadu a na síti
  return c < 1e-4 ? v * v / (2 * a) : v / c - (a / (c * c)) * Math.log(1 + c * v / a);
};
// rychlost pro šikmý vrh na vzdálenost d (úhel ANGLE, stejná výška startu a dopadu)
function launchVelocity(d, dir) {
  const g = 9.81, v = Math.sqrt(g * Math.max(0.01, d) / Math.sin(2 * SLING.ANGLE));
  return new THREE.Vector3(dir.x * v * Math.cos(SLING.ANGLE), v * Math.sin(SLING.ANGLE), dir.z * v * Math.cos(SLING.ANGLE));
}

function updateSling(st) {
  // tah na obrazovce: dolů = natáhnout dopředu, do stran = mířit opačně; poměr nátahu počítá modul
  sling.last = st;
  const { dx, dy } = st;
  sling.ratio = st.ratio;
  const fwd = new THREE.Vector3(PLAT.x - camera.position.x, 0, PLAT.z - camera.position.z).normalize();
  const right = new THREE.Vector3(-fwd.z, 0, fwd.x);
  const dir = fwd.clone().multiplyScalar(dy).add(right.multiplyScalar(-dx));
  if (dir.lengthSq() > 1e-6) sling.dir.copy(dir.normalize());
  sling.start.set(PLAT.x, PLAT.y + 0.8, PLAT.z);

  // křivka letu a místo dopadu
  const show = sling.ratio > SLING.MIN;
  arc.visible = landRing.visible = show;
  if (!show) return;
  const d = sling.ratio * SLING.MAX_DIST;
  const land = sling.start.clone().addScaledVector(sling.dir, d);
  clampArena(land, 1);
  const realD = Math.hypot(land.x - sling.start.x, land.z - sling.start.z);
  const v = launchVelocity(realD, sling.dir), T = 2 * v.y / 9.81;
  // první část křivky = let do místa dopadu, zbytek = dojezd po ploše
  const vh = Math.hypot(v.x, v.z) * SLING.SPEED, slide = slideDist(vh * ICE.keep);
  const stop = land.clone().addScaledVector(sling.dir, slide); clampArena(stop, 1);
  const sl = Math.hypot(stop.x - land.x, stop.z - land.z);
  const NF = slide > 0.3 ? Math.round(ARC_N * 0.8) : ARC_N;
  const a = arcGeo.getAttribute('position');
  for (let i = 0; i < ARC_N; i++) {
    let x, y, z;
    if (i < NF) {
      const t = (i / (NF - 1)) * T;
      x = sling.start.x + v.x * t; z = sling.start.z + v.z * t;
      y = Math.max(sling.start.y + v.y * t - 4.905 * t * t, heightAt(Math.hypot(x, z)) + 0.1);
    } else {
      const k = (i - NF + 1) / (ARC_N - NF);
      x = land.x + sling.dir.x * sl * k; z = land.z + sling.dir.z * sl * k;
      y = heightAt(Math.hypot(x, z)) + 0.1;
    }
    a.setXYZ(i, x, y, z);
  }
  a.needsUpdate = true; arc.computeLineDistances();
  land.y = heightAt(Math.hypot(land.x, land.z)) + 0.06;
  sling.land.copy(land);
  landRing.position.copy(land); landRing.scale.setScalar(0.9 + 0.1 * Math.sin(performance.now() / 120));
}

function hideSling() {
  sling.active = false; sling.last = null;
  arc.visible = landRing.visible = false;
}
// Vyzařování: zelenozlaté jiskry stoupají z hrdla, počet úměrný rychlosti (Quansistorů/s)
const EMIT_N = 160;
const emitGeo = new THREE.BufferGeometry();
emitGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(EMIT_N * 3), 3));
emitGeo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(EMIT_N * 3), 3));
const emitPts = new THREE.Points(emitGeo, new THREE.PointsMaterial({ size: 0.28, vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
emitPts.frustumCulled = false; scene.add(emitPts);
const emitP = Array.from({ length: EMIT_N }, () => ({ life: 0, x: 0, y: -99, z: 0, vx: 0, vy: 0, vz: 0 }));
let emitAcc = 0, emitIdx = 0, tokenCheckAt = 0, lastWhole = 0;
const cEmit = [new THREE.Color('#7CFFB2'), new THREE.Color('#FFD66B')];
function updateTokens(dt, now) {
  if (!over && world) {
    const got = tokens.tick(dt);
    if (Math.floor(tokens.tokens) !== lastWhole) {
      lastWhole = Math.floor(tokens.tokens);
      if (affordAmmo()) showAmmo(); else launcher.setBroke?.(!tokens.can(ammoQueue[0] ?? 2));
    }
    // jiskry: zhruba 3 na každý vyzářený Quansistor, nejvýš co se vejde
    emitAcc += got * 3;
    while (emitAcc >= 1) {
      emitAcc -= 1;
      const p = emitP[emitIdx++ % EMIT_N], a = Math.random() * Math.PI * 2, r = FUNNEL.rh * 0.6;
      p.life = 1; p.x = Math.cos(a) * r; p.z = Math.sin(a) * r; p.y = -FUNNEL.depth + 0.5;
      const s = 1.2 + Math.random() * 1.6; p.vx = Math.cos(a) * s; p.vz = Math.sin(a) * s; p.vy = 7 + Math.random() * 5;
      p.c = cEmit[Math.random() < 0.6 ? 0 : 1];
    }
    // konec hry: Quansistory nestačí ani na nabití děl, díra nic nevyzařuje a děla jsou prázdná
    if (now - tokenCheckAt > 1000) {
      tokenCheckAt = now;
      if (tokens.config.trickle <= 0 && !tokens.emitting && charge <= 0 && !tokens.can(Math.min(2, tokens.config.convertCost)) && !pieces.some(p => p.flying)) {
        endGame(L('Došly Quansistory', 'Out of Quansistors'), L('Nezbývají Quansistory na výstřel ani na nabití děl a černá díra nic nevyzařuje.', 'No Quansistors left to shoot or to charge the cannons, and the black hole is not radiating.'));
      }
    }
  }
  const pos = emitGeo.getAttribute('position'), col = emitGeo.getAttribute('color');
  for (let i = 0; i < EMIT_N; i++) {
    const p = emitP[i];
    if (p.life > 0) {
      p.life -= dt * 0.7; p.vy -= 4 * dt;
      p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
      const k = Math.max(0, p.life);
      pos.setXYZ(i, p.x, p.y, p.z); col.setXYZ(i, p.c.r * k, p.c.g * k, p.c.b * k);
    } else { pos.setXYZ(i, 0, -99, 0); col.setXYZ(i, 0, 0, 0); }
  }
  pos.needsUpdate = col.needsUpdate = true;
  tokens.draw(dt, now);
}

// krátký záblesk přes celou obrazovku
const riftFlashEl = Object.assign(document.createElement('div'), { className: 'rift-flash' });
vp.appendChild(riftFlashEl);
function riftFlash() { riftFlashEl.classList.remove('on'); void riftFlashEl.offsetWidth; riftFlashEl.classList.add('on'); }

let noTokAt = 0;
function noTokens(need) {
  const now = performance.now();
  if (now - noTokAt < 1500) return;
  noTokAt = now;
  const have = Math.floor(tokens.tokens);
  toast(need ? L(`Málo Quansistorů: ${need} stojí ${need}, máš ${have}. Přetažením přes čáru nabiješ děla (1 Quansistor).`, `Not enough Quansistors: ${need} costs ${need}, you have ${have}. Drag past the line to charge the cannons (1 Quansistor).`) : L(`Došly Quansistory (${have}). Dostrkej těleso 32+ do studny.`, `Out of Quansistors (${have}). Push a body of 32+ into the well.`), '#FFB86B');
}
// Žeton přetažený za střed kruhu se nevratně přemění na fotony a nabije děla
// (tah pak v modulu plynule přejde v míření; puštění vystřelí).
function chargeFromSling() {
  hideSling();
  if (charge >= CHARGE.max) { toast(L('Děla jsou plně nabitá', 'Cannons are fully charged'), '#7DF9FF'); return; }
  // nabití děl stojí Quansistory (výchozí 1) a přidá náboje (výchozí 1); náboj v praku zůstává
  if (!tokens.spend(tokens.config.convertCost)) { noTokens(); return; }
  setCharge(charge + CHARGE.perCoin);
  cockpit.photons(launcher.ringCenter());   // záblesk jde z kruhu po okrajích obrazovky, ne přes střed
  updateHud();
}
function endSling(st) {
  hideSling();
  if (over || !world) return;
  const v = ammoQueue[0] ?? 2, lift = v === 2 ? 0.6 : sizeOf(v) + 0.3;
  // výstřel i položení stojí tolik Quansistorů, kolik je hodnota náboje
  if (!tokens.spend(v)) { noTokens(v); return; }
  if (st.ratio <= SLING.MIN) {
    // jen klepnutí: náboj se položí na plochu pod modulem
    spawn(v, new THREE.Vector3(PLAT.x, PLAT.y + lift, PLAT.z));
    playSfx('sling', 0);
  } else {
    sling.start.set(PLAT.x, PLAT.y + Math.max(0.8, lift), PLAT.z);
    const realD = Math.hypot(sling.land.x - sling.start.x, sling.land.z - sling.start.z);
    const k = SLING.SPEED;
    const p = spawn(v, sling.start.clone(), launchVelocity(realD, sling.dir).multiplyScalar(k));
    p.rb.setGravityScale(k * k, true);           // stejný oblouk při jiné rychlosti
    p.flying = true;
    flash(sling.start, SHAPE_BY_VALUE[v].color, 0.7, 0.6);
    playSfx('sling', st.ratio);
  }
  nextAmmo();
  if (liveCount() > MAX_PIECES) endGame();
  updateHud();
}

// přistání: žeton dosedne naplocho a s částí rychlosti ještě kus dojede (strčí do těles v cestě)
function landFlyingCoins() {
  for (const p of pieces) {
    if (!p.flying) continue;
    const t = p.rb.translation(), v = p.rb.linvel();
    if (v.y <= 0 && t.y - heightAt(Math.hypot(t.x, t.z)) < p.size * (p.flat ? 0.3 : 1.1) + 0.15) {
      p.rb.setLinvel({ x: v.x * ICE.keep, y: 0, z: v.z * ICE.keep }, true);
      p.flying = false; p.rb.setGravityScale(1, true);
    }
  }
}

canvas.addEventListener('pointerdown', e => {
  if (e.button !== 0 || over || !world) return;
  setRay(e);
  // Tělesa se rukou (myší / prstem) nepřenášejí: hýbe s nimi jen prak (náraz) a laserová děla.
  const bh = banners.hit(ray);                       // klik / tap na banner: nabídka plné verze, nebo registrace
  if (bh) { if (bh.object.userData.banner?.action === 'wallet') registerWallet(); else paywall.open(); }
});
canvas.addEventListener('pointermove', e => {
  if (!held) {
    if (e.pointerType === 'mouse' && banners.visible && banners.count) { setRay(e); canvas.style.cursor = banners.hit(ray) ? 'pointer' : ''; }
    return;
  }
  setRay(e);
  // nesené těleso klouže nad zakřivenou plochou
  const hh = surfaceHit() ?? planeHit(0);
  if (!hh) return;
  clampArena(hh, held.size);
  holdTarget.set(hh.x, heightAt(Math.hypot(hh.x, hh.z)) + held.size + 1.2, hh.z);
});
canvas.addEventListener('pointerup', e => {
  if (held) release();
});
// F nebo mezerník = výstřel, šipky = míření
addEventListener('keydown', e => {
  const el = document.activeElement;
  const typing = el && (el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || (el.tagName === 'INPUT' && !/checkbox|radio|button/.test(el.type)));
  if (typing || el?.classList?.contains('lc-hit')) return;
  if ((e.key === 'f' || e.key === 'F' || e.key === ' ') && !e.repeat) { e.preventDefault(); if (!REC.bot) recKey(EV.KEY_FIRE, e.keyCode || 0); fireLasers(); }
  const k = { ArrowLeft: [-14, 0], ArrowRight: [14, 0], ArrowUp: [0, -14], ArrowDown: [0, 14] }[e.key];
  if (k) { e.preventDefault(); if (!REC.bot) recKey(EV.KEY_AIM, e.keyCode || 0); launcher.nudge(k[0] * 0.25, k[1] * 0.25); }
});
canvas.addEventListener('pointercancel', () => release());
document.getElementById('b-new').onclick = newGame;

document.getElementById('b-again').onclick = newGame;
// panel s recepty (na úzkém displeji ve výchozím stavu schovaný)
const panelEl = document.getElementById('panel'), panelBtn = document.getElementById('b-panel');
const setPanel = open => { panelEl.hidden = !open; panelBtn.setAttribute('aria-expanded', String(open)); };
setPanel(false); // v kokpitu je panel ve výchozím stavu schovaný, aby nezakrýval děla
panelBtn.onclick = () => { setPanel(panelEl.hidden); if (!panelEl.hidden) { settingsEl.hidden = true; settingsBtn.setAttribute('aria-expanded', 'false'); } };
// celá obrazovka (když ji prohlížeč nepovolí, hra běží dál v okně)
const fsBtn = document.getElementById('b-fs');
fsBtn.onclick = async () => {
  try {
    if (document.fullscreenElement) await document.exitFullscreen();
    else await document.documentElement.requestFullscreen();
  } catch (e) { toast(L('Celou obrazovku prohlížeč nepovolil', 'The browser did not allow fullscreen'), '#F1F5F9'); }
};
document.addEventListener('fullscreenchange', () => { fsBtn.textContent = document.fullscreenElement ? L('Zmenšit', 'Exit fullscreen') : L('Celá obrazovka', 'Fullscreen'); });

/* ---------- Nastavení ---------- */
const settingsEl = document.getElementById('settings'), settingsBtn = document.getElementById('b-settings');
const shapeFillDefault = SHAPE_BY_VALUE[2].mat.fill.opacity;
// Hudba na pozadí (music.js) – tlačítko v horní liště, hlasitost v nastavení
const music = createMusic(document.getElementById('b-music'));
// Zvukové efekty (sfx.js): prak, laserová děla, další úroveň, oprava plochy.
// V náhledu dema a ve zrychlené simulaci (benchmark AI arény) jsou ztlumené.
const sfx = createSfx();
function playSfx(name, ...args) { if (!DEMO && SIM.speed <= 1.01) sfx[name](...args); }
// Herní parametry (params.js): popisky a rozsahy jsou společné s obrazovkou Custom, tady jen napojení na hru.
const GAME_BIND = {
  finalTarget: [() => RULES.finalTarget, v => { RULES.finalTarget = +v; }],
  maxPieces: [() => RULES.maxPieces, v => { RULES.maxPieces = v; }],
  startBodies: [() => RULES.startBodies, v => { RULES.startBodies = v; }],
  depth0: [() => RULES.depth0, v => { RULES.depth0 = v; }],
  spin: [() => VIEW.spinDegPerSec, v => { VIEW.spinDegPerSec = v; }],
  wob: [() => VIEW.wobbleDeg, v => { VIEW.wobbleDeg = v; }],
  wobp: [() => VIEW.wobblePeriod, v => { VIEW.wobblePeriod = v; }],
  push: [() => LASER.push, v => { LASER.push = v; }],
  range: [() => SLING.MAX_DIST, v => { SLING.MAX_DIST = v; }],
  ammoMix: [() => AMMO.cuts.slice(), v => {
    if (!Array.isArray(v)) return;
    if (v.length === 3) v = [...v, 100];                 // starší uložené nastavení (bez 32)
    if (v.length !== 4) return;
    AMMO.cuts = v.map(Number).sort((a, b) => a - b); if (ammoQueue.length) refillAmmo(false);
  }],
  photon1: [() => CHARGE.perCoin, v => { CHARGE.perCoin = v; }],
  cap: [() => CHARGE.max, v => { CHARGE.max = v; setCharge(charge); }],
  keep: [() => Math.round(ICE.keep * 100), v => { ICE.keep = v / 100; }],
  fric: [() => ICE.mu, v => { ICE.mu = v; for (const q of pieces) if (q.flat) q.col.setFriction(v); }],
  speed: [() => SLING.SPEED, v => { SLING.SPEED = v; }],
  arc: [() => Math.round(SLING.ANGLE * 180 / Math.PI), v => { SLING.ANGLE = v * Math.PI / 180; }],
  tokStart: [() => tokens.config.start, v => tokens.configure({ start: v })],
  tokHalf: [() => tokens.config.halfLife, v => tokens.configure({ halfLife: v })],
  tokMult: [() => tokens.config.mult, v => tokens.configure({ mult: v })],
  tokConv: [() => tokens.config.convertCost, v => tokens.configure({ convertCost: v })],
  tokTrickle: [() => tokens.config.trickle, v => tokens.configure({ trickle: v })],
  tokMerge: [() => Math.round(tokens.config.merge * 100), v => tokens.configure({ merge: v / 100 })],
  riftOn: [() => rifts.config.on, v => rifts.configure({ on: !!v })],
  riftCells: [() => rifts.config.cells, v => rifts.configure({ cells: v })],
  repairCost: [() => REPAIR.cost, v => { REPAIR.cost = v; updateRepairButton(); }],
};
const gameGroups = GAME_GROUPS.map(g => ({ ...g, items: g.items.map(m => {
  const [get, set] = GAME_BIND[m.id];
  const it = { ...m, get, set };
  if (m.type === 'split') { it.minGap = 0; it.parts = m.values.map(v => ({ label: `${v} ${SHAPE_BY_VALUE[v].name}`, color: SHAPE_BY_VALUE[v].color })); }
  return it;
}) }));
const settingsUI = buildSettings(document.getElementById('settings-body'), [
  { title: L('Pohled', 'View'), items: [
    { id: 'elev', label: L('Úhel pohledu nad plochou', 'View angle above the sheet'), min: 8, max: 80, unit: '°', get: () => VIEW.elevationDeg, set: v => { VIEW.elevationDeg = v; } },
    { id: 'dist', label: L('Vzdálenost od plochy', 'Distance from the sheet'), min: 24, max: 70, get: () => VIEW.distance, set: v => { VIEW.distance = v; } },
    { id: 'fov', label: L('Zorné pole', 'Field of view'), min: 30, max: 80, unit: '°', get: () => camera.fov, set: v => { camera.fov = v; camera.updateProjectionMatrix(); } },
    { id: 'tgt', label: L('Výška středu pohledu', 'View center height'), min: -8, max: 4, step: 0.5, decimals: 1, get: () => VIEW.target.y, set: v => { VIEW.target.y = v; } },
  ] },
  ...gameGroups.filter(g => g.key === 'rules' || g.key === 'spin' || g.key === 'ctl'),
  { title: L('Míření (kruh)', 'Aiming (ring)'), note: L('Kruh dole je mapa hrací plochy: okraj kruhu = okraj plochy, jezdec ukazuje, kam děla míří.', 'The ring below is a map of the sheet: ring edge = sheet edge, the knob shows where the cannons aim.'), items: [
    { id: 'aimSens', label: L('Citlivost míření', 'Aim sensitivity'), min: 0.3, max: 2.5, step: 0.05, decimals: 2, unit: '×', get: () => launcher.sensitivity, set: v => { launcher.sensitivity = v; },
      hint: L('Kruh je mapa plochy (okraj kruhu = okraj plochy). 1 = jezdec jde přesně s prstem, menší číslo = jemnější míření.', 'The ring is a map of the sheet (ring edge = sheet edge). 1 = the knob follows your finger exactly, lower = finer aiming.') },
    { id: 'aimRing', label: L('Velikost kruhu', 'Ring size'), min: 0.6, max: 2, step: 0.05, decimals: 2, unit: '×', get: () => launcher.config.ring, set: v => launcher.configure({ ring: v }),
      hint: L('Větší kruh = přesnější míření (víc pixelů na stejnou plochu).', 'Bigger ring = more precise aiming (more pixels for the same sheet).') },
    { id: 'aimKnob', label: L('Velikost jezdce', 'Knob size'), min: 0.6, max: 2, step: 0.05, decimals: 2, unit: '×', get: () => launcher.config.knob, set: v => launcher.configure({ knob: v }) },
    { id: 'aimEdge', label: L('Posun u okraje obrazovky', 'Edge push'), min: 0, max: 3, step: 0.1, decimals: 1, unit: '×', get: () => launcher.config.edge, set: v => launcher.configure({ edge: v }),
      hint: L('Když při míření dojedeš prstem nebo myší k okraji obrazovky a tlačíš dál, jezdec jede sám tím směrem (hlavně po přeměně žetonu na fotony, kdy je prst už dole). 0 = vypnuto.', 'When you reach the screen edge with your finger or mouse while aiming and keep pushing, the knob keeps moving that way (mainly after turning a coin into photons, when your finger is already low). 0 = off.') },
    { id: 'aimTap', type: 'toggle', label: L('Klepnutí přesune jezdce', 'Tap moves the knob'), get: () => launcher.config.tapAim, set: v => launcher.configure({ tapAim: !!v }),
      hint: L('Zapnuto: klepnutím do kruhu míříš rovnou na to místo plochy, tahem pak dolaďuješ. Vypnuto: jezdec se posouvá jen tahem.', 'On: tapping the ring aims straight at that spot of the sheet, then fine-tune by dragging. Off: the knob moves only by dragging.') },
    { id: 'aimFire', type: 'toggle', label: L('Výstřel puštěním kruhu', 'Fire on ring release'), get: () => launcher.config.fireOnRelease, set: v => launcher.configure({ fireOnRelease: !!v }),
      hint: L('Vypnuto: kruhem se jen míří a střílí se klávesou F, mezerníkem nebo Enterem na kruhu.', 'Off: the ring only aims; fire with F, Space or Enter on the ring.') },
    { id: 'aimDots', type: 'toggle', label: L('Tělesa v kruhu (mapa)', 'Bodies in the ring (map)'), get: () => launcher.config.mapDots, set: v => launcher.configure({ mapDots: !!v }) },
  ] },
  { title: L('Odpalovací modul', 'Launcher module'), note: L('Kruh pro děla a katapult nad ním. Změny jsou vidět hned.', 'The cannon ring and the sling above it. Changes show immediately.'), items: [
    { id: 'lcScale', label: L('Velikost modulu', 'Module size'), min: 0.6, max: 1.6, step: 0.05, decimals: 2, unit: '×', get: () => launcher.config.scale, set: v => launcher.configure({ scale: v }) },
    { id: 'lcBottom', label: L('Odsazení od spodního okraje', 'Offset from bottom edge'), min: 0, max: 240, step: 2, unit: ' px', get: () => launcher.config.bottom, set: v => launcher.configure({ bottom: v }) },
    { id: 'lcX', label: L('Vodorovná poloha', 'Horizontal position'), min: -40, max: 40, unit: ' %', get: () => Math.round(launcher.config.offsetX * 100), set: v => launcher.configure({ offsetX: v / 100 }),
      hint: L('0 = uprostřed, záporné doleva, kladné doprava (např. pro palec na mobilu).', '0 = centered, negative = left, positive = right (e.g. for your thumb on a phone).') },
    { id: 'lcHang', label: L('Výška praku nad kruhem', 'Sling height above the ring'), min: 20, max: 90, unit: ' %', get: () => Math.round(launcher.config.hang * 100), set: v => launcher.configure({ hang: v / 100 }),
      hint: L('Kde visí žeton: podíl cesty od kruhu ke středu obrazovky. Výš = delší tah a jemnější míření dostřelu.', 'Where the coin hangs: share of the way from the ring to the screen center. Higher = longer draw and finer range control.') },
    { id: 'lcSpread', label: L('Rozpětí vidlice', 'Fork width'), min: 0.6, max: 1.8, step: 0.05, decimals: 2, unit: '×', get: () => launcher.config.spread, set: v => launcher.configure({ spread: v }) },
    { id: 'lcZone', label: L('Výška nabíjecí zóny', 'Charging zone height'), min: 0.2, max: 1.2, step: 0.05, decimals: 2, unit: '× R', get: () => launcher.config.zone, set: v => launcher.configure({ zone: v }),
      hint: L('Mezera mezi zlatou čarou (plný nátah, začíná odpor) a azurovou čarou (přeměna na fotony).', 'Gap between the gold line (full draw, resistance starts) and the cyan line (conversion to photons).') },
    { id: 'lcResist', label: L('Odpor v nabíjecí zóně', 'Charging zone resistance'), min: 10, max: 100, step: 5, unit: ' %', get: () => Math.round(launcher.config.resist * 100), set: v => launcher.configure({ resist: v / 100 }),
      hint: L('Kolik z pohybu prstu žeton v zóně udělá. Míň = větší odpor.', 'How much of your finger movement the coin follows in the zone. Less = more resistance.') },
    { id: 'lcHaptics', type: 'toggle', label: L('Vibrace při natahování (Android)', 'Vibration while drawing (Android)'), get: () => launcher.config.haptics, set: v => launcher.configure({ haptics: !!v }),
      hint: L('Cvaknutí na zlaté čáře, jemné tikání v zóně a delší zavibrování při přeměně na fotony. iPhone vibrace z prohlížeče nepodporuje.', 'A click at the gold line, gentle ticks in the zone and a longer buzz on conversion to photons. iPhone does not support vibration from the browser.') },
    { id: 'lcOpacity', label: L('Viditelnost modulu', 'Module opacity'), min: 30, max: 100, step: 5, unit: ' %', get: () => Math.round(launcher.config.opacity * 100), set: v => launcher.configure({ opacity: v / 100 }) },
  ] },
  { title: L('Děla a zaměřovač', 'Cannons and crosshair'), items: [
    { id: 'ckScale', label: L('Velikost děl', 'Cannon size'), min: 0.5, max: 1.6, step: 0.05, decimals: 2, unit: '×', get: () => cockpit.config.cannonScale, set: v => { cockpit.configure({ cannonScale: v }); placeRadar(); } },
    { id: 'ckInset', label: L('Odsazení děl od okrajů', 'Cannon inset from edges'), min: 0, max: 160, step: 2, unit: ' px', get: () => cockpit.config.inset, set: v => { cockpit.configure({ inset: v }); placeRadar(); } },
    { id: 'ckCross', label: L('Velikost zaměřovače', 'Crosshair size'), min: 0.6, max: 2, step: 0.05, decimals: 2, unit: '×', get: () => cockpit.config.crossScale, set: v => cockpit.configure({ crossScale: v }) },
    { id: 'ckFrame', type: 'toggle', label: L('Rám kabiny (vzpěry)', 'Cockpit frame (struts)'), get: () => cockpit.config.frame, set: v => cockpit.configure({ frame: !!v }) },
  ] },
  ...gameGroups.filter(g => g.key === 'tok' || g.key === 'rift'),
  { title: L('Plná verze', 'Full version'), note: L('Demo verze má na hrací ploše bannery. Po zaplacení (NEAR peněženka na testnetu, později Google Play v Android aplikaci) zmizí. Nabídku otevře i klik nebo tap na banner.', 'The demo has banners on the sheet. They disappear after payment (NEAR wallet on testnet, later Google Play in the Android app). Clicking or tapping a banner also opens the offer.'), items: [
    { id: 'premiumBtn', type: 'toggle', noSave: true, label: L('Plná verze odemčená', 'Full version unlocked'), get: () => paywall.premium, set: v => { if (v && !paywall.premium) paywall.open(); if (!v && paywall.premium) { paywall.reset(); banners.visible = true; } settingsUI?.refresh?.(); },
      hint: L('Zapnutím otevřeš nabídku placené verze. Vypnutím vrátíš demo (pro zkoušení).', 'Turning it on opens the full version offer. Turning it off brings back the demo (for testing).') },
  ] },
  { title: L('Výzkum', 'Research'), note: L('Hra může zaznamenat, jak ovládáš prak, kruh a klávesy (Canonical Behavior Transcript ze Spectral Chain). Záznam zůstává jen v tomto prohlížeči a nikam se neodesílá. Neobsahuje nic o zařízení ani o tobě, jen pohyby ukazatele a časy.', 'The game can record how you use the sling, the ring and the keys (Canonical Behavior Transcript from Spectral Chain). The transcript stays in this browser and is never sent anywhere. It contains nothing about your device or you, only pointer movements and timings.'), items: [
    { id: 'cbtOn', type: 'toggle', label: L('Zaznamenávat ovládání (CBT)', 'Record controls (CBT)'), get: () => REC.on, set: v => { REC.on = !!v; try { localStorage.setItem('lhc.cbt', REC.on ? '1' : '0'); } catch (e) { } } },
    { id: 'cbtDl', type: 'button', noSave: true, label: L('Záznam této hry', 'Transcript of this game'), button: L('Stáhnout (.json)', 'Download (.json)'), get: () => null, set: () => { }, run: () => downloadTranscript(),
      hint: L('Balíček LHC-CBT-PACK-1: bajty záznamu QFC-CBT-V1 v base64 a jejich otisk SHA-256. Hodí se pro porovnání lidí a botů z AI arény.', 'An LHC-CBT-PACK-1 pack: the QFC-CBT-V1 transcript bytes in base64 and their SHA-256 digest. Useful for comparing humans with AI arena bots.') },
  ] },
  { title: L('Hudba a zvuky', 'Music and sounds'), items: [
    { id: 'sfxOn', type: 'toggle', label: L('Zvukové efekty', 'Sound effects'), get: () => sfx.on, set: v => { sfx.on = !!v; },
      hint: L('Výstřel z praku, laserová děla, přechod na další úroveň a oprava plochy.', 'Sling shot, laser cannons, next level and sheet repair.') },
    { id: 'sfxVol', label: L('Hlasitost zvuků', 'Sound volume'), min: 0, max: 100, step: 1, unit: ' %', get: () => Math.round(sfx.volume * 100), set: v => { sfx.volume = v / 100; } },
    { id: 'musVol', label: L('Hlasitost hudby', 'Music volume'), min: 0, max: 100, step: 1, unit: ' %', get: () => Math.round(music.volume * 100), set: v => { music.volume = v / 100; } },
    { id: 'musAuto', type: 'toggle', label: L('Hrát hudbu při spuštění hry', 'Play music when the game starts'), get: () => music.auto, set: v => { music.auto = !!v; },
      hint: L('Prohlížeč pustí zvuk až po prvním kliknutí nebo dotyku ve hře. Tlačítko Hudba v horní liště hudbu pustí nebo zastaví.', 'The browser plays sound only after your first click or touch in the game. The Music button in the top bar starts or stops the music.') },
  ] },
  { title: 'HUD', items: [
    { id: 'hudRadar', type: 'toggle', label: L('Radar (minimapa)', 'Radar (minimap)'), get: () => radar.config.on, set: v => { radar.configure({ on: !!v }); placeRadar(); },
      hint: L('Celá plocha shora vlevo od odpalovacího modulu, otáčí se s pohledem. Na úzkém displeji se nevejde a skryje se.', 'The whole sheet from above, left of the launcher module, rotating with the view. Hidden on narrow screens where it does not fit.') },
    { id: 'hudMag', type: 'toggle', label: L('Zásobník (vpravo od modulu)', 'Magazine (right of the module)'), get: () => magazine.config.on, set: v => { magazine.configure({ on: !!v }); placeRadar(); },
      hint: L('Co je nabité v praku, co přijde potom a šance jednotlivých nábojů.', 'What is loaded in the sling, what comes next and the odds of each ammo.') },
    { id: 'hudTok', type: 'toggle', label: L('Quansistory (sloupec vpravo)', 'Quansistors (right column)'), get: () => tokens.config.on, set: v => { tokens.configure({ on: !!v }); placeRadar(); } },
    { id: 'hudRadarSize', label: L('Velikost panelů (radar, zásobník)', 'Panel size (radar, magazine)'), min: 0.6, max: 1.6, step: 0.05, decimals: 2, unit: '×', get: () => radar.config.size, set: v => { radar.configure({ size: v }); magazine.configure({ size: v }); placeRadar(); } },
  ] },
  { title: L('Grafika', 'Graphics'), items: [
    { id: 'themeKokpitV2', type: 'toggle', label: L('Modrošedý kokpit (zkušební)', 'Blue-grey cockpit (experimental)'), get: () => THEME.kokpit, set: v => applyTheme(!!v),
      hint: L('Masivní rám kabiny s displeji a jantarovými kontrolkami, tlumené ocelově modré barvy a opar. Vypnuto = původní neonový vzhled.', 'A massive cockpit frame with displays and amber lights, muted steel-blue colors and haze. Off = the original neon look.') },
    { id: 'glow', type: 'toggle', label: L('Záře hran a světelné záblesky', 'Edge glow and light flashes'), get: () => glowOn, set: v => { glowOn = !!v; },
      hint: L('Na slabším počítači nebo mobilu vypnutí výrazně zvedne FPS.', 'On a weaker computer or phone, turning this off greatly raises FPS.') },
    { id: 'bloom', label: L('Síla záře', 'Glow strength'), min: 0, max: 2.5, step: 0.05, decimals: 2, get: () => glow.bloom.strength, set: v => { glow.bloom.strength = v; } },
    { id: 'sheetDark', label: L('Barva plochy: modrá → černá', 'Sheet color: blue → black'), min: 0, max: 100, step: 5, unit: ' %', get: () => Math.round(LOOK.dark * 100), set: v => { setLook({ dark: v / 100 }); queueRecolor(); },
      hint: L('0 % = původní tmavě modrá, 100 % = skoro černá.', '0 % = the original dark blue, 100 % = almost black.') },
    { id: 'gridColor', type: 'choice', label: L('Barva mřížky', 'Grid color'), options: [['blue', L('Modrá', 'Blue')], ['green', L('Zelená (neon)', 'Green (neon)')], ['cyan', L('Azurová', 'Cyan')], ['amber', L('Zlatá', 'Amber')], ['violet', L('Fialová', 'Violet')], ['white', L('Bílá', 'White')]],
      get: () => LOOK.grid, set: v => { setLook({ grid: v }); queueRecolor(); }, hint: L('Mřížka svítí víc se zapnutou září hran (síla záře výše).', 'The grid glows more with edge glow on (glow strength above).') },
    { id: 'grid', label: L('Jas mřížky prostoru', 'Space grid brightness'), min: 0.1, max: 1, step: 0.05, decimals: 2, get: () => gridMat.opacity, set: v => { gridMat.opacity = v; } },
    { id: 'fill', label: L('Výplň stěn těles', 'Body face fill'), min: 0, max: 0.4, step: 0.02, decimals: 2, get: () => SHAPE_BY_VALUE[2].mat.fill.opacity,
      set: v => { for (const k of Object.keys(SHAPE_BY_VALUE)) if (+k !== 64) SHAPE_BY_VALUE[k].mat.fill.opacity = v; } },
  ] },
], { fixed: FIXED, lockedNote: L(`Obtížnost ${MODE_LABEL[MODE]}: tyto parametry jsou pevně dané. Vlastní nastavení si zvol v menu jako CUSTOM.`, `Difficulty ${MODE_LABEL[MODE]}: these parameters are fixed. Choose CUSTOM in the menu for your own settings.`),
  onChange: it => { if (it === null && MODE === 'custom') resetProfile(); } });

/* ---------- Editor tvaru plochy (spline) a horizont událostí ---------- */
let profileDirty = false, profileCommit = false;
const profileSec = document.createElement('section');
profileSec.className = 'st-group';
profileSec.innerHTML = L(`<h3>Tvar plochy</h3>
  <p class="st-note">Táhni body křivky. Dvojklik do grafu přidá bod, dvojklik na bod ho smaže. Oranžová čára je <b>horizont událostí</b>: co klesne pod ni, přestane se srážet a zmizí. Žlutá čárkovaná je osa hrdla, tenká svislá čára ukazuje současné hrdlo.</p>`,
  `<h3>Sheet shape</h3>
  <p class="st-note">Drag the curve points. Double-click the graph to add a point, double-click a point to delete it. The orange line is the <b>event horizon</b>: whatever sinks below it stops colliding and disappears. The yellow dashed line is the throat axis, the thin vertical line shows the current throat.</p>`);
const peHost = document.createElement('div');
profileSec.appendChild(peHost);
const peReset = document.createElement('button');
peReset.type = 'button'; peReset.className = 'gbtn'; peReset.textContent = L('Výchozí tvar plochy', 'Default sheet shape');
profileSec.appendChild(peReset);
document.getElementById('settings-body').prepend(profileSec);
if (MODE !== 'custom') {   // EASY/MEDIUM/HARD: tvar plochy je pevný (výchozí)
  profileSec.classList.add('st-locked');
  profileSec.querySelector('h3').textContent = L('Tvar plochy · pevné', 'Sheet shape · fixed');
  profileSec.querySelector('.st-note').textContent = L(`Obtížnost ${MODE_LABEL[MODE]} hraje na výchozí ploše. Vlastní tvar plochy si nastavíš v menu v režimu CUSTOM.`, `Difficulty ${MODE_LABEL[MODE]} plays on the default sheet. Set your own sheet shape in the menu in CUSTOM mode.`);
  peHost.style.pointerEvents = 'none'; peHost.inert = true; peReset.hidden = true;
}
accordion(document.getElementById('settings-body'));   // skupiny nastavení jako rozbalovací sekce
// Těleso hodnoty v položené na ose hrdla: kde by leželo (v jednotkách profilu, tj. 1. úrovně).
// Leží na ploše (pro r < hrdlo na víku hrdla), střed = nejvyšší z (výška pod ním + výška kulového vrchlíku).
function restingObj(v) {
  const s = sizeOf(v), k = FUNNEL.scale || 1;
  let cy = -Infinity;
  for (let i = 0; i <= 40; i++) {
    const r = s * i / 40, floor = r < FUNNEL.rh ? lidY() : heightAt(r);
    cy = Math.max(cy, floor + Math.sqrt(Math.max(0, s * s - r * r)));
  }
  const fits = s < FUNNEL.rh;                         // projde hrdlem (víko ho nezastaví jen u koulí)
  return { label: String(v), r: s, cy: cy / k, ry: s / k, wcy: cy, color: SHAPE_BY_VALUE[v].color,
    note: L(`r ${s.toFixed(2)} · hrdlo ${FUNNEL.rh.toFixed(2)}${fits ? ' · projde' : ' · neprojde'}`, `r ${s.toFixed(2)} · throat ${FUNNEL.rh.toFixed(2)}${fits ? ' · fits' : ' · does not fit'}`) };
}
const editor = createProfileEditor(peHost, {
  getPoints: () => PROFILE.points, getHorizon: () => PROFILE.horizon,
  rToU, uToR, R: FUNNEL.R, evalAt: r => profileAt(r), markers: () => ({ rh: FUNNEL.rh, objs: [restingObj(32)], worldAt: r => (r < FUNNEL.rh ? lidY() - 3 : heightAt(r)), lidY: lidY() }),
  onChange(points, horizon, commit) {
    setProfile(points, horizon);
    profileDirty = true; if (commit) profileCommit = true;
    if (commit && MODE === 'custom') { try { localStorage.setItem('lhc.profile', JSON.stringify({ points: PROFILE.points, horizon: PROFILE.horizon })); } catch (e) { } }
  },
});
function resetProfile() {
  setProfile(DEFAULT_PROFILE, DEFAULT_HORIZON);
  editor.set(PROFILE.points, PROFILE.horizon);
  profileDirty = profileCommit = true;
  try { localStorage.removeItem('lhc.profile'); } catch (e) { }
}
peReset.onclick = resetProfile;
// přestavba plochy: grafika hned (max. jednou za snímek), fyzika po puštění bodu
function applyProfileChanges() {
  if (!profileDirty) return;
  profileDirty = false;
  refreshFunnelVisual();
  if (profileCommit && world) { profileCommit = false; refreshFunnelPhysics(); }
}

const setSettings = open => {
  settingsEl.hidden = !open; settingsBtn.setAttribute('aria-expanded', String(open));
  if (open) setPanel(false);
};
settingsBtn.onclick = () => setSettings(settingsEl.hidden);
document.getElementById('b-settings-close').onclick = () => setSettings(false);
addEventListener('keydown', e => { if (e.key === 'Escape' && !settingsEl.hidden) setSettings(false); });

/* ---------- Rozhraní ---------- */
const $ = id => document.getElementById(id);
function updateHud() {
  $('score').textContent = score;
  $('sunk').textContent = sunk;
  const n = liveCount();
  $('count').textContent = `${n}/${MAX_PIECES}`;
  $('meter').style.setProperty('--fill', Math.min(1, n / MAX_PIECES));
  const nv = ammoQueue[0] ?? 2, s = SHAPE_BY_VALUE[nv];
  $('next').innerHTML = `<i style="--dot:${s.color}"></i><b>${nv}</b> ${s.name}`;
  $('best').textContent = Math.max(best, score);
  $('mode').textContent = MODE_LABEL[MODE];
  $('goal').textContent = L(`${target()} · úroveň ${level + 1}/${LEVELS.length}`, `${target()} · level ${level + 1}/${LEVELS.length}`);
}
let toastTimer;
function toast(msg, color) {
  const t = $('toast'); t.textContent = msg; t.style.setProperty('--dot', color); t.hidden = false;
  t.classList.remove('pop'); void t.offsetWidth; t.classList.add('pop');
  clearTimeout(toastTimer); toastTimer = setTimeout(() => (t.hidden = true), 2200);
}

// tabulka receptů
const recipesEl = $('recipes');
for (const [a, b, r] of RECIPES) {
  const row = document.createElement('li');
  row.dataset.a = a; row.dataset.b = b;
  const chip = v => `<span class="chip" style="--dot:${SHAPE_BY_VALUE[v].color}"><i></i>${v}</span>`;
  row.innerHTML = `${chip(a)}<span class="op">+</span>${chip(b)}<span class="op">→</span>${chip(r)}<span class="nm">${SHAPE_BY_VALUE[r].collider === 'ball' ? L(`${LEVELS.findIndex(l => l.target === r) + 1}. cíl`, `target ${LEVELS.findIndex(l => l.target === r) + 1}`) : SHAPE_BY_VALUE[r].name}</span>`;
  recipesEl.appendChild(row);
}
function highlightRecipes(v) {
  for (const li of recipesEl.children) {
    const on = v != null && (+li.dataset.a === v || +li.dataset.b === v);
    li.classList.toggle('on', on);
    li.classList.toggle('dim', v != null && !on);
  }
}

/* ---------- Smyčka ---------- */
function resize() {
  const r = vp.getBoundingClientRect();
  renderer.setSize(r.width, r.height, false);
  glow.setSize(r.width, r.height);
  camera.aspect = r.width / r.height; camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(vp);

let last = performance.now(), acc = 0, frames = 0, fpsT = last, stepMs = 0;
// rychlost simulace (1 = normální; bot ji může zvýšit pro rychlejší testování)
const SIM = { speed: 1 };
let gameTime = 0, simLost = 0;
function loop(now) {
  requestAnimationFrame(loop);
  if (demoPaused) { last = now; return; }             // náhled není vidět: nic nepočítat ani nekreslit
  const rdt = Math.min(0.1, (now - last) / 1000); last = now;
  const dt = rdt * SIM.speed;                         // herní čas (bot může hru zrychlit)
  gameTime += dt; simLost = 0;
  updateView(dt);
  updatePlat();
  cockpit.tick(rdt);
  launcher.tick(rdt);
  updateAim();
  if (frames % 4 === 0) updateMap();
  if (frames % 2 === 0) updateRadar(now);
  if (frames % 10 === 0) updateRepairButton();

  if (world && !over) {
    // držené těleso: řízení rychlostí k cíli (fyzika zůstává věrohodná, tělesa se odstrkují)
    if (held && !held.dead) {
      const t = held.rb.translation();
      const v = new THREE.Vector3(holdTarget.x - t.x, holdTarget.y - t.y, holdTarget.z - t.z).multiplyScalar(12);
      if (v.length() > 28) v.setLength(28);
      held.rb.setLinvel(v, true);
      const w = held.rb.angvel(); held.rb.setAngvel({ x: w.x * 0.9, y: w.y * 0.9, z: w.z * 0.9 }, true);
    }
    acc += dt; let steps = 0; const t0 = performance.now(); simLost = 0;
    while (acc >= DT && steps < 3 * SIM.speed) {
      for (const p of pieces) if (p.flat) alignFlat(p);   // žetony vždy naplocho
      world.step();
      abyssPull();
      collectMerges();
      applyMerges(now);
      surfaceGuard();
      landFlyingCoins();
      acc -= DT; steps++;
    }
    // fyzika nestíhá (pomalý počítač nebo velké zrychlení): herní čas jde jen o to, co se opravdu nasimulovalo
    if (steps >= 3 * SIM.speed) { simLost = acc; gameTime -= acc; acc = 0; }
    if (steps) stepMs = stepMs * 0.9 + ((performance.now() - t0) / steps) * 0.1;
    checkSunk();
  }

  // synchronizace grafiky
  for (const p of pieces) {
    const t = p.rb.translation(), q = p.rb.rotation();
    const grow = Math.min(1, (now - p.born) / 180);
    p.obj.position.set(t.x, t.y, t.z); p.obj.quaternion.set(q.x, q.y, q.z, q.w);
    p.obj.scale.setScalar(p.size * (0.6 + 0.4 * grow));
    p.label.position.set(t.x, t.y + (p.value === 2 ? p.size * 0.35 : 0), t.z);
  }

  bursts.update(dt);
  rifts.update(dt, now);
  updateTokens(Math.max(0, dt - simLost), now);
  updateLevelAnim(dt);
  updateBeams(dt);
  applyProfileChanges();
  updatePlatform();
  if (frames % 6 === 0) updateCrosshair();
  const hasTarget = pieces.some(p => p.value === target());
  holeMat.opacity = hasTarget ? 0.6 + 0.4 * Math.sin(now / 180) : 0.9;
  core.material.opacity = 0.55 + 0.25 * Math.sin(now / 700) + (hasTarget ? 0.3 : 0);

  if (glowOn) glow.render(); else renderer.render(scene, camera);
  renderer.autoClear = false; renderer.clearDepth();
  renderer.render(labelScene, camera);      // čísla přes záři, ostrá
  renderer.autoClear = true;
  frames++;
  if (now - fpsT > 500) {
    $('fps').textContent = Math.round(frames * 1000 / (now - fpsT));
    $('stepms').textContent = stepMs.toFixed(2);
    updateHud();
    frames = 0; fpsT = now;
  }
}

/* ---------- Start ---------- */
resize();

/* ---------- Uložení a načtení hry ---------- */
// Stav hry jako JSON: úroveň, skóre, Quansistory (i vyzařování díry), náboje, tělesa (poloha, rychlost,
// natočení), trhliny, natočení plochy. Uloží se do prohlížeče (pokračovat z launcheru) a stáhne jako soubor.
// Načíst jde z launcheru (Nahrát hru) přes play/?load=slot nebo ?load=import (soubor předaný launcherem).
const SAVE_KEY = 'lhc.save.slot', IMPORT_KEY = 'lhc.save.import', SAVE_VER = 1;
function saveState() {
  return {
    game: 'light-hedron-collider', version: SAVE_VER, savedAt: new Date().toISOString(),
    mode: MODE, rules: { ...RULES }, level, score, sunk, merges, charge, ammo: ammoQueue.slice(), viewAz: view.az,
    tokens: tokens.exportState(), rifts: rifts.exportState(),
    pieces: pieces.filter(p => !p.swallowed && !p.dead).map(p => {
      const t = p.rb.translation(), v = p.rb.linvel(), q = p.rb.rotation(), r = n => +n.toFixed(3);
      return { v: p.value, p: [r(t.x), r(t.y), r(t.z)], vel: [r(v.x), r(v.y), r(v.z)], q: [r(q.x), r(q.y), r(q.z), r(q.w)] };
    }),
  };
}
function loadState(st) {
  if (!st || st.game !== 'light-hedron-collider') throw new Error(L('Soubor není uložená hra Light Hedron Collider', 'The file is not a saved Light Hedron Collider game'));
  if (st.rules) Object.assign(RULES, st.rules);
  newGame();
  for (const p of [...pieces]) despawn(p);
  level = Math.max(0, Math.min(LEVELS.length - 1, st.level | 0));
  setFunnel(LEVELS[level]); refreshFunnelVisual(); refreshFunnelPhysics(); editor.redraw();
  score = st.score | 0; sunk = st.sunk | 0; merges = st.merges | 0;
  tokens.importState(st.tokens); setCharge(st.charge | 0);
  if (Array.isArray(st.ammo) && st.ammo.length) { ammoQueue = st.ammo.filter(v => SHAPE_BY_VALUE[v]).slice(0, 4); refillAmmo(true); }
  if (typeof st.viewAz === 'number') view.az = st.viewAz;
  rifts.importState(st.rifts);
  for (const b of st.pieces || []) {
    if (!SHAPE_BY_VALUE[b.v]) continue;
    const p = spawn(b.v, new THREE.Vector3(...b.p), new THREE.Vector3(...(b.vel || [0, 0, 0])));
    if (b.q && !p.flat) p.rb.setRotation({ x: b.q[0], y: b.q[1], z: b.q[2], w: b.q[3] }, true);
  }
  updateHud();
}
function saveGame() {
  if (!world) return;
  const st = saveState();
  try { localStorage.setItem(SAVE_KEY, JSON.stringify(st)); } catch (e) { }
  const blob = new Blob([JSON.stringify(st, null, 1)], { type: 'application/json' });
  const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: `lhc-save-${st.savedAt.slice(0, 19).replace(/[:T]/g, '-')}.json` });
  document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  toast(L('Hra uložena (prohlížeč + soubor JSON)', 'Game saved (browser + JSON file)'), '#7CFFB2');
}
document.getElementById('b-save').onclick = saveGame;
// automatické uložení při odchodu ze stránky (pokračovat z launcheru)
let arenaActive = false;
addEventListener('pagehide', () => { if (world && !over && !bot?.running && !arenaActive) try { localStorage.setItem(SAVE_KEY, JSON.stringify(saveState())); } catch (e) { } });
// spuštění podle adresy z launcheru: ?load=slot | ?load=import | ?bot=1
function startFromUrl() {
  const q = new URLSearchParams(location.search);
  const load = q.get('load');
  if (load) {
    try {
      const raw = localStorage.getItem(load === 'import' ? IMPORT_KEY : SAVE_KEY);
      if (!raw) throw new Error(L('Uložená hra nenalezena', 'Saved game not found'));
      loadState(JSON.parse(raw));
      if (load === 'import') localStorage.removeItem(IMPORT_KEY);
      toast(L('Hra načtena', 'Game loaded'), '#7CFFB2');
    } catch (e) { toast(L('Načtení hry selhalo: ', 'Loading the game failed: ') + (e?.message || e), '#FFB86B'); }
  }
  // AI aréna: ?arena=<slot> (ukázka) nebo &bench=1 (benchmark s pevnými hrami)
  const arenaSlot = q.get('arena');
  if (DEMO) {   // náhled: vyšší pohled, ať plocha vyplní vysoký rámeček; bez záře, bannerů a rámu kabiny
    glowOn = false; banners.visible = false; VIEW.elevationDeg = 46; VIEW.distance = 44;
    launcher.configure({ scale: 0.8 }); cockpit.configure({ frame: false, compact: true, compactTop: 0 }); placeRadar();
  }
  if (arenaSlot) {
    arenaActive = true;
    import('./arena-runtime.js').then(m => m.startArena(agent, { slotId: arenaSlot, bench: q.get('bench') === '1', demo: DEMO, mode: MODE, host: vp, toast }))
      .catch(e => { console.error(e); toast(L('Arénu se nepodařilo spustit: ', 'Could not start the arena: ') + (e?.message || e), '#FFB86B'); });
  }
  if (q.get('bot') === '1') { bot.togglePanel(true); document.getElementById('b-bot').setAttribute('aria-expanded', 'true'); bot.setRunning(true); }
  if (load || q.has('bot') || arenaSlot) history.replaceState(null, '', location.pathname + '?mode=' + MODE + location.hash);
}

/* ---------- Rozhraní pro bota / AI (agent) ---------- */
// Bot hraje jako hráč: prak ovládá simulovaným tahem myši/prstu přes odpalovací modul, míří jezdcem
// v kruhu (jako klepnutí do kruhu), střílí jako klávesa F a opravuje tlačítkem OPRAVA.
// Pozorování (observe) je čistý snímek stavu; stejné rozhraní poslouží i učící se AI (window.lhcAgent).
const agent = {
  get time() { return gameTime - gameStart; },
  get over() { return over; },
  sim: SIM,
  observe() {
    aimAxes();
    const cam = camera.position;
    return {
      t: gameTime - gameStart, over, endReason, score, sunk, merges, level: level + 1, levels: LEVELS.length, target: target(),
      tokens: tokens.tokens, tokenRate: tokens.rate(), tokensLeft: tokens.remaining(), tokensEarned: tokens.total,
      charge, chargeMax: CHARGE.max, convertCost: tokens.config.convertCost, repairCost: REPAIR.cost,
      ammo: ammoQueue.slice(), maxPieces: MAX_PIECES, destroyed: rifts.destroyed,
      funnel: { R: FUNNEL.R, lip: FUNNEL.lip, rh: FUNNEL.rh, depth: FUNNEL.depth },
      plat: { x: PLAT.x, y: PLAT.y, z: PLAT.z },
      camera: { x: cam.x, y: cam.y, z: cam.z }, fwd: { x: _fwd.x, z: _fwd.z }, right: { x: _right.x, z: _right.z },
      sling: { maxDist: SLING.MAX_DIST, ready: launcher.slingGeom().ready && launcher.mode === 'idle', enabled: Math.hypot(PLAT.x, PLAT.z) > FUNNEL.lip + 0.2 },
      repair: { wedgeHasRifts: rifts.hasInWedge(repairWedge()), busy: rifts.repairing },
      lastLaserHit,
      pieces: pieces.filter(p => !p.swallowed && !p.dead).map(p => {
        const t = p.rb.translation(), v = p.rb.linvel();
        return { id: p.col.handle, value: p.value, x: t.x, y: t.y, z: t.z, vx: v.x, vz: v.z, size: p.size, flying: !!p.flying, inRift: rifts.inRift(t.x, t.z) };
      }),
    };
  },
  inRift: (x, z, pad = 0) => rifts.inRift(x, z, pad),
  mergeResult: (a, b) => mergeResult(a, b),
  heightAt: r => heightAt(r),
  /** zaměřit jezdce v kruhu na bod plochy (jako klepnutí do kruhu na to místo) */
  aimWorld(x, z) {
    aimAxes();
    const r = Math.hypot(x, z);
    if (r < 1e-3) { launcher.setAim(0.002, 0); updateAim(); return; }   // přesně do středu (0, 0 = klidová poloha)
    const dx = x / r, dz = z / r, vr = Math.max(0.5, visibleR(dx, dz)), l = Math.min(1, r / vr);
    launcher.setAim((dx * _right.x + dz * _right.z) * l, -(dx * _fwd.x + dz * _fwd.z) * l);
    updateAim();
  },
  /** výstřel z děl (klávesa F); vrátí true, pokud děla vystřelila */
  fire() { const c = charge; fireLasers(); return charge < c; },
  /** oprava segmentu (tlačítko OPRAVA) */
  repair() { const t = tokens.tokens; repairSegment(); return tokens.tokens < t; },
  newGame(seed) { newGame(typeof seed === 'number' ? { seed } : undefined); },
  /** stav pro boty AI arény (čistý JSON, verze API LHC-BOT-1) */
  botState() {
    aimAxes();
    const cam = camera.position, r2 = v => Math.round(v * 100) / 100;
    const slingReady = launcher.slingGeom().ready && launcher.mode === 'idle', slingOn = Math.hypot(PLAT.x, PLAT.z) > FUNNEL.lip + 0.2;
    return {
      api: 'LHC-BOT-1', t: r2(gameTime - gameStart), level: level + 1, levels: LEVELS.length, target: target(),
      score, sunk, merges, maxPieces: MAX_PIECES,
      tokens: r2(tokens.tokens), tokenRate: r2(tokens.rate()), tokensPending: r2(tokens.remaining()),
      charge, chargeMax: CHARGE.max, chargePerConvert: CHARGE.perCoin, convertCost: tokens.config.convertCost, repairCost: REPAIR.cost,
      ammo: ammoQueue.slice(),
      sling: { ready: slingReady, enabled: slingOn, maxDist: SLING.MAX_DIST, x: r2(PLAT.x), z: r2(PLAT.z) },
      view: { camX: r2(cam.x), camZ: r2(cam.z), fwdX: r2(_fwd.x), fwdZ: r2(_fwd.z), spinDegPerSec: VIEW.spinDegPerSec },
      funnel: { R: FUNNEL.R, lip: r2(FUNNEL.lip), hole: r2(FUNNEL.rh) },
      destroyedPct: r2(rifts.destroyed * 100),
      repairAvailable: !rifts.repairing && rifts.hasInWedge(repairWedge()) && tokens.can(REPAIR.cost),
      rifts: rifts.centers().map(([x, z]) => ({ x: r2(x), z: r2(z) })),
      pieces: pieces.filter(p => !p.swallowed && !p.dead).map(p => {
        const t = p.rb.translation(), v = p.rb.linvel();
        return { id: p.col.handle, value: p.value, x: r2(t.x), z: r2(t.z), r: r2(Math.hypot(t.x, t.z)), vx: r2(v.x), vz: r2(v.z), size: r2(p.size),
          flying: !!p.flying, inRift: rifts.inRift(t.x, t.z), slingReach: slingOn && !!agent.planSling(t.x, t.z), ...agent.pointerHints(t.x, t.z) };
      }),
      ui: agent.ui(),
    };
  },
  /**
   * Plán tahu prakem do bodu plochy: posun ukazatele { dx, dy } (px) a poměr nátahu,
   * nebo null, když je cíl mimo dosah (za prakem nebo dál než plný dostřel).
   */
  /** záznam pro runtime arény: bot = jméno (záznam řídí runtime) nebo null (hráč) */
  rec: {
    setBot(name) { REC.bot = name || null; },
    input(kind, phase, cx, cy, tMs) { recInput(kind, phase, cx, cy, tMs); },
    key(type, code, tMs) { recKey(type, code, tMs); },
    finish: () => rec.finish(),
    EV,
  },
  /** geometrie ovládání pro pointer mode: souřadnice okna hry 0…1 (x podle šířky, y podle výšky) */
  ui() {
    const r = vp.getBoundingClientRect(), g = launcher.slingGeom(), ring = launcher.ringScreen(), kn = launcher.knobScreen();
    const nx = x => Math.round((x - r.left) / r.width * 1e4) / 1e4, ny = y => Math.round((y - r.top) / r.height * 1e4) / 1e4;
    return { w: Math.round(r.width), h: Math.round(r.height), cradle: [nx(g.x), ny(g.y)], grabPx: Math.round(launcher.hitRadius()),
      fullDrawY: ny(g.U), convertY: ny(g.L), resist: g.resist,
      ring: { x: nx(ring.x), y: ny(ring.y), rPx: Math.round(ring.r), mapRPx: Math.round(ring.rk) }, knob: [nx(kn.x), ny(kn.y)], sens: launcher.sensitivity };
  },
  /** klientské souřadnice z normovaných 0…1 */
  fromUnit(x, y) { const r = vp.getBoundingClientRect(); return [r.left + x * r.width, r.top + y * r.height]; },
  /** kde je těleso v ovládacím kruhu (normované souřadnice okna) a kam pustit prak, aby na něj dopadl */
  pointerHints(x, z) {
    aimAxes();
    const r = vp.getBoundingClientRect(), ring = launcher.ringScreen(), out = {};
    const nx = v => Math.round((v - r.left) / r.width * 1e4) / 1e4, ny = v => Math.round((v - r.top) / r.height * 1e4) / 1e4;
    const d = Math.hypot(x, z);
    if (d < 1e-3) out.aim = [nx(ring.x), ny(ring.y)];
    else {
      const dx = x / d, dz = z / d, vr = Math.max(0.5, visibleR(dx, dz));
      if (d <= vr + 0.5) { const k = Math.min(1, d / vr); out.aim = [nx(ring.x + (dx * _right.x + dz * _right.z) * k * ring.rk), ny(ring.y - (dx * _fwd.x + dz * _fwd.z) * k * ring.rk)]; }
    }
    const pl = agent.planSling(x, z);
    if (pl) out.release = [nx(pl.geom.x + pl.dx), ny(pl.geom.y + pl.dy)];
    return out;
  },
  planSling(x, z) {
    const g = launcher.slingGeom();
    const fwd = new THREE.Vector3(PLAT.x - camera.position.x, 0, PLAT.z - camera.position.z).normalize();
    const right = new THREE.Vector3(-fwd.z, 0, fwd.x);
    const dxw = x - PLAT.x, dzw = z - PLAT.z, d = Math.hypot(dxw, dzw);
    if (d < 0.5) return null;
    const ux = dxw / d, uz = dzw / d, ratio = d / SLING.MAX_DIST;
    if (ratio > 1) return null;
    const f = ux * fwd.x + uz * fwd.z, rgt = ux * right.x + uz * right.z;
    const Lp = Math.max(ratio, SLING.MIN + 0.02) * g.maxPull;
    const dx = -rgt * Lp, dy = f * Lp;
    if (dy < -25) return null;                       // za prak (nahoru) se natáhnout nedá
    return { dx, dy, ratio, geom: g };
  },
  launcher,
};
window.lhcAgent = agent;
const bot = createBot(agent, { host: vp, toast: (m, c) => toast(m, c) });
const botBtn = document.getElementById('b-bot');
botBtn.onclick = () => { const open = bot.togglePanel(); botBtn.setAttribute('aria-expanded', String(open)); };
if (location.hash === '#ladeni') setTimeout(() => { if (window.hra) window.hra.bot = bot; }, 0);

requestAnimationFrame(loop);
$('status').hidden = false; $('status').textContent = L('Načítám fyziku…', 'Loading physics…');
try {
  const mod = await import('../vendor/rapier/rapier.es.js');
  R = mod.default ?? mod;
  await R.init();
  $('status').hidden = true;
  newGame();
  startFromUrl();
  // ladění: otevři stránku s #ladeni a v konzoli máš window.hra.spawn(64, x, y, z)
  if (location.hash === '#ladeni') window.hra = { ys: () => pieces.map(q => [q.value, +q.rb.translation().y.toFixed(2), !!q.swallowed]), noglow: () => { glowOn = false; }, setHorizon: h => { setProfile(null, h); editor.set(PROFILE.points, PROFILE.horizon); profileDirty = true; }, resetProfile: () => resetProfile(), slingTest: (ratio, dxs = 0) => { const sp = slingScreen(); if (!launcher.debug.press('cradle', sp.x, sp.y)) return null; const L = (launcher.ringScreen().y - sp.y) * ratio; dbgPt = { x: sp.x + dxs, y: sp.y + L }; launcher.debug.move(dbgPt.x, dbgPt.y); return { start: sling.start.toArray().map(x => +x.toFixed(1)), land: sling.land.toArray().map(x => +x.toFixed(1)) }; }, slingRelease: () => launcher.debug.release(), slingMove: (dx, dy) => { dbgPt = { x: dbgPt.x + dx, y: dbgPt.y + dy }; launcher.debug.move(dbgPt.x, dbgPt.y); return { mode: launcher.mode, cross: cockpit.cross(), charge }; }, hit: () => { const h = aimHit(); return h ? { piece: h.piece?.value ?? null, p: h.point.toArray().map(x => +x.toFixed(2)) } : null; }, cam: () => [camera.position.toArray().map(x => +x.toFixed(1)), PLAT.toArray().map(x => +x.toFixed(1))], fire: () => fireLasers(), aim: (dx, dy) => launcher.nudge(dx, dy), aimAt: (nx, ny) => launcher.setAim(nx, ny), spin: v => { VIEW.spinDegPerSec = v; }, clear: () => { for (const q of [...pieces]) despawn(q); }, push: (v, x, z, vx, vz) => { const q = spawn(v, new THREE.Vector3(x, heightAt(Math.hypot(x, z)) + 0.2, z), new THREE.Vector3(vx, 0, vz)); return q.rb.handle; }, levelUp: () => levelUp(), level: () => ({ level, ...FUNNEL }), coins: () => pieces.filter(q => q.flat).map(q => { const t = q.rb.translation(); return [+t.x.toFixed(1), +t.y.toFixed(2), +t.z.toFixed(1), +heightAt(Math.hypot(t.x, t.z)).toFixed(2)]; }), tilt: () => pieces.filter(q => q.flat).map(q => { const r = q.rb.rotation(), t = q.rb.translation(); const up = new THREE.Vector3(0, 1, 0).applyQuaternion(new THREE.Quaternion(r.x, r.y, r.z, r.w)); return +(up.angleTo(normalAt(t.x, t.z)) * 180 / Math.PI).toFixed(2); }), spawn: (v, x = 0, y = 6, z = 0) => spawn(v, new THREE.Vector3(x, y, z)), charge: n => { if (n != null) setCharge(n); return charge; }, tok: n => { if (n != null) tokens.tokens = n; return +tokens.tokens.toFixed(2); }, emit: v => tokens.emit(v), rate: () => +tokens.rate().toFixed(3), repair: () => repairSegment(), plat: () => [PLAT.x, PLAT.z], premium: v => { if (v === false) { paywall.reset(); banners.visible = true; } return paywall.premium; }, paywall: () => paywall.open(), banners: () => banners.count, rift: (v = 32, x = 8, z = 0) => rifts.trigger(v, new THREE.Vector3(x, 0, z)), riftAt: (x, z) => rifts.debugOpenAt(x, z), riftCount: () => rifts.count, repairLocked: () => rifts.locked(repairWedge()), destroyed: () => +(rifts.destroyed * 100).toFixed(2), inRift: (x, z) => rifts.inRift(x, z), dbgTok: () => ({ emitting: tokens.emitting, charge, can: tokens.can(1), flying: pieces.filter(p => p.flying).length, over }), slingSpeed: k => { SLING.SPEED = k; }, pos: () => pieces.map(q => { const t = q.rb.translation(); return [q.value, +t.x.toFixed(1), +t.z.toFixed(1)]; }), state: () => ({ score, sunk, merges, charge, n: pieces.length, values: pieces.map(p => p.value) }),
    screens: v => pieces.filter(q => q.value === v).map(p => { const t = p.rb.translation(); const s = new THREE.Vector3(t.x, t.y, t.z).project(camera); const r = canvas.getBoundingClientRect(); return [r.left + (s.x + 1) / 2 * r.width, r.top + (1 - s.y) / 2 * r.height]; }),
    screen: v => { const p = pieces.find(q => q.value === v); if (!p) return null; const t = p.rb.translation(); const s = new THREE.Vector3(t.x, t.y, t.z).project(camera); const r = canvas.getBoundingClientRect(); return [r.left + (s.x + 1) / 2 * r.width, r.top + (1 - s.y) / 2 * r.height]; } };
} catch (err) {
  $('status').textContent = L('Fyziku se nepodařilo načíst: ', 'Failed to load physics: ') + (err?.message || err);
  console.error(err);
}

```

### assets/js/params.js

```javascript
// Herní parametry, které určují obtížnost. Jediný zdroj pravdy pro hru (play/) i obrazovku Custom (custom/).
// def = výchozí hodnota = obtížnost MEDIUM. EASY a HARD mění jen vyjmenované hodnoty.
// Pohodlí (pohled, míření, rozložení HUD, grafika, hudba) sem nepatří: to si hráč nastaví vždy.

import { L } from './i18n.js';
export const MODES = ['easy', 'medium', 'hard', 'custom'];
export const MODE_LABEL = { easy: 'EASY', medium: 'MEDIUM', hard: 'HARD', custom: 'CUSTOM' };

export const GAME_GROUPS = [
  { key: 'rules', title: L('Pravidla', 'Rules'), note: L('Projeví se při nové hře.', 'Takes effect in a new game.'), items: [
    { id: 'finalTarget', label: L('Konečný cíl', 'Final target'), type: 'choice', options: [[64, L('koule 64 (1 úroveň)', 'sphere 64 (1 level)')], [128, L('koule 128 (2 úrovně)', 'sphere 128 (2 levels)')], [256, L('koule 256 (3 úrovně)', 'sphere 256 (3 levels)')]], def: 256,
      hint: L('Kterou kouli musíš dostat do černé díry, abys vyhrál.', 'Which sphere you must drop into the black hole to win.') },
    { id: 'maxPieces', label: L('Limit těles na ploše', 'Body limit on the sheet'), min: 20, max: 80, def: 40, hint: L('Když je těles víc, hra končí (plocha je plná).', 'More bodies than this and the game ends (the sheet is full).') },
    { id: 'startBodies', label: L('Těles na začátku', 'Starting bodies'), min: 0, max: 30, def: 10 },
    { id: 'depth0', label: L('Hloubka studny', 'Well depth'), min: 4, max: 14, step: 0.5, decimals: 1, def: 8,
      hint: L('Hloubka 1. úrovně. Každá další úroveň studnu prohloubí o polovinu předchozího přírůstku.', 'Depth at level 1. Each further level deepens the well by half of the previous increase.') },
  ] },
  { key: 'spin', title: L('Otáčení plochy', 'Sheet rotation'), items: [
    { id: 'spin', label: L('Rychlost otáčení', 'Rotation speed'), min: -20, max: 20, step: 0.5, decimals: 1, unit: ' °/s', def: 3, hint: L('Záporná hodnota otáčí opačně, 0 plochu zastaví.', 'A negative value spins the other way, 0 stops the sheet.') },
    { id: 'wob', label: L('Kolébání náklonu', 'Tilt wobble'), min: 0, max: 12, step: 0.5, decimals: 1, unit: '°', def: 2.5 },
    { id: 'wobp', label: L('Perioda kolébání', 'Wobble period'), min: 3, max: 40, unit: ' s', def: 14 },
  ] },
  { key: 'ctl', title: L('Prak, náboje a děla', 'Sling, ammo and cannons'), items: [
    { id: 'ammoMix', type: 'split', label: L('Náboje do praku: šance', 'Sling ammo: odds'), def: [55, 80, 92, 100], values: [2, 4, 6, 8, 32],
      hint: L('Čtyři body dělí táhlo na pět dílů: šance na žeton, čtyřstěn, krychli, osmistěn a dvaatřicetistěn (32). Bod úplně vpravo = 32 nikdy.', 'Four handles split the bar into five parts: odds of a coin, tetrahedron, cube, octahedron and truncated icosahedron (32). Handle at the far right = never 32.') },
    { id: 'range', label: L('Dostřel katapultu', 'Sling range'), min: 8, max: 34, unit: ' m', def: 22 },
    { id: 'speed', label: L('Rychlost výstřelu žetonu', 'Coin launch speed'), min: 0.5, max: 3, step: 0.1, decimals: 1, unit: '×', def: 1,
      hint: L('Oblouk i místo dopadu zůstávají, mění se jen rychlost letu. Rychlejší žeton po dopadu dojede dál.', 'The arc and landing spot stay the same, only the flight speed changes. A faster coin slides further after landing.') },
    { id: 'arc', label: L('Úhel výstřelu katapultu', 'Sling launch angle'), min: 20, max: 75, unit: '°', def: 50 },
    { id: 'keep', label: L('Dojezd žetonu po dopadu', 'Coin slide after landing'), min: 0, max: 90, step: 5, unit: ' %', def: 55, hint: L('Kolik rychlosti si žeton po dopadu nechá. 0 = zůstane, kde dopadl.', 'How much speed the coin keeps after landing. 0 = it stays where it lands.') },
    { id: 'fric', label: L('Tření žetonu', 'Coin friction'), min: 0.03, max: 0.5, step: 0.01, decimals: 2, def: 0.15, hint: L('Menší číslo = žeton po ploše klouže dál.', 'Lower = the coin slides further across the sheet.') },
    { id: 'push', label: L('Síla laserového rázu', 'Laser blast force'), min: 2, max: 18, unit: ' m/s', def: 7 },
    { id: 'photon1', label: L('Nábojů děl za jedno nabití', 'Cannon shots per charge'), min: 1, max: 6, def: 1 },
    { id: 'cap', label: L('Kapacita děl', 'Cannon capacity'), min: 2, max: 16, def: 8 },
  ] },
  { key: 'tok', title: L('Ekonomika Quansistorů', 'Quansistor economy'), note: L('Quansistory jsou palivo: výstřel z praku stojí tolik, kolik je hodnota náboje. Příjem má tři zdroje: těleso 32+ ve studni (velký příval s poločasem rozpadu), odměna za každé spojení a slabé stálé záření díry.', 'Quansistors are fuel: a sling shot costs the value of the ammo. Income has three sources: a 32+ body in the well (a big burst with a half-life), a reward for every merge, and the faint steady radiation of the hole.'), items: [
    { id: 'tokStart', label: L('Quansistory na začátku hry', 'Starting Quansistors'), min: 0, max: 2000, step: 10, def: 100, hint: L('Na první kouli 64 je potřeba zhruba 40–60 Quansistorů materiálu (podle počátečních těles) plus nabíjení děl.', 'The first sphere 64 takes roughly 40–60 Quansistors of material (depending on starting bodies) plus cannon charging.') },
    { id: 'tokMult', label: L('Vyzářeno z tělesa ve studni', 'Radiated by a body in the well'), min: 0.25, max: 5, step: 0.25, decimals: 2, unit: L('× hodnota', '× value'), def: 2,
      hint: L('2× = koule 64 vyzáří 128 Quansistorů, což zhruba zaplatí stavbu další, dvakrát větší koule. Pod 2× se hra postupně dusí.', '2× = a sphere 64 radiates 128 Quansistors, roughly paying for the next, twice as big sphere. Below 2× the game slowly chokes.') },
    { id: 'tokHalf', label: L('Poločas vyzařování', 'Radiation half-life'), min: 2, max: 60, unit: ' s', def: 10, hint: L('Kratší = rychlý příval, delší = pomalý stálý příjem.', 'Shorter = a quick burst, longer = slow steady income.') },
    { id: 'tokMerge', label: L('Odměna za spojení', 'Merge reward'), min: 0, max: 40, unit: L(' % hodnoty', ' % of value'), def: 10,
      hint: L('Každé spojení vrátí tento podíl hodnoty nového tělesa: při 10 % dá 2+2→4 0,4 Quansistoru, 16+16→32 3,2 Quansistoru. Odměňuje přesnou hru.', 'Each merge pays back this share of the new body\'s value: at 10 %, 2+2→4 gives 0.4 Quansistors, 16+16→32 gives 3.2. Rewards precise play.') },
    { id: 'tokTrickle', label: L('Stálé záření díry', 'Steady radiation of the hole'), min: 0, max: 2, step: 0.05, decimals: 2, unit: ' Q/s', def: 0.2,
      hint: L('Slabý příjem pořád (Hawkingovo záření). Když je větší než 0, hra nikdy neskončí kvůli Quansistorům, jen se zpomalí. 0 = tvrdý režim.', 'A faint constant income (Hawking radiation). Above 0 the game never ends for lack of Quansistors, it only slows down. 0 = hard mode.') },
    { id: 'tokConv', label: L('Cena nabití děl', 'Cannon charge cost'), min: 0, max: 8, unit: ' Q', def: 1 },
  ] },
  { key: 'rift', title: L('Trhliny v prostoru', 'Rifts in space'), note: L('Plocha je mřížkou rozdělená na segmenty (16 soustředných pásů × 36 výsečí po 10°). Když těleso 32+ (cílová koule) propadne za horizont událostí, ze studny vyjde záblesk a roztrhne několik sousedních segmentů; tělesa jimi propadnou. Spojení na ploše trhliny nedělá. OPRAVA scelí jeden segment ve výseči mezi prakem a středem; tutéž výseč znovu až při další otáčce.', 'The sheet is split by a grid into segments (16 concentric bands × 36 wedges of 10°). When a 32+ body (the target sphere) falls past the event horizon, a flash bursts from the well and tears open several adjacent segments; bodies fall through them. Merges on the sheet make no rifts. REPAIR seals one segment in the wedge between the sling and the centre; the same wedge again only on the next turn.'), items: [
    { id: 'riftOn', type: 'toggle', label: L('Trhliny zapnuté', 'Rifts on'), def: true },
    { id: 'riftCells', label: L('Velikost trhliny', 'Rift size'), min: 1, max: 6, unit: L(' segm.', ' segm.'), def: 2, hint: L('Kolik sousedních segmentů mřížky se roztrhne při pádu tělesa 32+ za horizont událostí. EASY 1, MEDIUM 2, HARD 3.', 'How many adjacent grid segments tear open when a 32+ body falls past the event horizon. EASY 1, MEDIUM 2, HARD 3.') },
    { id: 'repairCost', label: L('Cena opravy jednoho segmentu', 'Cost to repair one segment'), min: 0, max: 100, unit: ' Q', def: 8 },
  ] },
];

export const GAME_ITEMS = GAME_GROUPS.flatMap(g => g.items);
export const GAME_IDS = new Set(GAME_ITEMS.map(it => it.id));
export const meta = id => GAME_ITEMS.find(it => it.id === id);

// Rozdíly proti MEDIUM (= def)
// Rozdíly proti MEDIUM (= def). Ekonomika: stavba cíle T stojí zhruba T Quansistorů materiálu (+ ztráty),
// příjem je T × násobek ze studny + odměny za spojení + stálé záření. EASY je štědrý, HARD na hraně.
export const PRESETS = {
  easy: {
    maxPieces: 50, startBodies: 14,
    spin: 1.5, wob: 1.5,
    ammoMix: [60, 85, 100, 100], range: 24, push: 9, photon1: 2, cap: 10,
    tokStart: 160, tokMult: 2.5, tokHalf: 8, tokMerge: 15, tokTrickle: 0.4,
    riftCells: 1, repairCost: 5,
  },
  medium: {},
  hard: {
    maxPieces: 34, startBodies: 10,
    spin: 4.5, wob: 3.5, wobp: 11,
    ammoMix: [45, 70, 88, 100], range: 20, cap: 6,
    tokStart: 140, tokMult: 1.75, tokHalf: 14, tokMerge: 10, tokTrickle: 0,
    riftCells: 3, repairCost: 12,
  },
};
export const MODE_INFO = {
  easy: L('Na pohodu: 160 Quansistorů, díra vrací 2,5× hodnotu koule, štědré odměny za spojení, pomalá plocha a trhliny jen po 1 segmentu.', 'Relaxed: 160 Quansistors, the hole returns 2.5× the sphere value, generous merge rewards, a slow sheet and rifts of just 1 segment.'),
  medium: L('Vyvážená hra: 100 Quansistorů, díra vrací 2× hodnotu koule, malá odměna za spojení a slabé stálé záření.', 'Balanced: 100 Quansistors, the hole returns 2× the sphere value, a small merge reward and faint steady radiation.'),
  hard: L('Na hraně: díra vrací jen 1,75×, žádné stálé záření (Quansistory můžou dojít), rychlá plocha, trhliny po 3 segmentech, menší kapacita děl a 34 těles.', 'On the edge: the hole returns only 1.75×, no steady radiation (Quansistors can run out), a fast sheet, rifts of 3 segments, smaller cannon capacity and 34 bodies.'),
  custom: L('Vlastní pravidla: tvar plochy, ekonomika Quansistorů, trhliny, prak a děla. Všechno se dá ladit.', 'Your own rules: sheet shape, Quansistor economy, rifts, sling and cannons. Everything is tunable.'),
};

export const presetValues = mode => {
  const v = Object.fromEntries(GAME_ITEMS.map(it => [it.id, Array.isArray(it.def) ? it.def.slice() : it.def]));
  return Object.assign(v, structuredClone(PRESETS[mode] || {}));
};

// Uložené vlastní (custom) hodnoty: stejný klíč jako nastavení hry
export const SETTINGS_KEY = 'lhc.settings', PROFILE_KEY = 'lhc.profile', MODE_KEY = 'lhc.mode';
export function customValues() {
  const v = presetValues('medium');
  try {
    const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
    for (const id of GAME_IDS) if (s[id] != null) v[id] = s[id];
  } catch (e) { }
  return v;
}

```

### assets/js/recipes.js

```javascript
// Pravidla slučování: [a, b, výsledek]. Pořadí a/b nehraje roli.
// Tělesa se nesčítají libovolně, spojí se jen dvojice uvedené v tabulce.
export const RECIPES = [
  [2, 2, 4],     // mince + mince → čtyřstěn
  // [2, 4, 6] mince + čtyřstěn → krychle: vypnuto, žeton a čtyřstěn se jen srazí
  [4, 4, 8],     // dva čtyřstěny → osmistěn
  [6, 6, 12],    // dvě krychle → dvanáctistěn
  [8, 8, 16],    // dva osmistěny → šestnáctistěn
  [8, 12, 20],   // osmistěn + dvanáctistěn → dvacetistěn
  [12, 12, 24],  // dva dvanáctistěny → čtyřiadvacetistěn
  [16, 16, 32],  // dva šestnáctistěny → dvaatřicetistěn
  [20, 12, 32],  // vedlejší větev: dvacetistěn + dvanáctistěn
  [24, 8, 32],   // vedlejší větev: čtyřiadvacetistěn + osmistěn
  [32, 32, 64],  // koule 64: cíl 1. úrovně
  [64, 64, 128], // koule 128: cíl 2. úrovně (otvor se zmenší tak, že 64 už neprojde)
  [128, 128, 256], // koule 256: cíl 3. úrovně, konec hry
];

const TABLE = new Map();
for (const [a, b, r] of RECIPES) { TABLE.set(a + '+' + b, r); TABLE.set(b + '+' + a, r); }

export function mergeResult(a, b) { return TABLE.get(a + '+' + b) ?? null; }

// s čím vším se dá těleso dané hodnoty spojit (pro zvýraznění v tabulce)
export function partnersOf(v) { return RECIPES.filter(([a, b]) => a === v || b === v); }

```

### assets/js/funnel-shape.js

```javascript
// Tvar hrací plochy jako čistá data (bez three.js), aby je mohl číst i generátor zadání pro AI (tools/build-bot.mjs).
export const FUNNEL_R = 26;   // vnější poloměr hrací plochy
// Výchozí profil pro 1. úroveň: [r, h]. Plocha je skoro rovná, u r ≈ 1 se láme do studny.
export const DEFAULT_PROFILE = [
  [26, 0], [14, -0.35], [5, -0.62], [1.5, -0.72], [1.03, -1.1], [0.8, -4.5], [0.55, -7.4], [0, -8.6],
];
// Horizont událostí: výška (v měřítku 1. úrovně), pod kterou se tělesa přestanou srážet a zmizí.
export const DEFAULT_HORIZON = -9.2;

```

### assets/js/funnel.js

```javascript
// Gravitační trychtýř: hrací plocha jako prostor zdeformovaný hmotou.
// Tvar plochy určuje profil (spline) z řídicích bodů: výška h v závislosti na vzdálenosti r od osy.
// Profil se dá upravovat v nastavení hry (editor křivky). Každá úroveň hry profil prohloubí
// (násobí výšky) a zúží hrdlo.
import * as THREE from 'three';
import { FUNNEL_R, DEFAULT_PROFILE, DEFAULT_HORIZON } from './funnel-shape.js';
export { DEFAULT_PROFILE, DEFAULT_HORIZON };

export const FUNNEL = {
  R: FUNNEL_R,    // vnější poloměr hrací plochy (vyplní většinu obrazovky)
  rh: 0.66,       // poloměr hrdla; mění se s úrovní hry (vždy o kousek větší než cílová koule)
  depth: 8,       // skutečná hloubka hrdla (dopočítá se z profilu)
  scale: 1,       // násobek výšek profilu pro aktuální úroveň (1. úroveň = 1)
  baseDepth: 8,   // hloubka 1. úrovně, vůči ní se počítá scale
  lip: 1.03,      // poloměr přepadu (použije ho hra pro vypnutí katapultu nad studnou)
  rings: 64, segs: 72,    // síť: 4 prstence a 2 sloupce na segment mřížky (trhliny se vyřezávají po segmentech)
};

let prof = { xs: [], ys: [], ms: [] };
export const PROFILE = { points: DEFAULT_PROFILE.map(p => [...p]), horizon: DEFAULT_HORIZON };

// Monotónní kubická interpolace (Fritsch–Carlson): hladká, ale bez zákmitů mezi body
function prepare(points) {
  // jen platná čísla, seřazená podle r; body se stejným r (např. dva body přetažené na osu)
  // by dělily nulou a vyrobily NaN, které záře (bloom) rozmaže přes celý obraz, proto je rozestoupíme
  const pts = points.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]))
    .map(p => [Math.max(0, p[0]), p[1]]).sort((a, b) => a[0] - b[0]);
  for (let i = 1; i < pts.length; i++) if (pts[i][0] - pts[i - 1][0] < 1e-3) pts[i][0] = pts[i - 1][0] + 1e-3;
  if (pts.length < 2) pts.push([FUNNEL.R, 0]);
  const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]), n = xs.length;
  const d = [], ms = new Array(n).fill(0);
  for (let i = 0; i < n - 1; i++) d.push((ys[i + 1] - ys[i]) / Math.max(1e-6, xs[i + 1] - xs[i]));
  ms[0] = d[0] ?? 0; ms[n - 1] = d[n - 2] ?? 0;
  for (let i = 1; i < n - 1; i++) ms[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2;
  for (let i = 0; i < n - 1; i++) {
    if (d[i] === 0) { ms[i] = ms[i + 1] = 0; continue; }
    const a = ms[i] / d[i], b = ms[i + 1] / d[i], h = a * a + b * b;
    if (h > 9) { const t = 3 / Math.sqrt(h); ms[i] = t * a * d[i]; ms[i + 1] = t * b * d[i]; }
  }
  prof = { xs, ys, ms };
}
export function profileAt(r) {
  const { xs, ys, ms } = prof, n = xs.length;
  if (r <= xs[0]) return ys[0];
  if (r >= xs[n - 1]) return ys[n - 1];
  let i = 0; while (i < n - 2 && r > xs[i + 1]) i++;
  const h = xs[i + 1] - xs[i];
  if (!(h > 1e-9)) return ys[i];
  const t = (r - xs[i]) / h, t2 = t * t, t3 = t2 * t;
  const y = (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * h * ms[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * h * ms[i + 1];
  return Number.isFinite(y) ? y : ys[i];
}

/** Nastaví profil plochy (body [r, h]) a výšku horizontu událostí. */
export function setProfile(points, horizon) {
  if (points) { PROFILE.points = points.map(p => [...p]); prepare(PROFILE.points); }
  if (horizon != null) PROFILE.horizon = horizon;
  FUNNEL.depth = -heightAt(FUNNEL.rh);
}
prepare(PROFILE.points);

/** Nastaví poloměr hrdla, hloubku úrovně a přepad (volá hra při startu a při změně úrovně). */
export function setFunnel({ rh, depth, lip }) {
  if (rh != null) FUNNEL.rh = rh;
  if (depth != null) FUNNEL.scale = depth / FUNNEL.baseDepth;
  if (lip != null) FUNNEL.lip = lip;
  FUNNEL.depth = -heightAt(FUNNEL.rh);
}

// Výška plochy ve vzdálenosti r od osy (profil × měřítko úrovně)
export function heightAt(r) {
  const rr = THREE.MathUtils.clamp(r, FUNNEL.rh, FUNNEL.R);
  return profileAt(rr) * FUNNEL.scale;
}
// výška horizontu událostí ve světě
export const horizonY = () => PROFILE.horizon * FUNNEL.scale;

// Mapování poloměru na „rovnoměrnou“ osu u (0 = okraj, 1 = osa hrdla).
// Stejné mapování používá editor křivky i hustota sítě, takže detail je tam, kde je vidět.
export const U_EXP = 0.4;
export const rToU = r => 1 - Math.pow(Math.max(0, r) / FUNNEL.R, U_EXP);
export const uToR = u => FUNNEL.R * Math.pow(Math.max(0, 1 - u), 1 / U_EXP);

// Normála plochy v bodě (x, z): kolmice ke svahu trychtýře (v hrdle a mimo plochu míří nahoru)
export function normalAt(x, z, out = new THREE.Vector3()) {
  const r = Math.hypot(x, z);
  if (r <= FUNNEL.rh || r >= FUNNEL.R) return out.set(0, 1, 0);
  const e = 0.05, slope = (heightAt(r + e) - heightAt(r - e)) / (2 * e); // dy/dr
  return out.set(-slope * x / r, 1, -slope * z / r).normalize();
}

// poloměry prstenců: rovnoměrně v ose u od okraje k hrdlu
function ringRadius(i) { return radiusAt(i / FUNNEL.rings); }
function radiusAt(t) {                     // t = 0 u hrdla, 1 na okraji
  const uh = rToU(FUNNEL.rh);
  return THREE.MathUtils.clamp(uToR(uh * (1 - t)), FUNNEL.rh, FUNNEL.R);
}

// počet poledníků mřížky: plochu dělí na stejně velké výseče (segmenty), používá i oprava trhlin
export const GRID_MERIDIANS = 36;
// počet pásů mezi soustřednými kruhy mřížky (od hrdla k okraji); segment trhliny = pás × výseč
export const GRID_PARALLELS = 16;

// Vzhled plochy: tmavost (0 = modrá, 1 = skoro černá) a barva svítící mřížky.
// Ukládá se v prohlížeči (lhc.look), takže platí ve hře, v náhledu na úvodní obrazovce i v editoru CUSTOM.
export const GRID_COLORS = {            // [jasná barva u hrdla, tlumená u okraje]
  blue: ['#9CC2FF', '#22345C'], green: ['#7CFFB2', '#0E4A2C'], cyan: ['#7DF9FF', '#10404C'],
  amber: ['#FFD66B', '#4A3A10'], violet: ['#C9A8FF', '#2E2252'], white: ['#EAF4FF', '#2A3242'],
};
export const LOOK = { dark: 0, grid: 'blue' };
try { Object.assign(LOOK, JSON.parse(localStorage.getItem('lhc.look') || '{}')); } catch (e) { }
export function setLook(patch) {
  Object.assign(LOOK, patch);
  try { localStorage.setItem('lhc.look', JSON.stringify(LOOK)); } catch (e) { }
}

/** Geometrie pro fyziku (trimesh) i grafiku. */
export function buildFunnel() {
  const { rings, segs } = FUNNEL;
  const pos = [], idx = [], col = [];
  const k = THREE.MathUtils.clamp(+LOOK.dark || 0, 0, 1);
  const deep = new THREE.Color('#1A3C8C').lerp(new THREE.Color('#06080E'), k), rim = new THREE.Color('#0B1328').lerp(new THREE.Color('#020305'), k);
  for (let i = 0; i <= rings; i++) {
    const r = ringRadius(i), y = heightAt(r);
    const c = deep.clone().lerp(rim, i / rings);
    for (let j = 0; j < segs; j++) {
      const a = (j / segs) * Math.PI * 2;
      pos.push(Math.cos(a) * r, y, Math.sin(a) * r);
      col.push(c.r, c.g, c.b);
    }
  }
  for (let i = 0; i < rings; i++) for (let j = 0; j < segs; j++) {
    const a = i * segs + j, b = i * segs + (j + 1) % segs, c = a + segs, d = b + segs;
    idx.push(a, b, c, b, d, c); // normály míří nahoru
  }
  const surfaceGeo = new THREE.BufferGeometry();
  surfaceGeo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  surfaceGeo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
  surfaceGeo.setIndex(idx);

  // mřížka jako na obrázku zakřiveného prostoru: poledníky + rovnoběžky,
  // u středu jasná (tam září), k okraji slábne
  const lp = [], lc = [];
  const [gb, gd] = GRID_COLORS[LOOK.grid] || GRID_COLORS.blue;
  const bright = new THREE.Color(gb), dim = new THREE.Color(gd);
  // poměr omezíme na 0…1: zaokrouhlením může r vyjít nepatrně menší než rh a mocnina
  // záporného čísla by dala NaN (neplatná barva → černý obdélník přes obraz při zapnuté záři)
  const lineColor = r => bright.clone().lerp(dim, Math.pow(THREE.MathUtils.clamp((r - FUNNEL.rh) / (FUNNEL.R - FUNNEL.rh), 0, 1), 0.55));
  const MER = GRID_MERIDIANS, FINE = 120;
  for (let m = 0; m < MER; m++) {                       // poledníky
    const a = (m / MER) * Math.PI * 2, ca = Math.cos(a), sa = Math.sin(a);
    for (let k = 0; k < FINE; k++) {
      const r1 = ringRadiusT(k / FINE), r2 = ringRadiusT((k + 1) / FINE);
      lp.push(ca * r1, heightAt(r1) + 0.02, sa * r1, ca * r2, heightAt(r2) + 0.02, sa * r2);
      const c1 = lineColor(r1), c2 = lineColor(r2);
      lc.push(c1.r, c1.g, c1.b, c2.r, c2.g, c2.b);
    }
  }
  const PAR = GRID_PARALLELS;
  for (let p = 0; p <= PAR; p++) {                      // rovnoběžky
    const r = ringRadiusT(p / PAR), y = heightAt(r) + 0.02, c = lineColor(r);
    for (let k = 0; k < FINE; k++) {
      const a1 = (k / FINE) * Math.PI * 2, a2 = ((k + 1) / FINE) * Math.PI * 2;
      lp.push(Math.cos(a1) * r, y, Math.sin(a1) * r, Math.cos(a2) * r, y, Math.sin(a2) * r);
      lc.push(c.r, c.g, c.b, c.r, c.g, c.b);
    }
  }
  // hrdlo: čáry se sbíhají dolů do „singularity“
  const THROAT = 5.5, TS = 24; // délka hrdla pod víkem
  for (let m = 0; m < MER; m += 2) {
    const a = (m / MER) * Math.PI * 2, ca = Math.cos(a), sa = Math.sin(a);
    for (let k = 0; k < TS; k++) {
      const t1 = k / TS, t2 = (k + 1) / TS;
      const r1 = FUNNEL.rh * (1 - 0.72 * t1 * t1), r2 = FUNNEL.rh * (1 - 0.72 * t2 * t2);
      const y1 = -FUNNEL.depth - THROAT * t1, y2 = -FUNNEL.depth - THROAT * t2;
      lp.push(ca * r1, y1, sa * r1, ca * r2, y2, sa * r2);
      const c1 = bright.clone().multiplyScalar(1 - 0.8 * t1), c2 = bright.clone().multiplyScalar(1 - 0.8 * t2);
      lc.push(c1.r, c1.g, c1.b, c2.r, c2.g, c2.b);
    }
  }
  const gridGeo = new THREE.BufferGeometry();
  gridGeo.setAttribute('position', new THREE.Float32BufferAttribute(lp, 3));
  gridGeo.setAttribute('color', new THREE.Float32BufferAttribute(lc, 3));

  return {
    surfaceGeo, gridGeo, throatDepth: THROAT,
    trimesh: { vertices: new Float32Array(pos), indices: new Uint32Array(idx) },
  };
}
function ringRadiusT(t) { return radiusAt(t); }

```

### assets/js/tokens.js

```javascript
import { pal } from './theme.js';
import { L } from './i18n.js';
import { hudPanel } from './hudframe.js';
let P = pal();   // barvy HUD podle tématu (obnovují se při každém kreslení)
// Ekonomika Quansistorů: Quansistory jsou palivo hry.
//
//   • výstřel (i položení) náboje z praku stojí tolik Quansistorů, kolik je jeho hodnota (2, 4, 6, 8),
//   • přeměna náboje na fotony (nabití děl) stojí 1 Quansistor a přidá 1 náboj dělům,
//   • Quansistory vyzařuje černá díra: když do studny propadne těleso 32 a víc, začne vyzařovat
//     celkem tolik Quansistorů, kolik je jeho hodnota (× násobek), s exponenciálním rozpadem
//     jako radioaktivní prvek:
//
//         zbývá vyzářit  N(t) = N₀ · e^(−λt),   λ = ln 2 / T½
//         rychlost       R(t) = λ · N₀ · e^(−λt)      (Quansistorů za sekundu; na začátku nejvíc)
//         vyzářeno       N₀ · (1 − e^(−λt))           (za T½ polovina, za 2·T½ tři čtvrtiny …)
//
//     Víc zdrojů naráz se sčítá.
//
// Modul drží stav (Quansistory, zdroje vyzařování) a kreslí HUD panel u pravého okraje obrazovky:
// číslo, rychlost vyzařování (Quansistorů/s), sloupec plněný podle počtu Quansistorů a křivku rozpadu.

//   • trickle: černá díra slabě vyzařuje pořád (Hawkingovo záření), Quansistorů za sekundu; 0 = vypnuto,
//   • merge: odměna za spojení = podíl hodnoty nového tělesa (0,1 = 2+2→4 vrátí 0,4 Quansistoru, 32 vrátí 3,2).
export const TOKEN_DEFAULTS = { start: 100, halfLife: 10, mult: 2, convertCost: 1, trickle: 0.2, merge: 0.1, on: true };

const TITLE = L('QUANSISTORY', 'QUANSISTORS');
export function createTokens(root) {
  const CFG = { ...TOKEN_DEFAULTS };
  const S = { tokens: CFG.start, emitters: [], total: 0, flash: 0 };

  /* ---------- ekonomika ---------- */
  const lambda = () => Math.LN2 / Math.max(0.5, CFG.halfLife);
  function rate() { const l = lambda(); return CFG.trickle + S.emitters.reduce((a, e) => a + l * e.left, 0); }
  function remaining() { return S.emitters.reduce((a, e) => a + e.left, 0); }
  /** posun v čase: každý zdroj vyzáří N·(1 − e^(−λ·dt)), přesně podle rozpadu */
  function tick(dt) {
    let got = Math.max(0, CFG.trickle) * dt;            // stálé slabé vyzařování
    if (!S.emitters.length) { S.tokens += got; S.total += got; return got; }
    const k = Math.exp(-lambda() * dt);
    for (const e of S.emitters) { const out = e.left * (1 - k); e.left -= out; got += out; }
    S.emitters = S.emitters.filter(e => e.left > 0.01);
    S.tokens += got; S.total += got;
    return got;
  }
  function emit(value, label) {
    const n = value * CFG.mult;
    S.emitters.push({ n0: n, left: n, label: label ?? String(value), born: performance.now() });
    S.flash = 1;
    return n;
  }
  /** odměna za spojení: vrátí počet připsaných Quansistorů */
  function reward(value) { const n = value * Math.max(0, CFG.merge); if (n > 0) { S.tokens += n; S.total += n; S.flash = Math.max(S.flash, 0.5); } return n; }
  const can = cost => S.tokens >= cost - 1e-6;
  function spend(cost) { if (!can(cost)) return false; S.tokens -= cost; return true; }
  function reset() { S.tokens = CFG.start; S.emitters = []; S.total = 0; }

  /* ---------- HUD panel u pravého okraje ---------- */
  const wrap = document.createElement('div');
  wrap.className = 'hud-tokens';
  wrap.setAttribute('role', 'status');
  wrap.innerHTML = '<canvas aria-hidden="true"></canvas><span class="sr-only"></span>'
    + '<div class="tok-degen"></div>'
    + '<button type="button" class="tok-repair" title="' + L('Scelí trhliny v segmentu plochy mezi prakem a černou dírou', 'Seals the rifts in the sheet segment between the sling and the black hole') + '"><b>' + L('OPRAVA', 'REPAIR') + '</b><span>' + L('SEGMENTU', 'SEGMENT') + '</span><i></i></button>';
  root.appendChild(wrap);
  const cv = wrap.querySelector('canvas'), sr = wrap.querySelector('.sr-only');
  const btn = wrap.querySelector('.tok-repair'), btnCost = btn.querySelector('i');
  let onRepair = null;
  btn.addEventListener('pointerdown', e => e.stopPropagation());
  btn.addEventListener('click', e => { e.stopPropagation(); onRepair?.(); });
  const g = cv.getContext('2d');
  const GREEN = '#7CFFB2';
  const MONO = getComputedStyle(document.documentElement).getPropertyValue('--mono').trim() || 'monospace';
  let W = 0, H = 0, shown = S.tokens, srAt = 0;

  /** umístění: u pravého okraje mezi pravými děly (top/bottom = volný prostor v px) */
  function place(top, bottom) {
    const avail = root.clientHeight - top - bottom;
    wrap.hidden = !CFG.on || avail < 200;
    if (wrap.hidden) return;
    const BTN = 70 + 50;                              // místo pro tlačítka DEGEN MODE a OPRAVA pod sloupcem
    const narrow = root.clientWidth < 760;              // mobil: menší sloupec, ať nezakrývá plochu
    const w = narrow ? 70 : 96, h = Math.min(narrow ? 230 : 430, avail - BTN);
    Object.assign(wrap.style, { width: `${w}px`, right: '10px', top: `${top + (avail - h - BTN) / 2}px` });
    const dpr = Math.min(2, devicePixelRatio || 1);
    W = w; H = Math.round(h);
    cv.width = W * dpr; cv.height = H * dpr;
    cv.style.width = `${W}px`; cv.style.height = `${H}px`;
    g.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  const niceCap = v => [64, 128, 256, 512, 1024, 2048, 4096].find(c => v <= c * 0.92) ?? 8192;

  function draw(dt, now = performance.now()) {
    P = pal();
    if (wrap.hidden || !W) return;
    shown += (S.tokens - shown) * Math.min(1, dt * 8);        // číslo dobíhá plynule
    S.flash = Math.max(0, S.flash - dt * 1.2);
    const r = rate();
    g.clearRect(0, 0, W, H);
    hudPanel(g, P, 3, 3, W - 6, H - 6, { c: 16, glow: S.flash, mono: MONO, header: 0 });

    g.textAlign = 'center';
    // název svisle podél levého okraje: písmena pod sebou od horního rohu až k začátku křivky rozpadu
    { const x = 18, y0 = 24, y1 = H - 86, n = TITLE.length, step = (y1 - y0) / Math.max(1, n - 1);
      const fs = Math.max(7, Math.min(11, step * 0.9));
      g.font = `700 ${fs}px ${MONO}`; g.fillStyle = P.GOLD; g.shadowColor = 'rgba(255,214,107,.6)'; g.shadowBlur = 5;
      for (let i = 0; i < n; i++) g.fillText(TITLE[i], x, y0 + i * step + fs * 0.35);
      g.shadowBlur = 0;
      g.strokeStyle = 'rgba(255,214,107,.28)'; g.lineWidth = 1;          // jemná linka odděluje název od přístroje
      g.beginPath(); g.moveTo(27.5, y0 - 8); g.lineTo(27.5, y1 + 6); g.stroke(); }
    const tx = 28 + (W - 28 - 8) / 2;                                     // střed pravé části (vedle názvu)
    // číslo
    const low = S.tokens < 2;
    g.font = `700 ${W < 90 ? 17 : 22}px ${MONO}`; g.fillStyle = low ? '#FFB86B' : '#FFFFFF';
    g.fillText(String(Math.floor(shown + 1e-6)), tx, 36);
    // poměr: Quansistorů za sekundu
    g.font = `600 10px ${MONO}`;
    g.fillStyle = r > 0.005 ? GREEN : 'rgba(203,213,225,.55)';
    g.fillText(r > 0.005 ? `+${r < 10 ? r.toFixed(1) : Math.round(r)}/s` : '+0/s', tx, 52);

    // sloupec
    const cx0 = 32, cw = 26, cy0 = 62, cy1 = H - 92, ch = cy1 - cy0;
    const cap = niceCap(Math.max(S.tokens, CFG.start));
    g.fillStyle = 'rgba(11,19,34,.9)'; g.fillRect(cx0, cy0, cw, ch);
    g.strokeStyle = P.c(.45); g.lineWidth = 1; g.strokeRect(cx0 + 0.5, cy0 + 0.5, cw - 1, ch - 1);
    const f = Math.min(1, shown / cap), fh = ch * f;
    const fill = g.createLinearGradient(0, cy1, 0, cy0);
    fill.addColorStop(0, P.CYAN); fill.addColorStop(1, P.GOLD);
    g.fillStyle = fill; g.shadowColor = P.GOLD; g.shadowBlur = 8;
    g.fillRect(cx0 + 3, cy1 - fh, cw - 6, Math.max(0, fh - 3)); g.shadowBlur = 0;
    // budoucí přírůstek (co ještě černá díra vyzáří): průsvitně nad hladinou
    const fut = Math.min(ch - fh, ch * remaining() / cap);
    if (fut > 0.5) { g.fillStyle = 'rgba(124,255,178,.18)'; g.fillRect(cx0 + 3, cy1 - fh - fut, cw - 6, fut); }
    // rysky a popisky
    g.textAlign = 'left'; g.font = `500 8.5px ${MONO}`;
    for (let i = 0; i <= 8; i++) {
      const y = cy1 - ch * i / 8, big = i % 2 === 0;
      g.strokeStyle = big ? P.a(.7) : P.a(.35); g.lineWidth = 1.2;
      g.beginPath(); g.moveTo(cx0 + cw + 2, y); g.lineTo(cx0 + cw + (big ? 9 : 5), y); g.stroke();
      if (big) { g.fillStyle = 'rgba(203,213,225,.7)'; g.fillText(String(Math.round(cap * i / 8)), cx0 + cw + 11, y + 3); }
    }
    // hladina: jiskřička, když přitéká
    if (r > 0.005) {
      const y = cy1 - fh, ph = (now / 120) % 1;
      g.fillStyle = `rgba(232,254,255,${0.5 + 0.5 * Math.sin(now / 90)})`;
      g.fillRect(cx0 + 3 + ph * (cw - 9), y - 2, 3, 2);
    }

    // křivka rozpadu: rychlost vyzařování na příštích 4 poločasů
    const gx0 = 14, gx1 = W - 12, gy0 = H - 78, gy1 = H - 34;
    g.strokeStyle = P.c(.25); g.lineWidth = 1;
    g.beginPath(); g.moveTo(gx0, gy1); g.lineTo(gx1, gy1); g.moveTo(gx0, gy0); g.lineTo(gx0, gy1); g.stroke();
    if (r > 0.005) {
      const T = CFG.halfLife * 4, l = lambda();
      g.beginPath(); g.moveTo(gx0, gy1);
      for (let i = 0; i <= 40; i++) {
        const t = T * i / 40, y = gy1 - (gy1 - gy0) * Math.exp(-l * t);
        g.lineTo(gx0 + (gx1 - gx0) * i / 40, y);
      }
      g.lineTo(gx1, gy1); g.closePath();
      g.fillStyle = 'rgba(124,255,178,.15)'; g.fill();
      g.beginPath();
      for (let i = 0; i <= 40; i++) {
        const t = T * i / 40, y = gy1 - (gy1 - gy0) * Math.exp(-l * t);
        (i ? g.lineTo : g.moveTo).call(g, gx0 + (gx1 - gx0) * i / 40, y);
      }
      g.strokeStyle = GREEN; g.lineWidth = 1.5; g.stroke();
      // značka poločasu
      const hx = gx0 + (gx1 - gx0) / 4, hy = gy1 - (gy1 - gy0) / 2;
      g.setLineDash([2, 2]); g.strokeStyle = P.a(.6);
      g.beginPath(); g.moveTo(hx, gy1); g.lineTo(hx, hy); g.lineTo(gx0, hy); g.stroke(); g.setLineDash([]);
    }
    g.textAlign = 'center'; g.font = `500 8.5px ${MONO}`; g.fillStyle = 'rgba(203,213,225,.7)';
    g.fillText(`T½ ${CFG.halfLife} s`, W / 2 + 4, H - 22);
    const rem = remaining();
    g.fillStyle = rem > 0.5 ? GREEN : 'rgba(203,213,225,.45)';
    let foot = rem > 0.5 ? L(`zbývá ${Math.round(rem)}`, `left ${Math.round(rem)}`) : CFG.trickle > 0 ? L(`záření ${CFG.trickle}/s`, `radiation ${CFG.trickle}/s`) : L('díra spí', 'hole asleep');
    { let fs = 8.5; while (fs > 6 && g.measureText(foot).width > W - 30) { fs -= 0.5; g.font = `500 ${fs}px ${MONO}`; } }   // musí se vejít i do úzkého sloupce
    if (rem <= 0.5 && CFG.trickle > 0 && g.measureText(foot).width > W - 30) foot = L(`záření ${CFG.trickle}/s`, `rad ${CFG.trickle}/s`);   // úzký sloupec: zkratka
    g.fillText(foot, W / 2 + 4, H - 11);

    if (now - srAt > 2000) { srAt = now; sr.textContent = L(`Quansistory ${Math.floor(S.tokens)}, přírůstek ${r.toFixed(1)} za sekundu`, `Quansistors ${Math.floor(S.tokens)}, gaining ${r.toFixed(1)} per second`); }
  }

  return {
    tick, emit, reward, can, spend, reset, place, draw, rate, remaining,
    /** uložení / načtení stavu (uložená hra) */
    exportState: () => ({ tokens: S.tokens, total: S.total, emitters: S.emitters.map(e => ({ n0: e.n0, left: e.left, label: e.label })) }),
    importState(st) { if (!st) return; S.tokens = +st.tokens || 0; S.total = +st.total || 0; S.emitters = (st.emitters || []).map(e => ({ ...e, born: performance.now() })); },
    degenHost: wrap.querySelector('.tok-degen'),
    /** tlačítko opravy: cena a stav (ready = jsou Quansistory a je co opravit) */
    setRepair({ cost, ready, busy, handler }) {
      if (handler) onRepair = handler;
      if (cost != null) btnCost.textContent = W && W < 90 ? `${cost} Q` : L(`${cost} QUANSISTORŮ`, `${cost} QUANSISTORS`);   // úzký sloupec (mobil): zkratka
      btn.disabled = !ready;
      btn.classList.toggle('busy', !!busy);
    },
    get tokens() { return S.tokens; },
    set tokens(v) { S.tokens = Math.max(0, v); },
    get emitting() { return S.emitters.length > 0; },
    get total() { return S.total; },
    configure(p) { Object.assign(CFG, p); },
    get config() { return { ...CFG }; },
  };
}

```

### assets/js/rifts.js

```javascript
// Trhliny v prostoročasovém kontinuu (v hrací ploše) – po segmentech mřížky.
//
// Plocha je rozdělená mřížkou: soustředné kruhy (GRID_PARALLELS pásů od hrdla k okraji) a paprsky
// (GRID_MERIDIANS výsečí po 10°). Segment = políčko mezi dvěma kruhy a dvěma paprsky.
// Když těleso 32+ propadne za horizont událostí, ze studny vyjde záblesk a za ním se roztrhne CFG.cells
// sousedních segmentů (EASY 1, MEDIUM 2, HARD 3). Roztržený segment je díra: zmizí z grafiky i fyziky
// a tělesa jím propadnou. Mřížka plochy sedí přesně na síť trojúhelníků (2 sloupce a 4 prstence na
// segment), takže výřez je přesný a test „je bod v trhlině“ je jen výpočet indexu políčka.
//
// Oprava: tlačítko OPRAVA scelí jeden segment ve výseči mezi prakem a středem (nejblíž praku).
// Stejnou výseč jde znovu opravit až při další otáčce (prak musí výseč opustit a vrátit se).
import * as THREE from 'three';
import { FUNNEL, heightAt, rToU, GRID_MERIDIANS, GRID_PARALLELS } from './funnel.js';

export const RIFT_DEFAULTS = { on: true, cells: 2 };

const TAU = Math.PI * 2, MER = GRID_MERIDIANS, PAR = GRID_PARALLELS, SEG = TAU / MER;
const angOf = (x, z) => ((Math.atan2(z, x) % TAU) + TAU) % TAU;

export function createRifts(scene, { onOpen } = {}) {
  const CFG = { ...RIFT_DEFAULTS };
  const open = new Set();         // roztržené segmenty: klíč = pás * MER + výseč
  const waiting = [];             // čekají na rázovou vlnu: { key, delay }
  const sweeps = [];
  const repairs = [];             // probíhající opravy: { key, t, mesh, done }
  let destroyed = 0, lockA = null, hotUntil = 0;

  /* ---------- geometrie segmentů ---------- */
  const uh = () => rToU(FUNNEL.rh);
  const bandR = p => FUNNEL.R * Math.pow(Math.max(0, 1 - uh() * (1 - p / PAR)), 2.5);   // = radiusAt(p / PAR) ve funnel.js
  const bandOf = r => Math.floor((1 - rToU(r) / uh()) * PAR);
  const key = (p, m) => p * MER + m;
  const pOf = k => Math.floor(k / MER), mOf = k => k % MER;
  const minBand = () => { for (let p = 0; p < PAR; p++) if (bandR(p) >= FUNNEL.lip + 2.5) return p; return PAR - 1; };
  function cellAt(x, z) {
    const r = Math.hypot(x, z);
    if (r <= FUNNEL.rh || r >= FUNNEL.R) return -1;
    const p = bandOf(r); if (p < 0 || p >= PAR) return -1;
    return key(p, Math.floor(angOf(x, z) / SEG) % MER);
  }
  const isOpen = (x, z) => { const k = cellAt(x, z); return k >= 0 && open.has(k); };
  function inRift(x, z, pad = 0) {
    if (!open.size) return false;
    if (isOpen(x, z)) return true;
    return pad > 0 && (isOpen(x + pad, z) || isOpen(x - pad, z) || isOpen(x, z + pad) || isOpen(x, z - pad));
  }
  const cellArea = k => { const r0 = bandR(pOf(k)), r1 = bandR(pOf(k) + 1); return (r1 * r1 - r0 * r0) * SEG / 2; };
  const cellCenter = k => { const r = (bandR(pOf(k)) + bandR(pOf(k) + 1)) / 2, a = (mOf(k) + 0.5) * SEG; return [Math.cos(a) * r, Math.sin(a) * r]; };

  /* ---------- vznik trhliny ---------- */
  function pickCells(n, avoid) {
    const p0 = minBand(), ok = k => k >= 0 && pOf(k) >= p0 && pOf(k) < PAR && !open.has(k) && k !== avoid;
    for (let attempt = 0; attempt < 40; attempt++) {
      const start = key(p0 + Math.floor(Math.random() * (PAR - p0)), Math.floor(Math.random() * MER));
      if (!ok(start)) continue;
      const cells = [start];
      while (cells.length < n) {
        // sousedi všech dosavadních segmentů (radiálně i po obvodu), náhodně jeden
        const nb = [];
        for (const k of cells) {
          const p = pOf(k), m = mOf(k);
          for (const c of [key(p + 1, m), p > 0 ? key(p - 1, m) : -1, key(p, (m + 1) % MER), key(p, (m + MER - 1) % MER)]) if (ok(c) && !cells.includes(c)) nb.push(c);
        }
        if (!nb.length) break;
        cells.push(nb[Math.floor(Math.random() * nb.length)]);
      }
      if (cells.length === n) return cells;
    }
    return [];
  }
  /** těleso 32+ za horizontem: záblesk z místa (studny), za ním se roztrhne CFG.cells segmentů; vrací počet */
  function trigger(value, origin, color = '#FFFFFF') {
    if (!CFG.on || value < 32 || CFG.cells < 1) return 0;
    const cells = pickCells(Math.round(CFG.cells), cellAt(origin.x, origin.z));
    if (!cells.length) return 0;
    addSweep(origin, color);
    for (const k of cells) { const [cx, cz] = cellCenter(k); waiting.push({ key: k, delay: Math.hypot(cx - origin.x, cz - origin.z) / SWEEP_SPEED }); }
    return cells.length;
  }

  /* ---------- zásah do plochy: vyřezat trojúhelníky, schovat mřížku uvnitř trhliny ---------- */
  function apply(f) {
    if (!open.size) { destroyed = 0; drawRifts(); return f; }
    const pos = f.surfaceGeo.getAttribute('position').array, idx = f.surfaceGeo.index.array;
    const keep = [];
    for (let i = 0; i < idx.length; i += 3) {
      const a = idx[i] * 3, b = idx[i + 1] * 3, c = idx[i + 2] * 3;
      if (isOpen((pos[a] + pos[b] + pos[c]) / 3, (pos[a + 2] + pos[b + 2] + pos[c + 2]) / 3)) continue;
      keep.push(idx[i], idx[i + 1], idx[i + 2]);
    }
    f.surfaceGeo.setIndex(keep);
    f.trimesh.indices = new Uint32Array(keep);
    // čára mřížky zmizí, když je trhlina z obou jejích stran (uvnitř roztržené oblasti)
    const gp = f.gridGeo.getAttribute('position'), e = 0.02;
    for (let i = 0; i < gp.count; i += 2) {
      const mx = (gp.getX(i) + gp.getX(i + 1)) / 2, mz = (gp.getZ(i) + gp.getZ(i + 1)) / 2, r = Math.hypot(mx, mz);
      if (r <= FUNNEL.lip) continue;
      const a = Math.atan2(mz, mx), ca = Math.cos(a), sa = Math.sin(a);
      const ang = isOpen(Math.cos(a + e / r) * r, Math.sin(a + e / r) * r) && isOpen(Math.cos(a - e / r) * r, Math.sin(a - e / r) * r);
      const rad = isOpen(ca * (r + e), sa * (r + e)) && isOpen(ca * (r - e), sa * (r - e));
      if (ang && rad) { gp.setXYZ(i, 0, -999, 0); gp.setXYZ(i + 1, 0, -999, 0); }
    }
    gp.needsUpdate = true;
    let cut = 0; for (const k of open) cut += cellArea(k);
    destroyed = cut / (Math.PI * (FUNNEL.R ** 2 - FUNNEL.lip ** 2));
    drawRifts();
    return f;
  }

  /* ---------- vzhled: žhnoucí obrys a „stěny“ díry dolů do prázdna (jedna geometrie pro všechny) ---------- */
  const edgeMat = new THREE.LineBasicMaterial({ color: 0xFF6A2A, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
  const wallMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide });
  const edges = new THREE.LineSegments(new THREE.BufferGeometry(), edgeMat);
  const walls = new THREE.Mesh(new THREE.BufferGeometry(), wallMat);
  edges.renderOrder = walls.renderOrder = 8; edges.frustumCulled = walls.frustumCulled = false;
  scene.add(walls, edges);
  const ARC = 6, DEPTH = 1.6;
  function drawRifts() {
    const lp = [], wp = [], wc = [];
    const hot = new THREE.Color('#FF6A2A'), cold = new THREE.Color('#000000');
    const pt = (r, a, dy = 0) => [Math.cos(a) * r, heightAt(r) + 0.06 + dy, Math.sin(a) * r];
    const side = (A, B) => {                     // úsečka obrysu + závěs (stěna) dolů od ní
      lp.push(...A, ...B);
      const A2 = [A[0], A[1] - DEPTH, A[2]], B2 = [B[0], B[1] - DEPTH, B[2]];
      wp.push(...A, ...B, ...A2, ...B, ...B2, ...A2);
      wc.push(...hot.toArray(), ...hot.toArray(), ...cold.toArray(), ...hot.toArray(), ...cold.toArray(), ...cold.toArray());
    };
    for (const k of open) {
      const p = pOf(k), m = mOf(k), r0 = bandR(p), r1 = bandR(p + 1), a0 = m * SEG, a1 = a0 + SEG;
      // kreslí se jen hrany, za kterými už trhlina nepokračuje (obrys celé roztržené oblasti)
      if (!open.has(key(p - 1, m)) || p === 0) for (let i = 0; i < ARC; i++) side(pt(r0, a0 + SEG * i / ARC), pt(r0, a0 + SEG * (i + 1) / ARC));
      if (!open.has(key(p + 1, m)) || p === PAR - 1) for (let i = 0; i < ARC; i++) side(pt(r1, a0 + SEG * i / ARC), pt(r1, a0 + SEG * (i + 1) / ARC));
      const N = 4;
      if (!open.has(key(p, (m + MER - 1) % MER))) for (let i = 0; i < N; i++) side(pt(r0 + (r1 - r0) * i / N, a0), pt(r0 + (r1 - r0) * (i + 1) / N, a0));
      if (!open.has(key(p, (m + 1) % MER))) for (let i = 0; i < N; i++) side(pt(r0 + (r1 - r0) * i / N, a1), pt(r0 + (r1 - r0) * (i + 1) / N, a1));
    }
    edges.geometry.dispose(); edges.geometry = new THREE.BufferGeometry();
    edges.geometry.setAttribute('position', new THREE.Float32BufferAttribute(lp, 3));
    walls.geometry.dispose(); walls.geometry = new THREE.BufferGeometry();
    walls.geometry.setAttribute('position', new THREE.Float32BufferAttribute(wp, 3));
    walls.geometry.setAttribute('color', new THREE.Float32BufferAttribute(wc, 3));
  }

  /* ---------- rázová vlna přes plochu ---------- */
  const SWEEP_N = 160, SWEEP_SPEED = 38;
  function addSweep(origin, color) {
    const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(SWEEP_N * 2 * 3), 3));
    const mat = new THREE.LineBasicMaterial({ color: new THREE.Color(color).lerp(new THREE.Color('#FFFFFF'), 0.6), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
    const line = new THREE.LineSegments(geo, mat); line.frustumCulled = false; line.renderOrder = 9;
    scene.add(line);
    sweeps.push({ ox: origin.x, oz: origin.z, r: 0, line, max: FUNNEL.R * 2.2 });
  }
  function drawSweep(s) {
    const a = s.line.geometry.getAttribute('position'), R = FUNNEL.R;
    let k = 0;
    const pt = i => { const an = i / SWEEP_N * TAU; return [s.ox + Math.cos(an) * s.r, s.oz + Math.sin(an) * s.r]; };
    for (let i = 0; i < SWEEP_N; i++) {
      const [x1, z1] = pt(i), [x2, z2] = pt(i + 1), r1 = Math.hypot(x1, z1), r2 = Math.hypot(x2, z2);
      if (r1 < R && r2 < R && r1 > FUNNEL.lip && r2 > FUNNEL.lip) { a.setXYZ(k++, x1, heightAt(r1) + 0.12, z1); a.setXYZ(k++, x2, heightAt(r2) + 0.12, z2); }
    }
    for (; k < SWEEP_N * 2; k++) a.setXYZ(k, 0, -999, 0);
    a.needsUpdate = true;
    s.line.material.opacity = Math.max(0, 1 - s.r / s.max);
  }

  /* ---------- oprava: jeden segment ve výseči mezi prakem a středem ---------- */
  const wedgeOf = (x, z) => Math.floor(angOf(x, z) / SEG) * SEG;   // počáteční úhel výseče
  const wedgeIdx = a0 => Math.round(a0 / SEG) % MER;
  const cellsInWedge = a0 => { const m = wedgeIdx(a0); return [...open].filter(k => mOf(k) === m && !repairs.some(rp => rp.key === k)); };
  /** je co opravit ve výseči a0 (a v této otáčce se tam ještě neopravovalo) */
  function hasInWedge(a0) {
    if (lockA !== null && wedgeIdx(a0) !== lockA) lockA = null;    // prak výseč opustil: zámek pryč
    return lockA === null && cellsInWedge(a0).length > 0;
  }
  const locked = a0 => lockA !== null && wedgeIdx(a0) === lockA && cellsInWedge(a0).length > 0;
  function repair(a0) {
    const list = cellsInWedge(a0);
    if (!list.length || locked(a0)) return false;
    const k = list.sort((a, b) => pOf(b) - pOf(a))[0];             // nejvzdálenější od středu = nejblíž praku
    lockA = wedgeIdx(a0);
    // světelná výplň segmentu: rozsvítí se, segment se scelí, pak zhasne
    const p = pOf(k), m = mOf(k), r0 = bandR(p), r1 = bandR(p + 1), N = 8, pos = [], idx = [];
    for (let i = 0; i <= N; i++) for (const r of [r0, r1]) { const a = (m + i / N) * SEG; pos.push(Math.cos(a) * r, heightAt(r) + 0.1, Math.sin(a) * r); }
    for (let i = 0; i < N; i++) { const a = i * 2; idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3); }
    const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); geo.setIndex(idx);
    const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x9FFCFF, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide }));
    mesh.renderOrder = 9; scene.add(mesh);
    repairs.push({ key: k, t: 0, mesh, done: false });
    return true;
  }
  const REPAIR_TIME = 1.0;
  function updateRepairs(dt) {
    let changed = false;
    for (let i = repairs.length - 1; i >= 0; i--) {
      const rp = repairs[i]; rp.t += dt;
      const k = Math.min(1, rp.t / REPAIR_TIME);
      rp.mesh.material.opacity = 0.85 * Math.sin(Math.PI * k);
      if (!rp.done && k >= 0.5) { rp.done = true; open.delete(rp.key); changed = true; }
      if (k >= 1) { scene.remove(rp.mesh); rp.mesh.geometry.dispose(); rp.mesh.material.dispose(); repairs.splice(i, 1); }
    }
    if (changed) onOpen?.();
  }

  /* ---------- čas ---------- */
  function update(dt, now = performance.now()) {
    updateRepairs(dt);
    for (let i = sweeps.length - 1; i >= 0; i--) {
      const s = sweeps[i]; s.r += SWEEP_SPEED * dt;
      if (s.r > s.max) { scene.remove(s.line); s.line.geometry.dispose(); s.line.material.dispose(); sweeps.splice(i, 1); continue; }
      drawSweep(s);
    }
    let opened = false;
    for (let i = waiting.length - 1; i >= 0; i--) {
      const w = waiting[i]; w.delay -= dt;
      if (w.delay <= 0) { waiting.splice(i, 1); open.add(w.key); opened = true; }
    }
    if (opened) { hotUntil = now + 1200; onOpen?.(); }
    // obrys: po otevření bíle žhne, pak chladne do pulzující oranžové
    if (open.size) {
      const hot = Math.max(0, (hotUntil - now) / 1200);
      edgeMat.color.setRGB(1, 0.42 + 0.58 * hot, 0.16 + 0.84 * hot);
      edgeMat.opacity = 0.7 + 0.3 * hot + 0.15 * Math.sin(now / 260);
      wallMat.opacity = 0.55 + 0.25 * Math.sin(now / 420) + 0.3 * hot;
    }
  }
  function clear() {
    for (const s of sweeps) { scene.remove(s.line); s.line.geometry.dispose(); s.line.material.dispose(); }
    for (const rp of repairs) { scene.remove(rp.mesh); rp.mesh.geometry.dispose(); rp.mesh.material.dispose(); }
    open.clear(); waiting.length = 0; sweeps.length = 0; repairs.length = 0; destroyed = 0; lockA = null;
    drawRifts();
  }

  return {
    trigger, apply, update, clear, inRift, repair, wedgeOf, hasInWedge, locked,
    /** uložení / načtení (uložená hra): seznam segmentů [pás, výseč] */
    exportState: () => [...open].map(k => [pOf(k), mOf(k)]),
    importState(list) {
      clear();
      // starší uložení (čáry trhlin) se nepřevádí, hra pokračuje bez trhlin
      for (const c of list || []) if (Array.isArray(c) && c.length === 2 && Number.isInteger(c[0]) && Number.isInteger(c[1])) open.add(key(c[0], c[1]));
      onOpen?.();
    },
    get repairing() { return repairs.length > 0; },
    get destroyed() { return destroyed; },
    get count() { return open.size; },
    /** středy roztržených segmentů (pro boty) */
    centers: () => [...open].map(k => cellCenter(k)),
    /** pro radar: obrysy roztržených segmentů ve světových souřadnicích [[x, z]…] */
    polygons: () => [...open].map(k => {
      const p = pOf(k), m = mOf(k), r0 = bandR(p), r1 = bandR(p + 1), out = [];
      for (let i = 0; i <= 4; i++) { const a = (m + i / 4) * SEG; out.push([Math.cos(a) * r1, Math.sin(a) * r1]); }
      for (let i = 4; i >= 0; i--) { const a = (m + i / 4) * SEG; out.push([Math.cos(a) * r0, Math.sin(a) * r0]); }
      return out;
    }),
    /** ladění: roztrhni segment pod bodem (x, z) */
    debugOpenAt(x, z) { const k = cellAt(x, z); if (k >= 0) { open.add(k); onOpen?.(); } return k; },
    configure(p) { Object.assign(CFG, p); },
    get config() { return { ...CFG }; },
  };
}

```

### assets/js/launcher.js

```javascript
// Odpalovací modul: jedna součást dole uprostřed kokpitu, která spojuje ovládání děl a katapult.
//
//        ●───────●        hroty vidlice, mezi nimi v gumách visí žeton (katapult)
//         \  (2) /
//          \    /         ramena vidlice vyrůstají z horní hrany kruhu
//        ╭──────╮
//        │  ◉   │         kruh = mapa hrací plochy: okraj kruhu = okraj plochy, střed = studna.
//                         Jezdec v kruhu ukazuje, kam děla míří; tahem ho posouváš, puštění střílí.
//        ╰──────╯
//
// Žeton se táhne dolů ke kruhu:
//   • nad horní čárou   = výstřel žetonu na plochu (čím níž, tím dál, do stran se míří opačně),
//                         na horní čáře je plný dostřel,
//   • mezi čarami       = nabíjecí zóna s odporem: žeton jde za prstem pomaleji (puštění = plný výstřel),
//                         na telefonu jemně vibruje,
//   • přes dolní čáru   = žeton se nevratně přemění na fotony (nabije děla)
//                         a tentýž tah plynule přejde v míření kruhem; puštění vystřelí.
// Klepnutí na žeton bez tahu ho jen položí na plochu.
//
// Modul řeší vzhled a ovládání (myš, dotyk, klávesnice). Herní logiku (3D dráha, žetony,
// nabití děl) dělá hra přes zpětná volání:
//   onAimStart(), onAim(dx, dy), onFire()                         … kruh
//   onSlingStart() → false = nejde, onSlingMove(st), onSlingRelease(st), onSlingCancel()
//   onConvert(st)                                                   … žeton → fotony
// st = { clientX, clientY, dx, dy, ratio }
import { L } from './i18n.js';
const NS = 'http://www.w3.org/2000/svg';
const el = (tag, attrs = {}, parent) => {
  const n = document.createElementNS(NS, tag);
  for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
  if (parent) parent.appendChild(n);
  return n;
};
const TIP_W = 54;          // polovina rozpětí hrotů vidlice (px)
const ARM_DEG = 42;        // kde ramena vyrůstají z kruhu (úhel od vrcholu kruhu)
const SEG_FROM = 58, SEG_TO = 302;   // segmenty nabití kolem kruhu (mimo místo, kde je vidlice)

// Nastavení rozložení (mění se v menu Nastavení → Odpalovací modul):
//   scale   velikost celého modulu (násobek)
//   bottom  odsazení kruhu od spodního okraje (px)
//   offsetX vodorovný posun od středu (podíl šířky obrazovky, −0,4 … 0,4)
//   hang    kde visí žeton: podíl cesty od kruhu ke středu obrazovky (0,5 = napůl)
//   spread  rozpětí vidlice (násobek)
//   opacity průhlednost vzhledu modulu (ovládání funguje dál)
//   zone    výška nabíjecí zóny mezi dvěma čarami (podíl poloměru kruhu, zóna je kolem jeho středu)
//   resist  odpor v zóně: kolik z pohybu prstu žeton udělá (1 = bez odporu)
//   haptics vibrace při natahování (Android; iPhone vibrace z prohlížeče nepodporuje)
//   ring    velikost kruhu pro míření (násobek, nezávisle na velikosti modulu)
//   knob    velikost jezdce v kruhu
//   tapAim  klepnutí do kruhu přesune jezdce přímo na to místo (jinak jen tah)
//   fireOnRelease  puštění kruhu po tahu vystřelí (jinak jen F / mezerník / Enter)
//   mapDots tělesa jako tečky v kruhu
export const LAUNCHER_DEFAULTS = { scale: 1, bottom: 18, offsetX: 0, hang: 0.5, spread: 1, opacity: 1, zone: 0.6, resist: 0.5, haptics: true,
  ring: 1, knob: 1, tapAim: false, fireOnRelease: true, mapDots: true, edge: 1 };
// edge = posun u okraje obrazovky (poloměrů mapy za sekundu; 0 = vypnuto): když prst nebo myš
// při míření dojede k okraji obrazovky a tlačí dál, jezdec jede sám tím směrem.
// Hlavně po přeměně žetonu na fotony, kdy je prst už dole u okraje a dál nemůže.
const EDGE_PX = 56;
const buzz = p => { try { navigator.vibrate?.(p); } catch (e) { /* bez vibrací */ } };

export function createLauncher(root, cb = {}) {
  const CFG = { ...LAUNCHER_DEFAULTS };
  /* ---------- vzhled (SVG, bez událostí) ---------- */
  // Styl „HUD stanice“: široký panel nabíjení za kruhem, dlouhé vzpěry od hrotů k dolním uzlům,
  // dvojitá ramena vidlice, příčka s medailonem náboje, kruh s rámem segmentů a zaměřovacím křížem.
  const svg = el('svg', { class: 'lc', 'aria-hidden': 'true' });
  root.appendChild(svg);
  const defs = el('defs', {}, svg);
  defs.innerHTML = `
    <radialGradient id="lc-disc"><stop offset="0" stop-color="#7DF9FF" stop-opacity=".10"/><stop offset=".55" stop-color="#03070D" stop-opacity=".78"/><stop offset="1" stop-color="#03070D" stop-opacity=".92"/></radialGradient>
    <linearGradient id="lc-arm" x1="0" x2="0" y1="1" y2="0"><stop offset="0" stop-color="#7DF9FF"/><stop offset="1" stop-color="#FFD66B"/></linearGradient>`;
  const gPanel = el('g', { class: 'lc-panel' }, svg);         // boční panely (pozadí nabíjecí zóny)
  // nabíjecí zóna: horní čára = plný nátah (začne odpor), dolní čára = přeměna na fotony
  const gLimit = el('g', { class: 'lc-limit' }, svg);
  const zoneBand = el('path', { class: 'lc-zone' }, gLimit);
  const lineU = el('path', { class: 'lc-line-u' }, gLimit);
  const lineL = el('path', { class: 'lc-line-l' }, gLimit);
  const gTabs = el('g', { class: 'lc-tabs' }, gLimit);
  const textU = el('text', { class: 'lc-text-u' }, gLimit);
  const textL = el('text', { class: 'lc-text-l' }, gLimit);
  textU.textContent = L('PLNÝ NÁTAH', 'FULL DRAW');
  textL.textContent = L('FOTONY ↓', 'PHOTONS ↓');
  // vzpěry od hrotů vidlice k dolním uzlům, na nich stupnice nátahu (rozsvěcuje se, jak žeton táhneš dolů)
  const gHud = el('g', { class: 'lc-hud' }, svg);
  let wingTicks = [];
  const gFork = el('g', { class: 'lc-fork' }, svg);
  const gRing = el('g', { class: 'lc-ring' }, svg);
  const gBezel = el('g', { class: 'lc-bezel' }, gRing);
  const gSeg = el('g', { class: 'lc-seg' }, gRing);
  const disc = el('circle', { class: 'lc-disc', fill: 'url(#lc-disc)' }, gRing);
  const gDeco = el('g', { class: 'lc-deco' }, gRing);
  const ringInner = el('circle', { class: 'lc-inner' }, gRing);
  // mapa plochy v kruhu: studna uprostřed a tělesa jako tečky (doplňuje hra přes setMap)
  const gMap = el('g', { class: 'lc-map' }, gRing);
  const mapWell = el('circle', { class: 'lc-map-well' }, gRing);
  const knob = el('g', { class: 'lc-knob' }, gRing);
  el('circle', { r: 9 }, knob);
  el('path', { d: 'M -15 0 L -11 0 M 11 0 L 15 0 M 0 -15 L 0 -11 M 0 11 L 0 15' }, knob);
  el('circle', { r: 2, class: 'lc-knob-dot' }, knob);
  const bandL = el('line', { class: 'lc-band' }, svg);
  const bandR = el('line', { class: 'lc-band' }, svg);
  // medailon: kolébka náboje mezi hroty (oranžový dvojitý kruh)
  const med = el('g', { class: 'lc-med' }, svg);
  el('circle', { r: 27, class: 'lc-med-o' }, med);
  el('circle', { r: 22, class: 'lc-med-r' }, med);
  el('circle', { r: 18.5, class: 'lc-cradle' }, med);
  const coin = el('g', { class: 'lc-coin' }, svg);
  // náboj v praku: žeton (2) nebo mnohostěn (4, 6, 8) jako drátěný obrázek v kapsli
  el('circle', { r: 15, class: 'lc-coin-body' }, coin);
  const coinRim = el('circle', { r: 10, class: 'lc-coin-rim' }, coin);
  const coinShape = el('path', { class: 'lc-coin-shape' }, coin);
  const coinNum = el('text', { y: 5, class: 'lc-coin-num' }, coin);
  coinNum.textContent = '2';
  const orb = el('circle', { r: 12, class: 'lc-orb' }, svg);
  const statusBox = el('path', { class: 'lc-status-box' }, svg);
  const statusBar = el('path', { class: 'lc-status-bar' }, svg);
  const status = el('text', { class: 'lc-status' }, svg);
  // obdélník se zkosenými rohy (vlevo nahoře a vpravo dole), základní tvar všech panelů HUD
  const chamf = (x, y, w, h, c) => `M ${x + c} ${y} H ${x + w} V ${y + h - c} L ${x + w - c} ${y + h} H ${x} V ${y + c} Z`;
  // dvojitá linka: tmavý obrys, světlá trubka, tmavé jádro (dvě svítící hrany)
  const tube = (d, g, w) => {
    el('path', { d, class: 'lc-tube-o', style: `stroke-width:${w * 1.7}px` }, g);
    el('path', { d, class: 'lc-tube', style: `stroke-width:${w}px` }, g);
    el('path', { d, class: 'lc-tube-c', style: `stroke-width:${w * 0.42}px` }, g);
  };
  const node = (x, y, r, g, cls = '') => {
    el('circle', { cx: x, cy: y, r: r * 1.45, class: 'lc-node-halo' + cls }, g);
    el('circle', { cx: x, cy: y, r, class: 'lc-node' + cls }, g);
    el('circle', { cx: x, cy: y, r: r * 0.42, class: 'lc-node-dot' + cls }, g);
  };

  /* ---------- plochy pro ovládání (HTML, kvůli dotyku a klávesnici) ---------- */
  const ringHit = document.createElement('div');
  ringHit.className = 'lc-hit lc-hit-ring';
  ringHit.tabIndex = 0;
  ringHit.setAttribute('role', 'slider');
  ringHit.setAttribute('aria-label', L('Ovládání děl: táhni po kruhu = míření, pusť = výstřel, šipky míří, Enter vystřelí', 'Cannon controls: drag around the ring = aim, release = fire, arrow keys aim, Enter fires'));
  const cradleHit = document.createElement('div');
  cradleHit.className = 'lc-hit lc-hit-cradle';
  cradleHit.tabIndex = 0;
  cradleHit.setAttribute('role', 'button');
  cradleHit.setAttribute('aria-label', L('Katapult: táhni žeton dolů ke kruhu = výstřel, přes střed kruhu = fotony pro děla, Enter = položit žeton', 'Sling: drag the coin down to the ring = shoot, past the ring centre = photons for the cannons, Enter = place the coin'));
  root.append(ringHit, cradleHit);

  /* ---------- rozměry ---------- */
  const G = { W: 0, H: 0, cx: 0, cy: 0, R: 66, px: 0, py: 0, S: 1, tip: TIP_W };
  function layout() {
    G.W = root.clientWidth; G.H = root.clientHeight;
    svg.setAttribute('viewBox', `0 0 ${G.W} ${G.H}`);
    const S = G.S = CFG.scale;
    svg.style.setProperty('--s', S);
    svg.style.opacity = CFG.opacity;
    G.R = (G.W < 760 ? 58 : 66) * S * CFG.ring;
    gMap.style.display = CFG.mapDots ? '' : 'none';
    G.tip = TIP_W * S * CFG.spread;
    const half = Math.max(G.R, G.tip + 30 * S) + 8;            // modul se vždy vejde na obrazovku
    G.cx = Math.min(G.W - half, Math.max(half, G.W / 2 + CFG.offsetX * G.W));
    G.cy = Math.max(G.H * 0.55, G.H - CFG.bottom - G.R - 10 * S);   // místo pro rám kruhu
    // žeton visí mezi kruhem a středem obrazovky (podle hang), ale vždy aspoň kus nad kruhem
    G.px = G.cx; G.py = Math.min(G.cy - (G.cy - G.H / 2) * CFG.hang, G.cy - G.R - 56 * S);
    const { cx, cy, R, px, py } = G, TIP = G.tip, hit = 80 * S;
    Object.assign(ringHit.style, { left: `${cx - R}px`, top: `${cy - R}px`, width: `${2 * R}px`, height: `${2 * R}px` });
    Object.assign(cradleHit.style, { left: `${px - hit / 2}px`, top: `${py - hit / 2}px`, width: `${hit}px`, height: `${hit}px` });
    G.Rk = R - 6 * S;                                          // poloměr mapy (okraj plochy)
    mapWell.setAttribute('cx', cx); mapWell.setAttribute('cy', cy); mapWell.setAttribute('r', Math.max(2.5, G.Rk * wellR));
    disc.setAttribute('cx', cx); disc.setAttribute('cy', cy); disc.setAttribute('r', R);
    ringInner.setAttribute('cx', cx); ringInner.setAttribute('cy', cy); ringInner.setAttribute('r', R * 0.62);
    med.setAttribute('transform', `translate(${px} ${py}) scale(${S})`);
    const narrow = G.W < 760, tiny = G.W < 520;
    svg.classList.toggle('narrow', tiny);
    // čáry nabíjení: zlatá = plný nátah (začíná odpor), azurová = přeměna na fotony
    G.U = cy - R * CFG.zone / 2; G.L = cy + R * CFG.zone / 2;
    const xin = R + 3 * S, xout = Math.min(R * (narrow ? 2.5 : 3.25), cx - 4, G.W - cx - 4);
    G.xout = xout;
    /* boční panel za kruhem: zkosený rám, vnitřní linka, mřížka */
    gPanel.innerHTML = '';
    const pT = G.U - 9 * S, pB = G.L + (tiny ? 9 : 24) * S, pX = cx - xout, pW = 2 * xout, c = 12 * S;
    el('path', { d: chamf(pX, pT, pW, pB - pT, c), class: 'lc-pan' }, gPanel);
    el('path', { d: chamf(pX + 4 * S, pT + 4 * S, pW - 8 * S, pB - pT - 8 * S, c * 0.7), class: 'lc-pan-i' }, gPanel);
    let grid = '';
    const step = 30 * S;
    for (let x = pX + step; x < pX + pW - 6 * S; x += step) grid += `M ${x.toFixed(1)} ${pT + 6 * S} V ${pB - 6 * S} `;
    grid += `M ${pX + 6 * S} ${cy} H ${pX + pW - 6 * S}`;
    el('path', { d: grid, class: 'lc-pan-grid' }, gPanel);
    // drobné značky na rámu panelu (jako na technickém výkresu)
    for (const s of [-1, 1]) {
      const x0 = cx + s * (xout - 16 * S);
      el('path', { d: `M ${x0} ${pB - 2 * S} h ${-s * 26 * S} M ${x0} ${pT + 2 * S} h ${-s * 18 * S}`, class: 'lc-pan-mark' }, gPanel);
    }
    const seg = y => `M ${cx - xout + 6 * S} ${y} H ${cx - xin} M ${cx + xin} ${y} H ${cx + xout - 6 * S}`;
    lineU.setAttribute('d', seg(G.U)); lineL.setAttribute('d', seg(G.L));
    zoneBand.setAttribute('d', `M ${cx - xout + 6 * S} ${G.U} H ${cx - xin} V ${G.L} H ${cx - xout + 6 * S} Z M ${cx + xin} ${G.U} H ${cx + xout - 6 * S} V ${G.L} H ${cx + xin} Z`);
    // štítek PLNÝ NÁTAH: zkosená záložka nad levým koncem horní čáry
    gTabs.innerHTML = '';
    const tabW = 92 * S, tabH = 20 * S, tabX = pX - 2 * S, tabY = pT - tabH + 3 * S;
    el('path', { d: `M ${tabX} ${tabY + tabH} V ${tabY + 5 * S} L ${tabX + 5 * S} ${tabY} H ${tabX + tabW - 12 * S} L ${tabX + tabW} ${tabY + tabH}`, class: 'lc-tab' }, gTabs);
    textU.setAttribute('x', tabX + 9 * S); textU.setAttribute('y', tabY + 14 * S);
    textL.setAttribute('x', pX + 9 * S); textL.setAttribute('y', pB - 8 * S);
    /* vidlice: dvojitá ramena z horní hrany kruhu k hrotům, příčka s medailonem, svislá osa */
    gFork.innerHTML = '';
    const a = ARM_DEG * Math.PI / 180, ax = R * Math.sin(a), ay = cy - R * Math.cos(a);
    const arm = s => `M ${cx + s * ax} ${ay + 2 * S} C ${cx + s * (ax + 8 * S)} ${ay - 26 * S} ${cx + s * TIP} ${py + (ay - py) * 0.62} ${cx + s * TIP} ${py}`;
    el('path', { d: `M ${cx} ${py + 28 * S} V ${cy - R - 18 * S}`, class: 'lc-axis' }, gFork);
    el('path', { d: `M ${cx} ${cy + R + 18 * S} V ${G.H}`, class: 'lc-axis' }, gFork);
    tube(arm(-1), gFork, 5.5 * S); tube(arm(1), gFork, 5.5 * S);
    tube(`M ${cx - TIP} ${py} H ${cx - 27 * S} M ${cx + 27 * S} ${py} H ${cx + TIP}`, gFork, 4 * S);
    for (const s of [-1, 1]) node(cx + s * TIP, py, 7.5 * S, gFork, ' lc-tip');
    /* kruh: rám se segmenty nabití, svorky, oranžové oblouky, zaměřovací kříž, terč */
    gBezel.innerHTML = '';
    el('circle', { cx, cy, r: R + 10 * S, class: 'lc-bez-fill', style: `stroke-width:${15 * S}px` }, gBezel);
    el('circle', { cx, cy, r: R + 18 * S, class: 'lc-bez-o' }, gBezel);
    el('circle', { cx, cy, r: R + 2.5 * S, class: 'lc-bez-i' }, gBezel);
    gDeco.innerHTML = '';
    const P = (ang, r) => [cx + r * Math.sin(ang * Math.PI / 180), cy - r * Math.cos(ang * Math.PI / 180)];
    const arc = (a0, a1, r) => { const [x0, y0] = P(a0, r), [x1, y1] = P(a1, r); return `M ${x0} ${y0} A ${r} ${r} 0 0 1 ${x1} ${y1}`; };
    for (const ang of [45, 135, 225, 315]) {
      const [x, y] = P(ang, R - 7 * S);
      el('rect', { x: x - 7 * S, y: y - 4.5 * S, width: 14 * S, height: 9 * S, rx: 1.5 * S, class: 'lc-clamp', transform: `rotate(${ang} ${x} ${y})` }, gDeco);
    }
    el('path', { d: arc(258, 282, R * 0.86) + ' ' + arc(78, 102, R * 0.86), class: 'lc-oarc' }, gDeco);
    el('path', { d: arc(-6, 6, R * 0.8) + ' ' + arc(174, 186, R * 0.8), class: 'lc-oarc lc-oarc-t' }, gDeco);
    const r0 = R * 0.24, r1 = R - 12 * S;
    el('path', { d: `M ${cx - r1} ${cy} H ${cx - r0} M ${cx + r0} ${cy} H ${cx + r1} M ${cx} ${cy - r1} V ${cy - r0} M ${cx} ${cy + r0} V ${cy + r1}`, class: 'lc-xh' }, gDeco);
    const k = R * 0.44, kl = 9 * S;
    el('path', { d: [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sy]) => `M ${cx + sx * k} ${cy + sy * (k - kl)} V ${cy + sy * k} H ${cx + sx * (k - kl)}`).join(' '), class: 'lc-brk' }, gDeco);
    el('circle', { cx, cy, r: R * 0.2, class: 'lc-tgt' }, gDeco);
    el('circle', { cx, cy, r: R * 0.12, class: 'lc-tgt lc-tgt-2' }, gDeco);
    /* vzpěry: hrot vidlice → dolní uzel, tenká linka z uzlu zpět ke kruhu */
    gHud.innerHTML = ''; wingTicks = [];
    for (const s of [-1, 1]) {
      const T = [cx + s * TIP, py];
      const B = [Math.min(G.W - 12 * S, Math.max(12 * S, cx + s * R * (narrow ? 2.05 : 2.3))), Math.min(cy + R * 0.98, G.H - 12 * S)];
      const [ix, iy] = P(s * 80, R + 18 * S);
      el('path', { d: `M ${B[0]} ${B[1]} L ${ix} ${iy}`, class: 'lc-wing-inner' }, gHud);
      el('path', { d: `M ${T[0]} ${T[1]} L ${B[0]} ${B[1]}`, class: 'lc-strut-o', style: `stroke-width:${7 * S}px` }, gHud);
      el('path', { d: `M ${T[0]} ${T[1]} L ${B[0]} ${B[1]}`, class: 'lc-strut', style: `stroke-width:${2.4 * S}px` }, gHud);
      // stupnice nátahu na vnější straně vzpěry: od hrotu dolů po zlatou čáru
      const len = Math.hypot(B[0] - T[0], B[1] - T[1]), ux = (B[0] - T[0]) / len, uy = (B[1] - T[1]) / len;
      const nx = uy * s, ny = -ux * s;                            // kolmice ven ze vzpěry
      const N = 12, yEnd = Math.min(G.U, B[1]);
      for (let i = 1; i <= N; i++) {
        const y = T[1] + (yEnd - T[1]) * i / N, t = (y - T[1]) / Math.max(1e-3, uy), x = T[0] + ux * t;
        const Lk = (i % 4 === 0 ? 9 : 5) * S, o = 4 * S;
        wingTicks.push({ y, node: el('line', { x1: x + nx * o, y1: y + ny * o, x2: x + nx * (o + Lk), y2: y + ny * (o + Lk), class: 'lc-tick' + (i % 4 === 0 ? ' major' : '') }, gHud) });
      }
      node(B[0], B[1], 8 * S, gHud, ' lc-foot');
    }
    /* stav děl: zkosený štítek vlevo pod panelem */
    // štítek leží mezi levou vzpěrou a rámem kruhu (vpravo ho omezuje rám kruhu v dané výšce)
    const sw = 74 * S, sh = 21 * S, sy = Math.min(pB + 6 * S, G.H - sh - 4), Rb = R + 18 * S, dy = sy + sh / 2 - cy;
    const sx = Math.max(4, cx - Math.sqrt(Math.max(0, Rb * Rb - dy * dy)) - 6 * S - sw);
    statusBox.setAttribute('d', chamf(sx, sy, sw, sh, 6 * S));
    statusBar.setAttribute('d', `M ${sx + 8 * S} ${sy + sh - 4 * S} h ${30 * S}`);
    status.setAttribute('x', sx + sw - 9 * S); status.setAttribute('y', sy + 14.5 * S);
    drawSegments();
    if (mode !== 'sling') placeCoin(px, py);
    setKnob();
    drawMap();
  }

  /* ---------- nabití děl: segmenty kolem kruhu + popisek ---------- */
  let charge = 0, chargeMax = 8;
  function drawSegments() {
    gSeg.innerHTML = '';
    // segmenty jako desky v rámu kruhu (mezikruží), plní se zleva (od vidlice) dolů a zpět nahoru doprava
    const { cx, cy, R } = G, r0 = R + 4 * G.S, r1 = R + 16 * G.S, n = Math.max(1, chargeMax), span = (SEG_TO - SEG_FROM) / n, gap = Math.min(2.5, span * 0.22);
    const p = (a, r) => [cx + r * Math.sin(a), cy - r * Math.cos(a)];
    for (let i = 0; i < n; i++) {
      const a0 = (SEG_TO - i * span - gap / 2) * Math.PI / 180, a1 = (SEG_TO - (i + 1) * span + gap / 2) * Math.PI / 180;
      const [x0, y0] = p(a0, r1), [x1, y1] = p(a1, r1), [x2, y2] = p(a1, r0), [x3, y3] = p(a0, r0);
      el('path', { d: `M ${x0} ${y0} A ${r1} ${r1} 0 0 0 ${x1} ${y1} L ${x2} ${y2} A ${r0} ${r0} 0 0 1 ${x3} ${y3} Z`, class: 'lc-segment' + (i < charge ? ' on' : '') }, gSeg);
    }
  }
  function setCharge(n, max) {
    const redraw = max !== chargeMax;
    charge = n; chargeMax = max;
    if (redraw) drawSegments();
    else [...gSeg.children].forEach((s, i) => s.classList.toggle('on', i < n));
    svg.classList.toggle('empty', n <= 0);
    status.textContent = n > 0 ? L(`NABITO ${n}`, `CHARGED ${n}`) : L('PRÁZDNÁ', 'EMPTY');
    ringHit.setAttribute('aria-valuetext', L(`Nabití děl: ${n} z ${max}`, `Cannon charge: ${n} of ${max}`));
  }

  /* ---------- žeton a gumy ---------- */
  let coinReady = true;
  function placeCoin(x, y) {
    coin.setAttribute('transform', `translate(${x} ${y}) scale(${G.S})`);
    const pull = Math.max(0, y - G.py) / Math.max(1, G.cy - G.py);            // 0 = v klidu, 1 = u středu kruhu
    for (const [b, s] of [[bandL, -1], [bandR, 1]]) {
      b.setAttribute('x1', G.cx + s * G.tip); b.setAttribute('y1', G.py + 2 * G.S);
      b.setAttribute('x2', x + s * 11 * G.S); b.setAttribute('y2', y);
      b.style.strokeWidth = ((3.2 - 1.8 * pull) * Math.sqrt(G.S)).toFixed(2);
    }
    svg.style.setProperty('--pull', pull.toFixed(3));
    for (const t of wingTicks) t.node.classList.toggle('on', y >= t.y - 0.5 && y > G.py + 2);
  }
  function reloadCoin(delay = 380) {
    coinReady = false; svg.classList.add('reloading');
    placeCoin(G.px, G.py);
    setTimeout(() => {
      coinReady = true; svg.classList.remove('reloading');
      coin.classList.remove('reload'); void coin.getBBox(); coin.classList.add('reload');
    }, delay);
  }

  /* ---------- ovládání ---------- */
  let mode = 'idle';                  // 'idle' | 'aim' | 'sling'
  let sens = 1;                     // citlivost míření (násobek pohybu prstu)
  let last = null, moved = 0, sl = null;
  const local = (x, y) => { const o = root.getBoundingClientRect(); return [x - o.left, y - o.top]; };
  // záznam ovládání (CBT): kind 'sling' | 'ring', phase 'down' | 'move' | 'up'
  const emit = (kind, phase, x, y) => cb.onInput?.(kind, phase, x, y);
  const kindNow = () => (mode === 'aim' ? 'ring' : 'sling');
  // jezdec v kruhu: poloha na mapě plochy (nx, ny v jednotkovém kruhu, ny kladné = dolů = blíž k hráči)
  const aim = { nx: 0, ny: 0 };
  function setKnob() { knob.setAttribute('transform', `translate(${G.cx + aim.nx * G.Rk} ${G.cy + aim.ny * G.Rk}) scale(${G.S * CFG.knob})`); }
  function nudge(dx, dy) {                        // posun jezdce o dx, dy pixelů kruhu
    let nx = aim.nx + dx / G.Rk, ny = aim.ny + dy / G.Rk;
    const l = Math.hypot(nx, ny); if (l > 1) { nx /= l; ny /= l; }   // okraj kruhu = okraj plochy
    aim.nx = nx; aim.ny = ny; setKnob();
    cb.onAim?.(aim.nx, aim.ny);
  }

  // posun u okraje obrazovky: zapne se, až prst v okrajovém pásu zatlačí směrem ven
  // (samotné stisknutí ani zastavení u okraje nic nespustí), vypne se pohybem zpět nebo z pásu ven
  const edge = { on: false, ex: 0, ey: 0 };
  function edgeVec(x, y) {
    const [lx, ly] = local(x, y), Z = EDGE_PX;
    const f = (v, max) => v < Z ? -(Z - v) / Z : v > max - Z ? (v - (max - Z)) / Z : 0;
    return [Math.max(-1, Math.min(1, f(lx, G.W))), Math.max(-1, Math.min(1, f(ly, G.H)))];
  }
  function aimStart(x, y, carried = 0) {
    mode = 'aim'; last = { x, y }; moved = carried; edge.on = false;
    svg.classList.add('aiming');
    cb.onAimStart?.();
  }
  function aimMove(x, y) {
    const dx = x - last.x, dy = y - last.y;
    moved += Math.hypot(dx, dy); last = { x, y };
    nudge(dx * sens, dy * sens);                    // jezdec jde s prstem, zůstane, kde ho pustíš
    const [ex, ey] = edgeVec(x, y), push = dx * ex + dy * ey;
    if (!ex && !ey) edge.on = false;
    else if (push > 0.5) edge.on = true;            // tlačí ven z obrazovky
    else if (push < -2) edge.on = false;            // vrací se zpátky
    edge.ex = ex; edge.ey = ey;
  }
  function tick(dt) {
    const on = mode === 'aim' && edge.on && CFG.edge > 0;
    if (on) {
      const v = CFG.edge * G.Rk * dt;               // v pixelech kruhu
      nudge(edge.ex * v, edge.ey * v);
      moved += 1;
    }
    svg.classList.toggle('edge', on);
  }
  function aimEnd(fire = true) {
    mode = 'idle'; edge.on = false; svg.classList.remove('aiming', 'edge');
    if (fire && CFG.fireOnRelease && moved >= 6) cb.onFire?.();   // samotné klepnutí do kruhu nestřílí
  }

  /* ---------- mapa těles v kruhu ---------- */
  let wellR = 0.04, mapItems = [];
  function drawMap() {
    const n = mapItems.length;
    while (gMap.childElementCount < n) el('circle', {}, gMap);
    while (gMap.childElementCount > n) gMap.lastChild.remove();
    mapItems.forEach((it, i) => {
      const c = gMap.children[i];
      c.setAttribute('cx', G.cx + it.nx * G.Rk); c.setAttribute('cy', G.cy + it.ny * G.Rk);
      c.setAttribute('r', Math.max(1.6, (it.r ?? 0.04) * G.Rk));
      c.setAttribute('fill', it.color || '#fff');
      c.setAttribute('class', it.target ? 'target' : '');
    });
  }

  function slingStart(x, y) {
    if (!coinReady || cb.onSlingStart?.() === false) return false;
    mode = 'sling'; sl = { x0: x, y0: y, st: null, zone: false, tickY: 0 };
    svg.classList.add('slinging');
    slingMove(x, y);
    return true;
  }
  function slingMove(x, y) {
    const [lx, ly] = local(x, y), [lx0, ly0] = local(sl.x0, sl.y0);
    // žeton jde za prstem; pod horní čárou s odporem (pohyb se zpomalí)
    const cx = G.px + (lx - lx0);
    let cy = Math.max(G.py - 30 * G.S, G.py + (ly - ly0));
    const inZone = cy > G.U;
    if (inZone) cy = G.U + (cy - G.U) * CFG.resist;
    // poměr nátahu: plný dostřel na horní čáře, v zóně zůstává plný
    const dx = cx - G.px, dy = cy - G.py;
    const st = { clientX: x, clientY: y, dx, dy, ratio: Math.min(1, Math.hypot(dx, Math.min(cy, G.U) - G.py) / Math.max(40, G.U - G.py)), zone: inZone };
    sl.st = st;
    // hmatová odezva: cvak při vstupu do zóny, jemné tikání v ní
    if (CFG.haptics) {
      if (inZone && !sl.zone) buzz(20);
      else if (inZone && Math.abs(cy - sl.tickY) > 8 * G.S) { buzz(6); sl.tickY = cy; }
    }
    if (inZone && !sl.zone) sl.tickY = cy;
    sl.zone = inZone;
    svg.classList.toggle('zone', inZone);
    svg.style.setProperty('--charge', inZone ? ((cy - G.U) / Math.max(1, G.L - G.U)).toFixed(3) : 0);
    if (cy >= G.L) { if (CFG.haptics) buzz([35, 40, 70]); convert(st, cx, cy); return; }
    placeCoin(cx, cy);
    cb.onSlingMove?.(st);
  }
  // žeton přes střed kruhu: nevratná přeměna na fotony, tah pokračuje jako míření
  function convert(st, lx, ly) {
    svg.classList.remove('slinging', 'zone');
    orb.setAttribute('cx', lx); orb.setAttribute('cy', ly);
    orb.classList.remove('flash'); void orb.getBBox(); orb.classList.add('flash');
    burst();
    cb.onConvert?.(st);
    reloadCoin(600);
    aimStart(st.clientX, st.clientY, 100);          // puštění pak vystřelí
  }
  function slingEnd(cancel = false) {
    mode = 'idle'; svg.classList.remove('slinging', 'zone');
    const st = sl?.st; sl = null;
    if (cancel || !st) { placeCoin(G.px, G.py); cb.onSlingCancel?.(); return; }
    cb.onSlingRelease?.(st);
    reloadCoin();
  }

  // ukazatel: každá plocha si ho po stisku podrží, pohyb i puštění pak řeší společně
  const down = kind => e => {
    if (e.button > 0) return;
    e.preventDefault();
    if (kind === 'ring' && CFG.tapAim) {          // klepnutí = jezdec přímo pod prst
      const [lx, ly] = local(e.clientX, e.clientY);
      aim.nx = 0; aim.ny = 0; nudge(lx - G.cx, ly - G.cy);
    }
    const ok = kind === 'ring' ? (aimStart(e.clientX, e.clientY), true) : slingStart(e.clientX, e.clientY);
    if (ok) { e.currentTarget.setPointerCapture(e.pointerId); emit(kind === 'ring' ? 'ring' : 'sling', 'down', e.clientX, e.clientY); }
  };
  const move = e => {
    if (mode === 'idle') return;
    if (mode === 'aim') aimMove(e.clientX, e.clientY); else if (mode === 'sling') slingMove(e.clientX, e.clientY);
    lastXY = [e.clientX, e.clientY];
    emit(kindNow(), 'move', e.clientX, e.clientY);
  };
  let lastXY = [0, 0];
  const up = e => {
    if (mode === 'idle') return;
    const k = kindNow(), x = e?.clientX ?? lastXY[0], y = e?.clientY ?? lastXY[1];
    if (mode === 'aim') aimEnd(); else if (mode === 'sling') slingEnd();
    emit(k, 'up', x, y);
  };
  const cancel = () => { if (mode === 'idle') return; const k = kindNow(); if (mode === 'aim') aimEnd(false); else if (mode === 'sling') slingEnd(true); emit(k, 'up', lastXY[0], lastXY[1]); };
  ringHit.addEventListener('pointerdown', down('ring'));
  cradleHit.addEventListener('pointerdown', down('cradle'));
  for (const h of [ringHit, cradleHit]) {
    h.addEventListener('pointermove', move);
    h.addEventListener('pointerup', up);
    h.addEventListener('pointercancel', cancel);
  }
  // klávesnice: na kruhu šipky míří a Enter/mezerník střílí, na katapultu Enter položí žeton
  ringHit.addEventListener('keydown', e => {
    const k = { ArrowLeft: [-3, 0], ArrowRight: [3, 0], ArrowUp: [0, -3], ArrowDown: [0, 3] }[e.key];
    if (k) { e.preventDefault(); nudge(k[0], k[1]); cb.onKey?.('aim', e.keyCode || 0); }
    if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cb.onKey?.('fire', e.keyCode || 0); cb.onFire?.(); }
  });
  cradleHit.addEventListener('keydown', e => {
    if (e.key !== 'Enter' && e.key !== ' ') return;
    e.preventDefault();
    if (coinReady && cb.onSlingStart?.() !== false) { cb.onSlingRelease?.({ clientX: 0, clientY: 0, dx: 0, dy: 0, ratio: 0 }); reloadCoin(); }
  });

  /* ---------- efekty ---------- */
  function burst() { svg.classList.remove('burst'); void svg.getBoundingClientRect(); svg.classList.add('burst'); }
  function dryFire() { svg.classList.remove('dry'); void svg.getBoundingClientRect(); svg.classList.add('dry'); }

  new ResizeObserver(layout).observe(root);
  layout();
  setCharge(0, chargeMax);

  const toClient = (x, y) => { const o = root.getBoundingClientRect(); return { x: x + o.left, y: y + o.top }; };
  return {
    setCharge, dryFire, burst, nudge, tick,
    /** náboj v praku: { value, color, segs: [[x1,y1,x2,y2]…] v jednotkovém kruhu } (segs prázdné = žeton) */
    setAmmo(a) {
      const flat = !a.segs?.length;
      coin.classList.toggle('poly', !flat);
      coin.style.setProperty('--ammo', a.color);
      coinRim.style.display = flat ? '' : 'none';
      coinShape.setAttribute('d', flat ? '' : a.segs.map(([x1, y1, x2, y2]) => `M ${(x1 * 12).toFixed(1)} ${(y1 * 12).toFixed(1)} L ${(x2 * 12).toFixed(1)} ${(y2 * 12).toFixed(1)}`).join(' '));
      coinNum.textContent = a.value;
      cradleHit.setAttribute('aria-label', L(`Katapult, nabito: ${a.value} ${a.name || ''}. Táhni dolů ke kruhu = výstřel, přes střed kruhu = fotony pro děla, Enter = položit`, `Sling, loaded: ${a.value} ${a.name || ''}. Drag down to the ring = shoot, past the ring centre = photons for the cannons, Enter = place`));
    },
    /** na náboj v praku nejsou Quansistory (žeton ztlumený, jde jen nabít děla) */
    setBroke(b) { svg.classList.toggle('broke', !!b); },
    /** pravý okraj celého modulu (souřadnice hry) */
    rightEdge() { return G.cx + (G.xout ?? G.R * 3.4); },
    /** kam míří jezdec: { nx, ny } v jednotkovém kruhu (okraj = okraj plochy) */
    aim() { return { ...aim }; },
    setAim(nx, ny) { aim.nx = nx; aim.ny = ny; setKnob(); },
    /** mapa: items = [{ nx, ny, r, color, target }], well = poloměr studny (podíl plochy) */
    setMap(items, well) { mapItems = items; if (well != null) wellR = well; mapWell.setAttribute('r', Math.max(2.5, G.Rk * wellR)); drawMap(); },
    /** změna rozložení (velikost, odsazení…); hra pak přepočítá, co na modulu závisí */
    configure(patch) { Object.assign(CFG, patch); layout(); cb.onLayout?.(); },
    get config() { return { ...CFG }; },
    get mode() { return mode; },
    get sensitivity() { return sens; },
    set sensitivity(v) { sens = v; },
    /** katapult nad studnou nejde použít */
    setEnabled(on) { svg.classList.toggle('disabled', !on); cradleHit.classList.toggle('disabled', !on); },
    /** horní okraj celého modulu (hroty vidlice) v souřadnicích hry, pod něj zaměřovač nesjede */
    top() { return G.py - 30; },
    /** levý okraj celého modulu (konce čar nabíjení), souřadnice hry */
    leftEdge() { return G.cx - (G.xout ?? G.R * 3.4); },
    /** střed kruhu (souřadnice hry, px) – odsud letí fotony do děl */
    ringCenter() { return { x: G.cx, y: G.cy, r: G.R }; },
    ringScreen() { const c = toClient(G.cx, G.cy); return { ...c, r: G.R, rk: G.Rk }; },
    /** poloha ukazatele vůči kruhu: 0 = střed, ±1 = okraj mapy v kruhu */
    toRing(clientX, clientY) { const [x, y] = local(clientX, clientY); return [(x - G.cx) / G.Rk, (y - G.cy) / G.Rk]; },
    /** poloha jezdce v kruhu (souřadnice okna) a místo pro uchopení praku */
    knobScreen() { return toClient(G.cx + aim.nx * G.Rk, G.cy + aim.ny * G.Rk); },
    hitRadius() { return 40 * G.S; },
    /** místo žetonu v klidu na obrazovce (clientX/Y) */
    cradleScreen() { return toClient(G.px, G.py); },
    /** pro ladění: simulace ukazatele (clientX/Y) */
    /** geometrie praku v souřadnicích okna (pro bota): klidová poloha žetonu, čáry nabíjení, plný nátah */
    slingGeom() {
      const o = root.getBoundingClientRect();
      return { x: G.px + o.left, y: G.py + o.top, U: G.U + o.top, L: G.L + o.top, maxPull: Math.max(40, G.U - G.py), resist: CFG.resist, ready: coinReady };
    },
    debug: {
      press(kind, x, y) { const ok = kind === 'ring' ? (aimStart(x, y), true) : slingStart(x, y); if (ok) { lastXY = [x, y]; emit(kind === 'ring' ? 'ring' : 'sling', 'down', x, y); } return ok; },
      move(x, y) { move({ clientX: x, clientY: y }); },
      release: () => up(),
    },
  };
}

```

### assets/js/arena-runtime.js

```javascript
// AI aréna ve hře: spustí bota jedné AI (kód z arény) v pískovišti (Web Worker) a jeho akce provede
// stejným ovládáním jako hráč: prak, děla, oprava. Režim benchmark odehraje pevné hry a uloží výsledek.
//
// Akce bota (API LHC-BOT-1):
//   { type: 'sling', x, z }   výstřel náboje z praku tak, aby dopadl na bod plochy (x, z)
//   { type: 'laser', x, z }   výstřel z děl na bod (x, z); děla bez náboje se nejdřív nabijí (stojí Quansistory)
//   { type: 'repair' }        oprava segmentu plochy pod prakem
//   { type: 'wait', t }       čekání t sekund herního času (0,2–3 s)
//   { type: 'pointer', path }  pointer mode: vlastní dráha ukazatele [[dt_ms, x, y], …] v souřadnicích okna 0…1;
//                              první bod = stisk (na praku nebo v kruhu), poslední = puštění. Arena ji přehraje
//                              beze změny a do záznamu (CBT) zapíše přesně zadané časy a body.
// Každá hra bota se zaznamená jako CBT (cbt.js); po benchmarku jdou záznamy stáhnout.
import { loadArena, saveArena, BENCH, API_VERSION, summarize } from './arena-store.js';
import { packJSON, download } from './cbt.js';
import { L } from './i18n.js';

export async function startArena(api, { slotId, bench = null, demo = false, mode = 'medium', host, toast }) {
  const arena = loadArena();
  // v ukázce na úvodní obrazovce: když vybraný bot nemá kód, hraje referenční bot
  let slot = arena.slots[slotId];
  if (demo && (!slot || (!slot.code && !slot.builtin))) slot = arena.slots.reference;
  const tell = msg => { if (demo && parent !== window) parent.postMessage({ type: 'lhc-demo', ...msg }, location.origin); };
  if (!slot) { toast?.(L('Neznámý bot v aréně', 'Unknown bot in the arena'), '#FFB86B'); return; }
  let code = slot.code;
  slotId = slot.id;
  if (slot.builtin && !code) code = await (await fetch(new URL('../arena/reference-bot.js', import.meta.url))).text();
  if (!code) { toast?.(L(`${slot.name}: zatím nemá vložený kód bota`, `${slot.name}: no bot code pasted yet`), '#FFB86B'); return; }

  /* ---------- panel ---------- */
  const panel = document.createElement('div');
  panel.className = 'arena-panel';
  panel.style.setProperty('--ac', slot.color);
  panel.innerHTML = `
    <div class="ar-head"><i></i><b>${esc(slot.name)}</b><span class="ar-mode">${bench ? 'BENCHMARK' : L('UKÁZKA', 'DEMO')} · ${mode.toUpperCase()}</span></div>
    <div class="ar-line ar-game">–</div>
    <div class="ar-line ar-act">${L('načítám bota…', 'loading bot…')}</div>
    <div class="ar-bar"><span></span></div>
    <div class="ar-btns"><a class="ar-back" href="../arena/">${L('Zpět do arény', 'Back to arena')}</a><button type="button" class="ar-stop">${L('Zastavit', 'Stop')}</button></div>
    <div class="ar-result" hidden></div>`;
  host.appendChild(panel);
  if (demo) panel.hidden = true;
  ['pointerdown', 'click', 'keydown'].forEach(t => panel.addEventListener(t, e => e.stopPropagation()));
  const $ = s => panel.querySelector(s);

  /* ---------- pískoviště ---------- */
  const worker = new Worker(new URL('./arena-worker.js', import.meta.url));
  let reqId = 0;
  const pending = new Map();
  worker.onmessage = e => { const m = e.data, p = pending.get(m.id ?? 'load'); if (p) { pending.delete(m.id ?? 'load'); p(m); } };
  worker.onerror = e => { e.preventDefault(); for (const [k, p] of pending) { pending.delete(k); p({ type: 'error', message: e.message || L('chyba ve workeru', 'worker error') }); } };
  const ask = (msg, timeout) => new Promise(res => {
    const id = msg.type === 'load' ? 'load' : ++reqId;
    pending.set(id, res);
    worker.postMessage({ ...msg, id });
    setTimeout(() => { if (pending.has(id)) { pending.delete(id); res({ type: 'timeout' }); } }, timeout);
  });
  const loaded = await ask({ type: 'load', code }, 5000);
  if (loaded.type !== 'loaded') { tell({ state: 'error' }); $('.ar-act').textContent = L('Kód bota nejde spustit: ', 'Bot code cannot run: ') + (loaded.message || loaded.type); panel.classList.add('err'); return; }

  /* ---------- čekání v herním čase (výsledek nezávisí na rychlosti počítače) ---------- */
  const frame = () => new Promise(r => requestAnimationFrame(r));
  async function waitGame(sec) {
    const end = api.time + sec, guard = performance.now() + 20000;
    while (api.time < end && !api.over && running && performance.now() < guard) await frame();
  }
  // ovládání modulu se záznamem (CBT): API akce se zapisují v herním čase, pointer mode s časy, které zadal bot
  const kindNow = () => (api.launcher.mode === 'aim' ? 'ring' : 'sling');
  const gameMs = () => api.time * 1000;
  function press(kind, x, y, t = gameMs()) { const ok = api.launcher.debug.press(kind, x, y); if (ok) api.rec.input(kind === 'ring' ? 'ring' : 'sling', 'down', x, y, t); return ok; }
  function moveTo(x, y, t = gameMs()) { if (api.launcher.mode === 'idle') return false; api.launcher.debug.move(x, y); api.rec.input(kindNow(), 'move', x, y, t); return true; }
  function release(x, y, t = gameMs()) { if (api.launcher.mode === 'idle') return; const k = kindNow(); api.launcher.debug.release(); api.rec.input(k, 'up', x, y, t); }
  async function drag(x0, y0, x1, y1, steps, sec) {
    for (let i = 1; i <= steps; i++) { const k = i / steps; moveTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k); await waitGame(sec / steps); }
  }
  // pointer mode: dráha ukazatele od bota
  async function pointer(path, s) {
    if (!Array.isArray(path) || path.length < 2 || path.length > 600) return 'invalid';
    const pts = [];
    let total = 0;
    for (const q of path) {
      if (!Array.isArray(q) || q.length < 3) return 'invalid';
      const dt = num(q[0]), x = num(q[1]), y = num(q[2]);
      if (dt == null || x == null || y == null || dt < 0 || dt > 2000 || x < -0.2 || x > 1.2 || y < -0.2 || y > 1.2) return 'invalid';
      total += dt; pts.push([dt, x, y]);
    }
    if (total > 8000) return 'invalid';
    const ui = api.ui(), [x0, y0] = api.fromUnit(pts[0][1], pts[0][2]);
    const [kx, ky] = api.fromUnit(ui.cradle[0], ui.cradle[1]), [rx, ry] = api.fromUnit(ui.ring.x, ui.ring.y);
    const kind = Math.hypot(x0 - kx, y0 - ky) <= ui.grabPx ? 'cradle' : Math.hypot(x0 - rx, y0 - ry) <= ui.ring.rPx ? 'ring' : null;
    if (!kind) return 'miss';
    if (kind === 'cradle' && (!s.sling.ready || !s.sling.enabled)) return 'sling-busy';
    await waitGame(pts[0][0] / 1000);
    const t0 = gameMs();
    let t = t0;
    if (!press(kind, x0, y0, t)) return kind === 'cradle' ? 'sling-busy' : 'failed';
    let x = x0, y = y0;
    for (let i = 1; i < pts.length; i++) {
      const [dt, ux, uy] = pts[i];
      await waitGame(dt / 1000);                 // provedení po snímcích, záznam s přesným časem od bota
      t += dt;
      [x, y] = api.fromUnit(ux, uy);
      if (api.over || !moveTo(x, y, t)) break;
    }
    release(x, y, t);
    return 'ok';
  }

  /* ---------- provedení akcí ---------- */
  const num = v => (typeof v === 'number' && Number.isFinite(v) ? v : null);
  async function exec(a) {
    const s = api.botState();
    if (!a || typeof a !== 'object') return 'invalid';
    if (a.type === 'wait') { await waitGame(Math.min(3, Math.max(0.2, num(a.t) ?? 0.5))); return 'ok'; }
    if (a.type === 'pointer') return pointer(a.path, s);
    if (a.type === 'repair') { api.rec.key(api.rec.EV.REPAIR, 0, gameMs()); return api.repair() ? 'ok' : 'failed'; }
    const x = num(a.x), z = num(a.z);
    if (x == null || z == null) return 'invalid';
    if (a.type === 'sling') {
      if (!s.sling.ready || !s.sling.enabled) return 'sling-busy';
      if (s.tokens < s.ammo[0]) return 'no-tokens';
      const plan = api.planSling(x, z);
      if (!plan) return 'unreachable';
      const g = plan.geom;
      if (!press('cradle', g.x, g.y)) return 'sling-busy';
      await drag(g.x, g.y, g.x + plan.dx, g.y + plan.dy, 6, 0.5);
      const fix = api.planSling(x, z);          // plocha se mezitím pootočila: dorovnat těsně před puštěním
      if (!fix) { moveTo(g.x, g.y); release(g.x, g.y); return 'unreachable'; }
      moveTo(g.x + fix.dx, g.y + fix.dy);
      release(g.x + fix.dx, g.y + fix.dy);
      return 'ok';
    }
    if (a.type === 'laser') {
      if (s.charge > 0) { api.aimWorld(x, z); await waitGame(0.1); api.rec.key(api.rec.EV.KEY_FIRE, 0, gameMs()); return api.fire() ? 'ok' : 'failed'; }
      if (s.tokens < s.convertCost) return 'no-tokens';
      if (!s.sling.ready || !s.sling.enabled) return 'sling-busy';
      // nabití: tah praku přes azurovou čáru (náboj se změní na fotony), pak zamířit a vystřelit
      const g = api.launcher.slingGeom();
      if (!press('cradle', g.x, g.y)) return 'sling-busy';
      const yConv = g.U + (g.L - g.U) / Math.max(0.1, g.resist) + 14;
      await drag(g.x, g.y, g.x, yConv, 8, 0.6);
      api.aimWorld(x, z); await waitGame(0.1);
      const c = api.botState().charge;
      release(g.x, yConv);
      await waitGame(0.1);
      if (api.botState().charge >= c && c > 0) { api.rec.key(api.rec.EV.KEY_FIRE, 0, gameMs()); api.fire(); }
      return 'ok';
    }
    return 'invalid';
  }

  /* ---------- hlavní smyčka ---------- */
  let running = true, stats = null, games = [], gameIdx = 0, last = null, errors = 0, lastMsg = '';
  const transcripts = [];
  api.rec.setBot(slot.name);
  const seeds = bench ? BENCH.seeds : [null];
  api.sim.speed = bench ? BENCH.speed : demo ? 1.5 : 1;
  tell({ state: 'playing', name: slot.name, color: slot.color, id: slot.id });

  async function startGame() {
    const sd = seeds[gameIdx] ?? null;
    api.newGame(sd ?? undefined);
    stats = { seed: sd, actions: {}, errors: 0, invalid: 0 };
    last = null;
    const r = await ask({ type: 'newGame', config: { api: API_VERSION, bench: bench ? BENCH.id : null, mode, seed: sd, maxTime: BENCH.maxTime } }, 2000);
    if (r.type === 'error') { stats.errors++; lastMsg = 'init: ' + r.message; }
  }
  async function finishGame(reason) {
    const o = api.botState();
    const g = { seed: stats.seed, score: o.score, level: o.level, sunk: o.sunk, merges: o.merges, time: Math.round(o.t),
      reason, errors: stats.errors, invalid: stats.invalid, actions: stats.actions, input: stats.actions.pointer ? 'pointer' : 'api' };
    games.push(g);
    try {
      const t = await api.rec.finish();
      if (t && bench) { t.meta = { ...t.meta, input: g.input, actions: { ...stats.actions }, bench: bench ? BENCH.id : null }; g.cbt = t.digest; transcripts.push(t); }
    } catch (e) { }
  }
  async function run() {
    await startGame();
    while (running) {
      const o = api.botState();
      const timeUp = bench && o.t >= BENCH.maxTime;
      if (api.over || timeUp) {
        await finishGame(timeUp && !api.over ? L('čas vypršel', 'time is up') : (api.observe().endReason || L('konec', 'end')));
        tell({ state: 'gameover', score: games[games.length - 1].score });
        gameIdx++;
        if (bench && gameIdx >= seeds.length) { done(); return; }
        if (!bench) gameIdx = 0;
        await new Promise(r => setTimeout(r, 1200));
        await startGame();
        continue;
      }
      const state = { ...o, last };
      const r = await ask({ type: 'decide', state }, BENCH.decideTimeout);
      let action = null;
      if (r.type === 'action') action = r.action;
      else { stats.errors++; errors++; lastMsg = r.type === 'timeout' ? L('decide: vypršel čas', 'decide: timed out') : 'decide: ' + r.message; }
      const kind = action?.type || 'wait';
      const result = r.type === 'action' ? await exec(action) : (await waitGame(0.5), 'error');
      if (result === 'invalid') stats.invalid++;
      stats.actions[kind] = (stats.actions[kind] || 0) + 1;
      last = { action, result };
      $('.ar-act').textContent = `${describe(action)} → ${result}${lastMsg ? ' · ' + lastMsg : ''}`;
      lastMsg = '';
      await waitGame(BENCH.think);
      await frame();
    }
  }
  function describe(a) {
    if (!a) return L('čeká', 'waits');
    if (a.type === 'sling' || a.type === 'laser') return `${a.type === 'sling' ? L('prak', 'sling') : L('děla', 'cannons')} (${(+a.x).toFixed(1)}, ${(+a.z).toFixed(1)})`;
    if (a.type === 'pointer') return L(`ukazatel (${Array.isArray(a.path) ? a.path.length : 0} bodů)`, `pointer (${Array.isArray(a.path) ? a.path.length : 0} points)`);
    return a.type === 'repair' ? L('oprava', 'repair') : a.type === 'wait' ? L('čeká', 'waits') : String(a.type);
  }
  // průběh
  const ui = setInterval(() => {
    if (!running) return;
    const o = api.botState();
    $('.ar-game').textContent = `${bench ? `${L('hra', 'game')} ${Math.min(gameIdx + 1, seeds.length)}/${seeds.length} · ` : ''}${Math.round(o.t)} s · ${L('skóre', 'score')} ${o.score} · ${L('úroveň', 'level')} ${o.level} · ${Math.floor(o.tokens)} Q`;
    const k = bench ? (gameIdx + Math.min(1, o.t / BENCH.maxTime)) / seeds.length : 0;
    $('.ar-bar span').style.width = `${(k * 100).toFixed(1)}%`;
  }, 300);

  function done() {
    running = false; clearInterval(ui); worker.terminate(); api.sim.speed = 1; api.rec.setBot(null);
    const st = loadArena();
    const sl = st.slots[slotId];
    sl.results = sl.results || {};
    sl.results[mode] = { bench: BENCH.id, at: new Date().toISOString(), codeHash: hash(code), games };
    saveArena(st);
    const sm = summarize(sl.results[mode]);
    $('.ar-act').textContent = L('Benchmark dokončen.', 'Benchmark complete.');
    $('.ar-bar span').style.width = '100%';
    const res = $('.ar-result');
    res.hidden = false;
    res.innerHTML = L(`<b>Ø skóre ${sm.score.toFixed(0)}</b> · Ø úroveň ${sm.level.toFixed(2)} · Ø délka ${sm.time.toFixed(0)} s`, `<b>Ø score ${sm.score.toFixed(0)}</b> · Ø level ${sm.level.toFixed(2)} · Ø length ${sm.time.toFixed(0)} s`) +
      `<ul>${games.map((g, i) => L(`<li>hra ${i + 1}: ${g.score} b · úr. ${g.level} · ${g.time} s · ${esc(g.reason)}${g.errors ? ` · chyb ${g.errors}` : ''}</li>`,
        `<li>game ${i + 1}: ${g.score} pts · lvl ${g.level} · ${g.time} s · ${esc(g.reason)}${g.errors ? ` · errors ${g.errors}` : ''}</li>`)).join('')}</ul>`;
    $('.ar-stop').hidden = true;
    if (transcripts.length) {
      const b = document.createElement('button');
      b.type = 'button'; b.className = 'ar-dl';
      b.textContent = L(`Stáhnout záznamy CBT (${transcripts.length})`, `Download CBT transcripts (${transcripts.length})`);
      b.onclick = () => download(`lhc-cbt-${slot.id}-${mode}-${BENCH.id}.json`, packJSON(transcripts));
      $('.ar-btns').appendChild(b);
    }
    toast?.(L(`${slot.name}: benchmark hotový, Ø skóre ${sm.score.toFixed(0)}`, `${slot.name}: benchmark done, Ø score ${sm.score.toFixed(0)}`), slot.color);
  }
  $('.ar-stop').onclick = () => {
    running = false; clearInterval(ui); worker.terminate(); api.sim.speed = 1; api.rec.setBot(null);
    $('.ar-act').textContent = bench ? L('Benchmark přerušen, výsledek se neuložil.', 'Benchmark interrupted, the result was not saved.') : L('Bot zastaven.', 'Bot stopped.');
    $('.ar-stop').hidden = true;
  };
  run();
  return { stop: () => $('.ar-stop').click() };
}

function esc(s) { return String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]); }
function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(16); }

```

### assets/js/arena-worker.js

```javascript
// Pískoviště pro kód bota (Web Worker): bot nevidí stránku, DOM ani hru, dostává jen stav jako JSON
// a vrací akci. Hra mu nic jiného nedovolí.
let botDecide = null, botInit = null, memory = {};

function load(code) {
  code = String(code).replace(/^\s*export\s+(default\s+)?/gm, '');   // ES modul → obyčejný skript
  // kód bota definuje function decide(state, memory) a volitelně function init(config) a const BOT = {...}
  const f = new Function(`"use strict";\n${code}\n;return {
    decide: typeof decide === 'function' ? decide : undefined,
    init: typeof init === 'function' ? init : undefined,
    BOT: typeof BOT === 'object' && BOT ? BOT : null };`);
  const r = f();
  if (!r.decide) throw new Error('Kód nedefinuje funkci decide(state, memory).');
  botDecide = r.decide; botInit = r.init;
  return r.BOT;
}

self.onmessage = e => {
  const m = e.data;
  try {
    if (m.type === 'load') {
      const meta = load(m.code);
      self.postMessage({ type: 'loaded', meta: meta ? JSON.parse(JSON.stringify(meta)) : null });
    } else if (m.type === 'newGame') {
      memory = {};
      if (botInit) botInit(m.config, memory);
      self.postMessage({ type: 'ready', id: m.id });
    } else if (m.type === 'decide') {
      const a = botDecide(m.state, memory);
      self.postMessage({ type: 'action', id: m.id, action: a == null ? null : JSON.parse(JSON.stringify(a)) });
    }
  } catch (err) {
    self.postMessage({ type: 'error', id: m.id, message: String(err && err.message || err) });
  }
};

```

### assets/arena/reference-bot.js

```javascript
// LHC referenční bot (API LHC-BOT-1). Stejná strategie jako vestavěný bot ve hře:
// každé rozhodnutí ohodnotí kandidátní tahy a vybere nejlepší. Slouží jako měřítko v aréně.
const BOT = { name: 'LHC referenční bot', author: 'LHC.institute', model: 'ruční heuristika', version: '1.0' };

const RECIPES = [[2, 2, 4], [4, 4, 8], [6, 6, 12], [8, 8, 16], [8, 12, 20], [12, 12, 24], [16, 16, 32], [20, 12, 32], [24, 8, 32], [32, 32, 64], [64, 64, 128], [128, 128, 256]];
function merge(a, b) { for (const [x, y, r] of RECIPES) if ((x === a && y === b) || (x === b && y === a)) return r; return null; }
function rand(mem) { mem.seed = ((mem.seed || 12345) * 48271) % 2147483647; return (mem.seed - 1) / 2147483646; }
function reach(s, x, z) {
  const dx = x - s.sling.x, dz = z - s.sling.z, d = Math.hypot(dx, dz);
  if (d < 0.6 || d > s.sling.maxDist * 0.98) return false;
  return (dx * s.view.fwdX + dz * s.view.fwdZ) / d > 0.05;      // ne za prak
}

function init(config, mem) { mem.seed = (config.seed || 7) * 31 + 1; }

function decide(s, mem) {
  const cand = [], v = s.ammo[0];
  const pcs = s.pieces.filter(p => !p.flying && !p.inRift);
  const canSling = s.sling.ready && s.sling.enabled;
  const canLaser = s.charge > 0 || (s.tokens >= s.convertCost && canSling);
  const cos = d => Math.cos(d * Math.PI / 180);

  // 1) prak na partnera ke spojení
  if (canSling && s.tokens >= v) for (const p of pcs) {
    if (!p.slingReach) continue;
    const r = merge(v, p.value);
    if (!r) continue;
    const d = Math.hypot(p.x - s.sling.x, p.z - s.sling.z) / s.sling.maxDist;
    cand.push({ s: 10 + r * 2 + (r >= 32 ? 40 : 0) + (r === s.target ? 60 : 0) - v * 0.5 - d * 3, a: { type: 'sling', x: p.x, z: p.z } });
  }
  // 2) děla: cílová koule do studny, nebo těleso k partnerovi (děla tlačí ve směru pohledu)
  if (canLaser) for (const p of pcs) {
    const dx = p.x - s.view.camX, dz = p.z - s.view.camZ, l = Math.hypot(dx, dz) || 1, fx = dx / l, fz = dz / l;
    if (p.value >= 32 && p.r > s.funnel.lip && p.r < 15) {
      const al = (fx * -p.x + fz * -p.z) / (p.r || 1);
      if (al > cos(18)) cand.push({ s: (p.value === s.target ? 120 : 50) * al, a: { type: 'laser', x: p.x, z: p.z } });
    }
    for (const q of pcs) {
      if (q === p) continue;
      const rr = merge(p.value, q.value);
      if (!rr) continue;
      const ex = q.x - p.x, ez = q.z - p.z, d = Math.hypot(ex, ez);
      if (d > 10 || d < 0.3) continue;
      const al = (fx * ex + fz * ez) / d;
      if (al > cos(20)) cand.push({ s: rr * 1.5 * al, a: { type: 'laser', x: p.x, z: p.z } });
    }
  }
  // 3) oprava trhlin pod prakem
  if (s.repairAvailable && s.tokens >= s.repairCost + 12) cand.push({ s: 12 + s.destroyedPct, a: { type: 'repair' } });
  // 4) založit nové těleso na volném místě
  if (canSling && s.tokens >= v + 10 && s.pieces.length < s.maxPieces - 8 && !cand.some(c => c.a.type === 'sling')) {
    for (let i = 0; i < 8; i++) {
      const ang = rand(mem) * Math.PI * 2, rr = 7 + rand(mem) * 8, x = Math.cos(ang) * rr, z = Math.sin(ang) * rr;
      if (reach(s, x, z)) { cand.push({ s: 3, a: { type: 'sling', x, z } }); break; }
    }
  }
  cand.push({ s: 1, a: { type: 'wait', t: 0.4 } });
  cand.sort((a, b) => b.s - a.s);
  return cand[0].a;
}

```
