Accedi
IT
games/open-lemmings / AGENTS.md · commit 04e2e2a
# open-lemmings ("Lumikins") — notes for agentsThis game is built with JumPHP. **Read `../../AGENTS.md` first** (rules, commands, API).## What it is**Lumikins**: an original crowd-guiding puzzle game. Little lantern creatures drop from a hatch and walk straightahead; the player picks one of 8 skills in the bar and clicks a Lumikin to give it (climb, glide, burst, block,build, bash, mine, dig) so that enough of them reach the exit. 24 levels in 11 worlds (forest, cave, forge, night, desert, ice, jungle, sky, autumn, reef, clockwork; one tune each: play..play11), destructible terrain, mouse ortouch only (keys are shortcuts). Its own name, creatures, levels, pictures and music: nothing is taken from anyexisting game.## Layout- `src/Sim/` — the rules, **pure PHP, no SDL** (30 ticks/s, deterministic): `Terrain` (one byte per pixel, change log),  `Lumi`, `World` (states, skills, events), `Levels` (shapes + a scripted `solution` per level), `LevelBuilder`,  `Autoplay` (plays a solution: demo, smoke test, proof that levels can be won).- `src/View/` — drawing: `TerrainView` (GD image -> 64x58 chunk textures, re-sent when dug), `WorldView` (parallax,  motes, pools, halos, particles, flashes, sounds from `World::$events`), `Particles`, `DemoRunner` (title background).- `src/Service/` — autowired: `Art` (textures), `Fonts` (TTF per language), `Audio`, `Progress` (var/progress.json),  `Effects` (bloom post shader through `$engine->gl()`, option `effects`; nothing without GL).- `src/Scene/` — `TitleScene` -> `LevelScene` -> `PlayScene` (+ overlays `PauseScene`, `OptionsScene`, `ResultScene`);  `MenuBase` = menus that work with keys, pad, mouse and finger.- View 480x270: the level is 480x232 (scrolls sideways), the bar is the 38 rows below (`PlayScene::buttons()`).## The 3D view (camera turned by 45 degrees)- Two arrows at the top right of the level (`PlayScene::viewButtons()`, actions `turn_left` / `turn_right`: `[` `]`, `,` `.`,  pad LT / RT) set `yawTarget` to -45, 0 or 45; `yaw` eases towards it. **At 0 the 2D path is untouched** (`WorldView::render`,  no GL layer). Any other angle (transitions included): `WorldView::render3d()` clears the 2D transparent, `World3d` draws the  level in the GL layer (`setLayer('below', 'view', null)`), the HUD stays 2D on top. Without GL (`gl()->isAvailable()` false:  headless tests, `JUMPHP_GL=0`) the arrows are greyed, let the clicks through and do nothing.- `Camera3d` (pure math, tested): level plane = z 0, 3D y = -level y, terrain from z -12 to +12. At yaw 0 it maps the plane exactly  like the 2D view (camX, camY, zoom); a yaw turns it around the vertical axis through the middle of the view and tilts it down  0.3 degree per degree. `unproject()` casts a view point on the plane z 0: `PlayScene::toLevel()` feeds `World::pick()` with it  (clicks and hover), `project()` places the 2D fuse counters.- `TerrainMesh` (pure): per TerrainView chunk (64x58), a front face with the chunk's picture (holes cut by its alpha) + tops,  bottoms, left / right sides from the mask outline, merged in runs, textured with the colour of the pixel at the edge, shaded  (tops light, right sides dark). `World3d` keeps one buffer + one texture per chunk; `TerrainView::$touched` (a change and its  neighbour chunks) says which to rebuild, `prepare($cam, $ms)` builds them within a time budget, those in view first. The first  turn waits until the chunks in view are built (no holes), nothing 3D is made before the first turn.- Pictures go to GL as GD's raw bytes (`imagegd()`: A R G B, decoded in the shaders, no PHP loop over pixels); HD pictures are  shrunk to 2x retro size with `imagescale()` (no mipmaps; `imagecopyresampled()` would blacken the clear pixels). GD reads no  JPEG in the runtimes: the 3D sky is `assets/hd/bg/<theme>-sky-gl.png` (2x retro, written by `tools/art_hd.py` `sky_gl()`).- No dark fringes on smoothed HD pictures: `tools/art_hd.py` `save()` spreads the edge colours into the fully clear pixels  (`bleed()`, alpha unchanged; the 2D HD view looks the same, its edges a little cleaner). The terrain shader divides the colour  by its alpha (the terrain picture's clear pixels are black).- Cards (Lumikins, hatch, exit, cursor, particles, motes, glows) face the camera at z 0, batched per picture into one dynamic buffer  each per frame (4 vertices + a shared index buffer per quad), sorted back to front; glows are pushed 3 px away from the camera so  they never cover their owner. A Lumikin hidden by the ground (`World3d::hidden()`: its body-to-eye rays sampled on the mask)  gets a glowing outline drawn through everything (outline mode of the card shader). Shadows: soft ovals on the ground's top.  HD haze: the 2D haze drawn flat over the 3D (`WorldView::render3d()`). Pools: boxes with a waving top.- Sky and parallax layers: `Backdrop` (pure, tested) puts each on a plane behind the level at the depth where perspective gives  the 2D scroll fraction (sky 0.04, far 0.15, near 0.4), turning with 0.75 of the camera's angle around the middle of the view,  cut to the view's corners (no hole at any angle), rows hung on the point the middle of the view sees; repeat sideways (sky:  mirrored both ways). At 0 degrees they are exactly the 2D layers.- Dragging in the turned view casts the grabbed point and the pointer on the level plane: the grabbed point stays under it.- Test params: `angle` (-45 / 45: starts turned; needs GL), `force3d` (tests: the 3D logic, meshes and clicks without GL),  `bloom` (true / false: sets the glow option, for shots: headless runs have a temporary var/).  Shots: `JUMPHP_SCENE='App\Scene\PlayScene' JUMPHP_SCENE_PARAMS='{"level":2,"auto":true,"angle":45}' bin/jumphp shot open-lemmings 300`  (GL on Xvfb: slow, one at a time).## Generated assets (never edit the outputs by hand)```bashpython3 tools/art.py      # every retro picture (numpy + Pillow)python3 tools/art_hd.py   # the HD set (assets/hd): sprites, gate, hatch drawn as shapes; painted skies; run after art.pypython3 tools/sound.py    # music (Ogg, ffmpeg) and sound effects, written as notes in the scriptpython3 tools/lang.py     # config/lang.yaml (14 languages) + project.yaml descriptionspython3 tools/fonts.py <Lato-Bold.ttf> <NotoSansSC.ttf> <NotoSansKR.ttf>   # subsets with the characters of lang.yamlbin/jumphp php games/open-lemmings tools/solve.php [level] [N]   # plays the solutions (text view every N ticks)```A new text: add it to `tools/lang.py` in the 14 languages, run lang.py then fonts.py (CJK subsets only hold thecharacters used).## Checks```bashbin/jumphp test open-lemmings                 # lint + phpstan + phpunit (rules + game tests) + smoke (level 3 plays itself)bin/jumphp shot open-lemmings 200             # titleJUMPHP_SCENE='App\Scene\PlayScene' JUMPHP_SCENE_PARAMS='{"level":8,"demo":true}' bin/jumphp shot open-lemmings 600```## Non-obvious- A level change (shapes, sizes in `World`) can break its `solution`: run `tools/solve.php`; the test  `testEveryLevelCanBeWonByItsSolution` guards it.- `LevelBuilder::roughen()` removes 0-2 px of the ground's top: positions in solutions are approximate on purpose.- Scenes read the mouse (`$engine->input->getMouse()`); phones use `touch: {pad: none}` (touches are clicks).  Tests click with `mouse->move()/down()/up()` around `run()`.- Headless runs use Xvfb + Mesa because the code calls `gl()`: slower shots; `JUMPHP_GL=0` skips the bloom and the 3D view.  PHPUnit tests run without GL even then (only the smoke test and shots get Xvfb): the 3D tests use `force3d`.