From de48afcbd93fe4076a73aa344cd64eb94c47c255 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 3 Sep 2026 20:58:03 +0200 Subject: [PATCH] Fix aiming under a scrolling camera; add status and decisions docs 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. --- CLAUDE.md | 6 ++ README.md | 2 + docs/DECISIONS.md | 128 +++++++++++++++++++++++++++ docs/ROADMAP.md | 176 ++++++++++++++++++++++++++----------- src/net/client_runtime.gd | 17 +++- src/view/game_scene.gd | 5 ++ tests/unit/test_aim.gd | 61 +++++++++++++ tests/unit/test_aim.gd.uid | 1 + 8 files changed, 342 insertions(+), 54 deletions(-) create mode 100644 docs/DECISIONS.md create mode 100644 tests/unit/test_aim.gd create mode 100644 tests/unit/test_aim.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index c8100dc..1903aab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,12 @@ Top-down twin-stick bullet-hell with a dedicated, server-authoritative backend. Godot 4.7, GDScript only. One executable is both server and client. +**Starting a new session?** [docs/ROADMAP.md](docs/ROADMAP.md) says what is +built, what is next, and which file implements each feature. +[docs/DECISIONS.md](docs/DECISIONS.md) records what the user has already decided +and why — read it before asking a design question, several answers there are not +the obvious default. + ## Commands ```bash diff --git a/README.md b/README.md index ef0b093..c27a05b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ will otherwise cost an afternoon. ## Docs +- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives +- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning - [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas - [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and why the setup looks like this - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — code map and the node-free simulation diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..c3029b7 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,128 @@ +# Settled decisions + +Design choices the user has already made, with the reasoning. **Check here +before asking** — re-litigating a settled decision wastes a round trip, and +several of these look like defaults you would otherwise pick differently. + +Ordered newest last. + +--- + +## Simulation shape + +**Game logic lives in plain `RefCounted` objects, not nodes.** No +`CharacterBody2D`, no `Area2D`, no physics server. Bullet counts make per-bullet +nodes unaffordable, the dedicated server allocates nothing, and the whole suite +runs with no `SceneTree`. Full reasoning in [ARCHITECTURE.md](ARCHITECTURE.md). + +**Content is GDScript, not `.tres`.** A boss is a readable diff, there are no +resource UIDs churning in version control, and a test can build content inline. +`tools/export_content.gd` writes `.tres` copies for inspector tuning, but code +is the source of truth — port inspector edits back. + +--- + +## Netcode + +**The client sends input and nothing else.** No message exists for position, +hits, damage, or a finished escape. Having no code path is strictly stronger +than validating one. + +**Bullets replicate as spawn events, not state.** ~36 bytes once per bullet; +both sides run the identical integration. Only early deaths (a hit, or a wall) +need announcing. Pinned by `tests/integration/test_replica_parity.gd`. + +**No lag compensation.** Rewinding the world to a shooter's view would mean a +player who dodged on their own screen still takes the hit. If it becomes a +complaint, lag-compensate *player bullets against enemies only* — never enemy +bullets against players. + +**`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's input +acceptance window has to be wider than the band in which the client re-syncs its +own numbering. When it was not, drifting clocks landed in a dead zone where the +server silently rejected every input and the client never noticed. Pinned by +`tests/unit/test_input_lead.gd`. + +--- + +## Combat feel + +**Hitbox is smaller than the sprite** (`PLAYER_RADIUS` 6 vs +`PLAYER_VISUAL_RADIUS` 13), and `PLAYER_MUZZLE_OFFSET` derives from the visual +radius. A visible near-miss reads as fair; an invisible hit does not. + +**No invulnerability frames.** Every bullet that touches you lands. I-frames +make dense patterns *safer* than sparse ones, which inverts the genre. Measured +cost: ~13.6s stationary in the Warden's opening phase. `spawn_grace` on entering +a dungeon is the sole exception, and it is a transition, not a combat mechanic. + +**No contact damage.** Every threat is a bullet you can see and dodge. The +Stalker carries a point-blank shotgun rather than damaging you by touch. +`tests/unit/test_content.gd` asserts every hostile enemy has an emitter. + +--- + +## Leaving a run + +**Escape is a 1-second channel that damage does not interrupt.** An +interruptible channel makes killing the process better than pressing the button. + +**A disconnect runs the same channel.** The player stays in the world as +`linkdead`, still killable. All four exits (key, menu button, clean disconnect, +SIGKILL) converge on one server-side path keyed on the socket closing — there is +deliberately no "clean leave" message. `tools/smoke.sh` asserts both the hard +kill and the polite disconnect. + +**Boss rooms do not lock.** *(User, this session.)* You can always walk out of a +boss fight, and the boss cannot follow. The consequence: fights cannot rely on +trapping the player, and disengaging is always available. + +--- + +## World + +**Tile grid, generated layout, hand-authored boss arenas.** *(User, this +session.)* A grid because collision, line of sight and interest management all +become array lookups; authored arenas because a generated boss room is a bad one +about as often as a good one. + +**Hard fog.** *(User, this session.)* No remembered terrain — anything outside +current line of sight is not drawn, including ground already walked over. + +**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev +flag; what raises depth in actual play is still open. + +**Never send the map, or its seed.** *(User, this session — corrected an earlier +choice of mine.)* Sending `(seed, depth)` and regenerating client-side is far +cheaper on the wire and hands any modified client the entire floor plan. Tiles +stream per peer instead. The accepted trade, in the user's words: a cheater +seeing further than they should is tolerable; seeing the whole map is not. + +Consequence to preserve: the client holds *real* geometry it cannot see, because +it predicts movement against walls and simulates bullets that die on them. So +hard fog is a rendering rule, not secrecy. The secrecy is in what the server +declines to send. + +--- + +## Identity and persistence + +**Steam-shaped auth abstraction.** *(User, this session.)* The goal is +eventually Steam, so build the shape Steamworks uses and keep it swappable: +client presents an opaque ticket, server validates it and gets a stable 64-bit +account id (SteamID64's stand-in). A local dev provider persists a generated id +in `user://`, so **no Steam account is needed now**. + +Not integrating GodotSteam yet: it needs a running Steam client and an app ID, +which would break the "no Steam account" requirement. Swapping it in later +should be one provider class and no schema change. + +--- + +## Progression + +**XP from kills, bosses worth far more.** *(User, this session.)* A first full +dungeon should give a bit more than is needed for the first level-up. + +**Permadeath.** Death marks a character inactive — never deleted, for archival +and troubleshooting — and the player picks another character or creates one. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4e3f182..41a38ca 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,67 +1,139 @@ -# Roadmap +# Status and roadmap -## Built +**Read this first when picking up work.** It maps every feature in the design +brief to its current state and the files that implement it, so you can find the +relevant code without re-reading the whole project. -| MVP requirement | State | +Design decisions that are already settled — and the reasoning behind them — live +in [DECISIONS.md](DECISIONS.md). Check there before asking the user something +that may already have an answer. + +Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started) + +--- + +## Stage 1 — World and exploration · *done* + +| Feature | State | Where | +| --- | --- | --- | +| Tile grid: movement / bullet / sight blocking | done | [src/sim/map_grid.gd](../src/sim/map_grid.gd) | +| Dungeon generation from (seed, depth) | done | [src/sim/map_gen.gd](../src/sim/map_gen.gd) | +| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) | +| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables | +| Enemies placed per room at creation | done | `Instance._populate()` | +| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) | +| Hard fog of war | done | `WorldView._draw_terrain` / `_visible` | +| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` | +| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` | +| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` | +| Boss confined to its room | **partial** | `SimBoss.room` exists and bounds curtain patterns, but nothing clamps the boss — every boss is stationary today. Enforcement belongs with boss movement in Stage 5. | +| **Actor interest management** | **todo** | The known gap. `ServerRuntime._physics_process` still encodes one snapshot and sends it to every peer in the instance, so clients are told about enemies the fog then hides. Terrain is properly withheld; actors are not. **Do this first.** | + +### The invariant that matters most here + +Maps are streamed per peer, and the generation seed is **never** sent. See +[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must +stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against +terrain it does not have. + +--- + +## Stage 2 — Characters, persistence, levels · *todo, next* + +Depends on nothing in Stage 1 except a place to stand. Blocked only on the +identity layer, which is decided but unbuilt. + +| Feature | Notes | | --- | --- | -| Top-down 2D twin-stick bullet hell | Movement, aim, fire, i-frames, death and respawn; MultiMesh bullet rendering. | -| Multiplayer with server-side hit validation | Authoritative dedicated server; clients send input only; all hits resolved server-side for both players and enemies. No PvP. | -| Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. | -| Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. | -| Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. | -| Emergency escape | One-second server-owned channel, cancelled by release but not by damage; a disconnect runs the same channel so quitting is never a cheaper exit. | -| Leaving and arriving | Downed players return to the hub on request (no timed respawn); entering a dungeon grants 2s of invulnerable, weapons-cold arrival protection. | -| Hub awareness | Server-pushed roster showing who is online and which dungeon they are in. | -| In-game menu | Escape opens return-to-hub / disconnect / quit. | +| Identity abstraction | Shaped like Steamworks so it swaps out cleanly: opaque ticket from client → server validates → stable 64-bit account id (a SteamID64 stand-in). Local dev provider persists a generated id in `user://`. See [DECISIONS.md](DECISIONS.md#identity). | +| Character store | Server-side, keyed by account id. Needs a schema and a file format; nothing persistent exists in the project today. | +| Up to 5 characters, random colour each | Colour is a placeholder for a later cosmetic system. | +| Last-played character auto-selected on join | | +| Permadeath → mark inactive, never delete | Archival/troubleshooting. Confirm whether the 5-character cap counts only *active* characters. | +| Death flow: pick another character or create one | Replaces today's "return to hub" button, which currently just revives you. | +| Levels 1–15, +10 max HP per level | Confirm whether level 1 is 100 HP (→ 240 at cap) or 110. | +| XP from kills, bosses worth much more | First full dungeon should give slightly more than one level. | -91 tests plus an end-to-end smoke test over a real socket, including a bot that -is SIGKILLed mid-dungeon to prove the disconnect path. +Current behaviour to replace: `SimPlayer` has no identity beyond a peer id; +`ServerRuntime.peer_names` is client-supplied and trusted for display only. -## Next, in rough order of value +--- -**1. Make the dungeon a place.** Right now it is one arena and three stages. -Rooms, doors, and a `SimWorld` with static geometry — which means adding a -collision representation for walls (segment-vs-circle for players, segment -crossing for bullets) since there is no physics engine to lean on. +## Stage 3 — Upgrades · *todo* -**1b. Progression, and what it turns on.** Several things are currently sized -for "no persistence yet" and should be revisited together with it: death sends -you to the hub rather than costing anything, a linkdead body is simply deleted -once it channels out (there is no hub state to put it in), and `peer_names` is -client-supplied and trusted for display. All three are fine now and none of them -are once a character has anything worth losing. +| Feature | Notes | +| --- | --- | +| Lobby NPC, 3 random choices per level gained | Confirm whether unclaimed level-ups queue. | +| Every upgrade also grants +5% damage, additive | Displayed on the choice alongside its specific effects. | +| Upgrade list | split shot, glass cannon, spread, sniper, doubleshot (rarer), poison (rare), eraser (legendary) | +| Rarity weights | **Unspecified.** Needs numbers. | +| Damage stacking formula | **Unspecified.** Sniper is explicitly multiplicative; the rest read as additive. Order needs pinning down before any of it is built. | +| "Chosen upgrades" screen | | -**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot -texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and -sound. None of it touches the simulation — this is entirely `src/view/`. +Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`; upgrades turn +it into a per-player computed stat, so `SimWorld._fire_player_shot` grows a +stats block. Several upgrades (split, spread, doubleshot) change how many +bullets spawn per shot, so they belong in the same place. -**3. A second boss.** The format claims to be reusable; the way to find out is -to use it. A mobile boss will exercise `BossDef.stationary`, which the runtime -already reads but no content uses yet. +--- -**4. Progression.** Loot, character stats, persistence. Needs a decision on -storage: for a dedicated server, player state belongs server-side in a database, -not in a client save file. +## Stage 4 — Inventory and loot · *todo* -**5. Netcode hardening.** In the order they will actually matter: - - DTLS on `ENetMultiplayerPeer` before any public server. - - Authentication; `peer_names` is currently client-supplied and trusted for - display only, which is fine now and will not be once there is progression. - - Snapshot delta compression, once enemy counts grow. - - Interest management inside an instance, once arenas are larger than a - screen. +| Feature | Notes | +| --- | --- | +| Small always-on-screen inventory | Slot count unspecified. | +| Health potions: rare from trash, guaranteed from bosses | | +| World-shared loot | Player-instanced loot planned later. | +| A unique, useless food item from bosses | Exists specifically to test player-instanced loot. Confirm whether it should be instanced *now* or just marked for it. | +| Dropping items so others can pick them up | | -**6. Scale.** The simulation costs ~1.4% of a core per instance, so the ceiling -is bandwidth and process supervision, not CPU. A shard manager that runs several -server processes behind a lobby-of-lobbies is the shape, but it is premature -until there is a game to fill it. +--- -## Deliberately not done +## Stage 5 — Boss features and new bosses · *todo* -- **Lag compensation.** Discussed in [NETCODE.md](NETCODE.md): rewinding for a - shooter's view would mean a player who dodged still gets hit, which is the - wrong trade for this genre. -- **Pattern-level bullet replication.** A large bandwidth win that couples the - client to emitter behaviour. Not worth it until bandwidth binds. +| Feature | State | +| --- | --- | +| Stationary phases | done — every current phase | +| Roaming / chasing within the boss room | todo; needs `SimBoss.room` clamping (see Stage 1 partial) | +| Phases that move to preset locations | todo | +| Attacks spawned at a distance with a telegraph indicator | todo — new event type plus a renderer, and it must survive fog | +| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` yet | + +The boss format itself is proven: `tests/unit/test_boss.gd` builds a boss from +scratch and asserts the simulation needs no changes to run it. Movement will be +the first thing that format has not covered, so expect `BossPhase` to gain a +movement field rather than `SimWorld` gaining a branch. + +--- + +## Deliberate omissions + +Not oversights — each was considered and rejected for now, with the reasoning in +[NETCODE.md](NETCODE.md) or [DECISIONS.md](DECISIONS.md): + +- **Lag compensation.** Rewinding to a shooter's view means a player who dodged + still gets hit; wrong trade for this genre. +- **Snapshot delta compression.** Fine at current actor counts. +- **DTLS / encryption.** `ENetMultiplayerPeer` supports it. Required before any + public server, not before then. +- **Pattern-level bullet replication.** A real bandwidth win that couples the + client to emitter behaviour. - **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape - for a bullet hell. See [ARCHITECTURE.md](ARCHITECTURE.md). + for a bullet hell — see [ARCHITECTURE.md](ARCHITECTURE.md). + +--- + +## Open questions for the user + +Genuinely unspecified; do not guess at these, they change the design: + +1. Damage stacking order — additive pool then multiplicative, or something else? +2. Rarity weights for the four upgrade tiers. +3. Split shot geometry: ±22.5° from the original heading, or 45° to each side? +4. Poison: do applications stack, or refresh a single DoT? +5. Eraser: does it delete *enemy bullets* it passes through? +6. Inventory slot count. +7. Do unclaimed level-ups queue at the NPC? +8. Does the 5-character cap count only active characters? +9. Glass cannon's −50% health: of base HP, or of levelled max HP? +10. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play. diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 114a4ac..3a7cde6 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -57,6 +57,19 @@ var cleared_countdown: int = Protocol.COUNTDOWN_NONE ## Where this world's dungeon portal is. Per-map now, so it has to be told. var portal_pos := Vector2.ZERO +## Offset the view applies when drawing the world: screen = world + this. +## Published by the game scene every frame, rather than assumed, so aiming +## stays correct if the camera ever stops being exactly centred on the player +## (clamping at map edges, screen shake, a look-ahead offset). +var camera_offset := Vector2.ZERO + + +## Screen point to world point. The camera scrolls now, so this is no longer +## "relative to the middle of the screen" -- treating it as such made the ship +## aim at a fixed world location instead of at the cursor. +static func screen_to_world(screen: Vector2, offset: Vector2) -> Vector2: + return screen - offset + ## Backstop for input-numbering drift: if the server stops acknowledging new ## inputs, our tick numbering has fallen outside its acceptance window and no ## amount of waiting fixes it. Counted in snapshots, not ticks. @@ -133,8 +146,8 @@ func _sample_input() -> InputFrame: var dead_buttons := InputFrame.BTN_INTERACT if request_respawn else 0 return InputFrame.make(input_tick, Vector2.ZERO, aim, dead_buttons) var move := Input.get_vector("move_left", "move_right", "move_up", "move_down") - var mouse := get_viewport().get_mouse_position() - get_viewport().get_visible_rect().size * 0.5 - var to_mouse := mouse - predicted_pos + var world_mouse := screen_to_world(get_viewport().get_mouse_position(), camera_offset) + var to_mouse := world_mouse - predicted_pos if to_mouse.length_squared() > 1.0: aim = to_mouse.angle() var buttons := 0 diff --git a/src/view/game_scene.gd b/src/view/game_scene.gd index debc5f6..99494a6 100644 --- a/src/view/game_scene.gd +++ b/src/view/game_scene.gd @@ -32,6 +32,11 @@ func _follow_camera() -> void: if _bound != null: focus = _bound.predicted_pos world_view.position = _screen_centre - focus + if _bound != null: + # The client converts cursor position to world space with this, so it + # has to come from the transform actually used to draw, not from a + # second assumption about where the camera is. + _bound.camera_offset = world_view.position func _process(_delta: float) -> void: diff --git a/tests/unit/test_aim.gd b/tests/unit/test_aim.gd new file mode 100644 index 0000000..c1d661a --- /dev/null +++ b/tests/unit/test_aim.gd @@ -0,0 +1,61 @@ +extends GutTest +## Cursor-to-world conversion. +## +## This broke the moment the camera started scrolling: the old code treated +## "mouse relative to the centre of the screen" as a world position, which was +## true only while the world was drawn fixed at the origin. Afterwards the ship +## aimed at a fixed world location regardless of where the cursor was. + +const SCREEN := Vector2(1280.0, 720.0) +const CENTRE := Vector2(640.0, 360.0) + + +## The camera the game actually uses: world_view.position = centre - player. +func _offset_for(player: Vector2) -> Vector2: + return CENTRE - player + + +func _aim_from(mouse_screen: Vector2, player: Vector2) -> Vector2: + var world_mouse := ClientRuntime.screen_to_world(mouse_screen, _offset_for(player)) + return world_mouse - player + + +func test_screen_to_world_inverts_the_draw_transform() -> void: + # The view draws at screen = world + offset, so this must be its inverse or + # everything derived from the cursor is off by the camera position. + var offset := _offset_for(Vector2(900.0, -400.0)) + var world := Vector2(123.0, -456.0) + var screen := world + offset + assert_eq(ClientRuntime.screen_to_world(screen, offset), world) + + +func test_aim_follows_the_cursor_not_a_world_location() -> void: + # Same cursor position, two very different player positions: the aim + # direction must be identical, because the player is drawn centred. + var mouse := CENTRE + Vector2(100.0, 0.0) + var near_origin := _aim_from(mouse, Vector2.ZERO) + var far_away := _aim_from(mouse, Vector2(4000.0, -2500.0)) + assert_almost_eq(near_origin.angle(), far_away.angle(), 0.0001, + "aim must depend on the cursor, not on where in the map you stand") + assert_almost_eq(near_origin.angle(), 0.0, 0.0001, "cursor right of centre aims right") + + +func test_the_four_cardinal_directions() -> void: + var player := Vector2(3000.0, 1500.0) + assert_almost_eq(_aim_from(CENTRE + Vector2(50.0, 0.0), player).angle(), 0.0, 0.001) + assert_almost_eq(_aim_from(CENTRE + Vector2(0.0, 50.0), player).angle(), PI * 0.5, 0.001) + assert_almost_eq(_aim_from(CENTRE + Vector2(0.0, -50.0), player).angle(), -PI * 0.5, 0.001) + assert_almost_eq(absf(_aim_from(CENTRE + Vector2(-50.0, 0.0), player).angle()), PI, 0.001) + + +## The regression itself, stated directly: with the old expression the aim +## vector picked up the player's position, so walking around the map swung the +## crosshair even with the mouse held still. +func test_walking_does_not_swing_the_aim() -> void: + var mouse := CENTRE + Vector2(0.0, -120.0) + var angles: Array[float] = [] + for step in 5: + angles.append(_aim_from(mouse, Vector2(step * 500.0, step * -300.0)).angle()) + for a in angles: + assert_almost_eq(a, angles[0], 0.0001, + "the crosshair must not move when only the player moves") diff --git a/tests/unit/test_aim.gd.uid b/tests/unit/test_aim.gd.uid new file mode 100644 index 0000000..11148be --- /dev/null +++ b/tests/unit/test_aim.gd.uid @@ -0,0 +1 @@ +uid://b7wqk5agkkso2