3586555aea3959817ce13c462936e3e5a9606618
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3586555aea |
Remove non-redistributable art from the repo; record licence terms
Researched the licence at each of the four itch.io sources. Two of the four
forbid redistribution, and publishing a repository containing them is exactly
that, so they are out of the tree.
0x72 DungeonTileset II CC0 1.0 commercial ok, no credit required
Helton Yan Pixel Combat CC BY 4.0 commercial ok, CREDIT REQUIRED
Fire Pixel Bullet 16x16 custom non-commercial, NO REDISTRIBUTION
750+ Effect and FX Pixel custom non-commercial, NO REDISTRIBUTION
Both bdragon1727 packs state verbatim: "You cannot do: Resell / redistribute
this asset." The FX pack's paid tier is no better for this purpose -- "You can
NOT re-distribute the file, no matter how much you modify it" -- so paying does
not unlock it and neither does editing the art.
bullets.png and impact.png therefore move to assets/local/, gitignored.
Art.bullets_texture() / impact_texture() load them at runtime and return null
when absent; the bullet renderer falls back to the generated dot it used before,
tinted per kind so bullet types stay distinguishable. Nothing there may be
preload()ed -- preload resolves at compile time and would fail the build on
every machine without the files, which is every clone.
Verified both ways rather than assumed: with the files present the game uses
them and no fallback triggers; with them moved aside, 156/156 tests pass and the
game runs clean on the generated dots.
Added CREDITS.md, because Helton Yan's CC BY 4.0 makes attribution a licence
obligation rather than a courtesy. A file in the source tree does not discharge
it once the game ships -- an in-game credits screen is now a tracked todo, and
flagged as a licence requirement rather than polish.
NOT addressed here, and it needs a decision: these files remain in git history
at
|
||
|
|
9fa2e260f2 |
Replace placeholder rendering with real sprites and audio
Terrain, actors, boss and bullets are sprites now, and four sounds play off server events. 548KB total across seven files, out of 3.9GB of source packs. - src/view/art.gd holds every atlas rect and sound path in one table, copied from the 0x72 pack's own tile_list. Scattering coordinates through draw calls would make re-cutting the atlas a hunt, and a wrong rect invisible. - Bullets get one MultiMesh per kind rather than one overall: a MultiMeshInstance2D carries a single texture and each kind needs a different region of the sheet. Four draw calls, no custom shader passing UVs through per-instance data. - src/view/sfx.gd is a 16-voice round-robin pool. Bullet-hell fire rates mean sounds overlap constantly, and identical sounds landing within two frames are collapsed so a ring hitting eight bullets is a bang rather than clipping. Every sound is triggered by a server event, never a local guess, so what you hear matches what happened. Audio was converted, not copied: the pack ships 24-bit/96kHz masters averaging 2MB. Downsampled to 16-bit/44.1kHz mono, silence-trimmed, and the shoot sound hard-capped to 0.30s -- it came out at 2.03s, against a fire cooldown of 0.12s, which would have smeared held fire into noise. tests/unit/test_art.gd checks what cannot be eyeballed here: every atlas rect lands inside its texture, every animation frame of a strip fits (the last frame is what runs off the sheet, not the first), there is a sprite per bullet kind and per enemy visual, and no sound is long enough to stack badly. A wrong atlas coordinate does not error -- it silently draws the wrong pixels. Licence sources recorded in docs/ASSETS.md now that they are known. All four are free versions and four rows still say "confirm at source": free itch packs vary on credit and commercial use, and there is no credits screen yet. 155 tests (was 146). check.sh, test.sh and smoke.sh pass. |
||
|
|
7ee6c8e761 |
Close Stage 1: per-peer actor interest, boss room confinement
ci / verify (push) Successful in 46s
The gap Stage 1 left open: snapshots were encoded once and broadcast to every peer in an instance, so clients were handed enemies the fog then hid. Fog is a rendering rule -- a modified client draws whatever it holds -- so that was no defence at all. Snapshots are now encoded per peer, and actors beyond ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over the snapshot interval. Two asymmetries worth keeping: - The observer's own player record is never filtered, however far out the arithmetic puts it. The client reconciles its prediction against that record, so dropping it breaks the player's own movement rather than hiding someone. - Only bullet SPAWNS are filtered, never despawns. A client told about a bullet must always be told it died, or it keeps a phantom. Bullet spawns use a much wider radius (2200u) than actors, deliberately. An enemy appearing at the edge of sight is cosmetic; a bullet withheld at spawn that later flies into view is invisible damage. The floor is longest bullet travel + fog radius -- 1500 for the Warden's Collapse snipe -- and test_interest.gd recomputes that from live content, so adding a faster or longer-lived bullet fails a test instead of producing bullets that wink into existence. Also clamped bosses to their room. A no-op today since every boss is stationary, which is exactly when the invariant is cheap to establish: boss rooms have no door that locks, so the only thing keeping a fight in the boss room is the boss, and Stage 5's movement work would otherwise break it quietly. 146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no partials left; docs/ROADMAP.md now records the three interest radii and the floor each must respect. |
||
|
|
20dd6bc502 |
Keep asset packs out of git and out of Godot's importer
ci / verify (push) Successful in 46s
The packs total 3.9GB, almost all of it Helton Yan's SFX shipped as 24-bit / 96kHz studio masters -- ~2MB for a single gunshot. Two guards, because they solve different problems: - .gitignore excludes the contents. The directory and its guard files stay tracked, so a fresh clone has the .gdignore in place before anyone drops packs back in. - assetpacks/.gdignore stops Godot descending into the tree at all. This is the urgent half: tools/check.sh runs --import, which walks all of res://, so the next check would have imported several thousand PNGs and 2,100 of those WAVs, and every run after it. Verified by controlled test rather than assumed -- an unguarded PNG under res:// imports and gets a .import file beside it; with .gdignore neither happens. check.sh stays at 3.3s and .godot/imported at 1.2MB. The trade the .gdignore buys is that nothing in assetpacks/ can be referenced from a scene or script. That is the intended shape: raw source on one side, game-ready files copied into res://assets/ on the other, so adding a huge pack costs the repo and the import step nothing. docs/ASSETS.md records attribution, because gitignoring the packs also gitignores their licence files. Worth flagging: four of the six packs shipped with no licence text at all, and two of the undecided ones are demo versions, which are often more restricted than the paid packs. Only RF Catacombs has explicit terms (public domain, no resale). Those rows need a source URL before anything ships. Also removed __MACOSX and .DS_Store extraction artefacts, and left considering_dont_use_yet/ untouched as the user's own "not chosen" marker. |
||
|
|
de48afcbd9 |
Fix aiming under a scrolling camera; add status and decisions docs
ci / verify (push) Successful in 46s
The aim bug was collateral from the camera work. "Mouse relative to the centre of the screen" WAS the cursor's world position while the world was drawn fixed at the origin, so subtracting the player position gave the right vector. Once the camera scrolled, that expression became the aim vector itself, and subtracting the player position again made the ship aim at a fixed world location -- walking around swung the crosshair with the mouse held still. Fixed by inverting the transform the view actually draws with (world = screen - world_view.position, published by the game scene each frame) rather than assuming the player is centred, so it still holds if the camera later clamps at map edges or gets shake or look-ahead. tests/unit/test_aim.gd pins it, including the regression directly: moving the player must not move the crosshair. Documentation, for other sessions picking this up cold: - docs/ROADMAP.md rewritten as the status map -- every feature in the brief against its state and the file implementing it, the known gaps called out (actor interest management is the notable one), and the ten design questions that are genuinely unspecified and should not be guessed at. - docs/DECISIONS.md, new: settled decisions with their reasoning, so a session does not re-litigate or re-ask. Several are not the obvious default -- no i-frames, no contact damage, non-interruptible escape, and never sending the map seed. - CLAUDE.md and README point at both. 137 tests; check.sh, test.sh and smoke.sh pass. |
||
|
|
7af439341d |
Stage 1: tile maps, walls, fog of war, aggro, scrolling camera
ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom. |
||
|
|
e1f9fa8096 |
Unlock menu after failed join; remove contact damage; cover clean disconnects
ci / verify (push) Successful in 45s
A failed connection left the connect buttons disabled forever. _show_menu() returned early when a menu already existed, so the set_busy(false) that re-enables them never ran on the way back from _abort_connect -- the player was locked out of both joining and hosting with no way out but a restart. Also guards against a second attempt stacking on an in-flight one. Contact damage is gone as a concept: nothing in this game hurts you by touching it, because every threat should be a bullet you can see and dodge. The Stalker walked at you and dealt contact damage and nothing else, so it now carries a point-blank shotgun instead -- five pellets, 62 degrees, 18-tick lifetime for about 78px of reach, so it still has to close and leaves nothing lingering. tests/unit/test_content.gd asserts every hostile enemy has an emitter, so a new enemy cannot quietly reintroduce the mechanic. The menu's "Disconnect" DOES get the anti-cheese protection -- verified, not assumed. It calls Net.shutdown(), the socket closes, and the server takes the same linkdead path as a SIGKILL, because the channel is keyed on the socket closing rather than on how it closed. Made permanent: a --leave-after bot flag and two smoke assertions covering the polite exit alongside the hard kill, so a future "clean leave" message that skipped the channel would fail a test. The menu now says so out loud in a dungeon -- the mechanic only reads as fair if the cost is known before clicking. 103 tests, 12 smoke assertions; check.sh, test.sh and smoke.sh all pass. |
||
|
|
f70de1b825 |
Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s
The real cause of the ship/bullet separation, which the previous commit only half-addressed. The server dropped inputs past a lead of 12 while the client only re-synced past 16, so a client whose lead drifted into 13-16 had every input silently rejected while believing its timing was fine. The server coasted on held_input and then stopped; the client kept predicting. The two separated permanently and the reconciler fought it every snapshot -- "shoved around". It needed two independent clocks to drift, hence "only after some time", and nothing in the loop could notice, hence "then persists". The listen-server diagnostic could never reproduce it: one process, one physics tick, lead constant by construction. Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a test; and an ack-stall detector re-syncs when last_input_tick stops advancing, which catches the whole class regardless of cause -- lead alone cannot, because a wrong lead looks normal from the client. diag_prediction.gd now injects a +14 tick drift and exits non-zero unless the gap recovers. Also: - No invulnerability frames. Every bullet that touches a player lands; i-frames made dense patterns safer than sparse ones, which inverts the genre. Measured: a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s drifting. spawn_grace remains the only invulnerable state. - Death is exited with a HUD button, disabled for the first 3s. The lockout is enforced in SimWorld, not just by graying the button -- a client that ignores its own UI still waits. The interact key no longer respawns. - Joining a server that is not there no longer drops the player into an empty lobby they cannot act in. Net.join() only creates an ENet object; the game scene now waits for the server to actually place us in an instance, with an 8s timeout, and headless runs exit non-zero instead of idling. Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass. |
||
|
|
005679f1b5 |
Fix bullet/ship desync; rework death, escape, arrival and hub awareness
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
- ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
not consumed until frame N+1, leaving the drawn ship a constant one tick
(4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
ClientRuntime now sets process_physics_priority = -10. Gap on a listen
server: 4.00px -> 0.10px mean, 0.30px worst.
- PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
ship, so bullets were born inside the sprite. Regression from the previous
commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.
(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.
(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.
(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.
(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.
(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.
(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.
Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
|
||
|
|
d9a59fff03 |
Fix lobby/HUD UI pinned at (0,0); shrink player hitbox below visual size
The lobby connect menu and three pieces of the HUD (hit-flash overlay, boss bar centering, death-message centering, hint label) were all silently broken by the same Godot gotcha: set_anchors_preset(preset) with the default keep_offsets=false does NOT zero the offsets to the preset's margins -- it recomputes them to preserve the control's *current* rect, which for a freshly constructed Control is (0,0). Anchors end up correct; the actual rect stays pinned to the top-left corner regardless. Fixed by switching to set_anchors_and_offsets_preset() everywhere a Control is built in code, and by using offset_left/offset_top (anchor-relative) instead of .position (absolute) for the HUD hint label. Documented as gotcha #5 in CLAUDE.md. Also split PLAYER_RADIUS into two constants: PLAYER_RADIUS (6.0, the authoritative hitbox used by SimWorld) and PLAYER_VISUAL_RADIUS (13.0, view-only, used by world_view.gd). The client renders every ship a little late relative to the server -- interpolation delay, reconciliation smoothing -- so a hitbox that matched the sprite would let bullets connect against a ship the player watched dodge clear of them. A smaller hitbox means the occasional bullet visibly clips the sprite without a hit, which reads as more forgiving of latency than the reverse. Verified headlessly: a throwaway scene instantiating MainMenu/HUD under a real 1280x720 viewport, asserting the panel is centered and _canvas.size / hint position resolve correctly, before and after each fix. check.sh, test.sh (78/78) and smoke.sh all pass. |
||
|
|
c4beeae38f |
Initial commit: Transcience MVP
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated server with client-side prediction. Clients send input only; the server resolves every hit for both players and enemies (no PvP). - SimWorld: whole simulation as plain RefCounted objects (no nodes, no physics server), ~0.24ms/tick at peak load -- runs headless for free and drives 78 tests in under a second - BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn events rather than per-tick state - Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash enemies and bosses -- a new boss is data in src/content/content.gd, no simulation changes - The Warden of the Fold: stationary 4-phase boss built entirely on that format - Lobby hub with a portal into on-demand dungeon instances; one process hosts the hub plus every concurrent dungeon - Emergency escape: 3s server-owned channel, cancelled by damage - tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over ENet), bench.gd; git hooks wired to the same scripts - docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md |