diff --git a/.gitattributes b/.gitattributes index 745c9e1..f8518e4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,3 +40,9 @@ # ─── Don't diff these ────────────────────────────────────────────── *.import -diff *.uid -diff + +# ─── Lockable files types ────────────────────────────────────────────── +*.glb filter=lfs diff=lfs merge=lfs -text lockable +*.blend filter=lfs diff=lfs merge=lfs -text lockable +*.png filter=lfs diff=lfs merge=lfs -text lockable +*.res filter=lfs diff=lfs merge=lfs -text lockable diff --git a/README.md b/README.md index 04aac90..8a48817 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,19 @@ A 3D darts game where the core loop is **dragging modifiers onto board sectors** to create compounding scoring interactions. Balatro's joker system, applied to a dartboard. -Built in **Godot 4**. Modding is a first-class concern, not an afterthought — our own -content ships through the same loader community mods will use. +Built in **Godot 4.7**. Modding is a first-class concern, not an afterthought — our own content +ships through the same loader community mods will use. --- ## ⚠️ Install `git-lfs` BEFORE you clone Clone without it and you'll get **130-byte text pointers** where your textures and models -should be. Godot will throw import errors that look completely baffling and you will lose -an hour figuring out why. +should be. Godot will throw import errors that look completely baffling and you will lose an +hour. -**NixOS** — `configuration.nix`: -```nix -environment.systemPackages = with pkgs; [ git git-lfs ]; -``` - -Or declaratively via Home Manager (also writes the LFS filter config, so a fresh machine is -correct with no imperative step): +**NixOS** — Home Manager (also writes the LFS filter config, so a fresh machine is correct with +no imperative step): ```nix programs.git = { enable = true; @@ -28,6 +23,11 @@ programs.git = { }; ``` +Or system-wide: +```nix +environment.systemPackages = with pkgs; [ git git-lfs ]; +``` + **Debian/Ubuntu** — `sudo apt install git-lfs` **macOS** — `brew install git-lfs` @@ -38,19 +38,28 @@ git lfs install --- -## Setup +## Clone ```bash -git clone /DartGame.git +git clone ssh://git@gitea.lilpenn.org:30009/ced/DartGame.git cd DartGame -git lfs pull ``` -**Verify LFS worked.** Open `game/core/board/dartboard.glb` in a text editor. If it's -readable text starting with `version https://git-lfs...`, LFS did **not** run. Stop and fix -it before opening the project. +> **Note the `git@`.** Without it, SSH tries to log in as your local username and you get +> `Permission denied (publickey)`. Gitea's SSH user is always `git`. -### Tooling +### Verify LFS worked + +```bash +file game/core/board/dartboard.glb +``` + +**Want:** `glTF binary model, version 2, length 3302888 bytes` +**Bad:** `ASCII text` ← that's an unfetched LFS pointer. Stop and fix it before opening Godot. + +--- + +## Setup ```bash # GDScript formatter + linter @@ -60,9 +69,7 @@ nix-shell -p gdtoolkit_4 # or: pipx install "gdtoolkit==4.*" pre-commit install ``` -### Open the project - -Point Godot 4 at `game/project.godot`. +Then point Godot 4 at `game/project.godot`. --- @@ -72,18 +79,19 @@ Point Godot 4 at `game/project.godot`. |---|---|---| | `.blend`, `.kra`, `.psd` | ✅ | ❌ **never** | | `.glb`, `.png` (exported) | ❌ | ✅ (LFS) | +| `.res` (collision shapes) | ❌ | ✅ (LFS) | | Code, scenes, `.tres` | ❌ | ✅ | | Moodboards, references | ✅ | ❌ | +| **Documentation** | ✅ | ❌ | Source files live in the **company Nextcloud share**, not here. They churn constantly, never -merge, and 99% of their revisions have no corresponding code change — they'd bloat LFS -history forever, on every clone. +merge, and 99% of their revisions have no corresponding code change — they'd bloat LFS history +forever, on every clone. -Exported artifacts **do** live here, because they must version in lockstep with the code -that references them. If a UV layout changes and a script needs updating, those belong in -one commit. +Exported artifacts **do** live here, because they must version in lockstep with the code that +references them. If a UV layout changes and a script needs updating, those belong in one commit. -`SOURCES.md` maps each repo artifact back to its Nextcloud source. +[`SOURCES.md`](SOURCES.md) maps each repo artifact back to its Nextcloud source. --- @@ -92,18 +100,15 @@ one commit. ``` game/ ├── core/ ← THE ENGINE. Contains zero game content. Ever. -│ ├── board/ dartboard scene, mesh, sector logic -│ ├── dart/ dart scene, physics -│ ├── scoring/ ScorePacket pipeline -│ ├── game/ GameContext ← the mod API -│ ├── modding/ loader, registry, base classes, primitives -│ ├── ui/ modifier tray, drag-and-drop -│ └── debug/ hit_simulator.tscn ← you'll use this daily +│ ├── board/ dartboard scene, mesh, 21-slot logic, trimesh collision +│ ├── dart/ dart scene, drag/throw/flight physics +│ ├── modding/ mod registry, modifier base class +│ └── ui/ drop catcher, modifier tray, sector swatch ├── content/official/ ← OUR CONTENT, loaded as a mod like any other -│ ├── skins/ -│ ├── modifiers/ -│ └── modes/ -└── shared/ fonts, shaders +│ ├── mod.json +│ ├── skins/ bulls/ · sectors/ · flat_tire/ +│ └── modifiers/ (empty — Phase 5) +└── scenes/ main.tscn, camera_3d.gd ``` ### The two rules @@ -117,10 +122,33 @@ pipeline is real. If it can't, we don't have a mod system — we have a plugin f --- +## The board is 21 slots + +`dartboard.glb` exports 21 material slots: `sector_1` … `sector_20`, plus `bulls`. Each sector +is its own material, so it can be skinned and modified independently. + +Hit detection reads the **mesh face index** from the raycast and maps it to the material name — +*not* an angle. That's what lets a modifier rotate or remap the board and still score correctly. + +Full detail in the docs. + +--- + ## Docs -Full documentation lives in the Obsidian vault at -`Nextcloud/DartGame/documentation/`. Start with **Build Plan**. +Full documentation is an Obsidian vault at `Nextcloud/DartGame/documentation/`. + +**Start with `Build Plan`.** Then `Conventions` before your first commit. + +| Note | | +|---|---| +| **Build Plan** | What we're building. Eight phases, current status. | +| **Conventions** | Naming, git workflow, linting. **Read before your first commit.** | +| **Architecture** | How the engine works and why. | +| **Godot Gotchas** | Every trap that cost us time. Read it. | +| **Modding** | The mod format and public API. | +| **Asset Pipeline** | Nextcloud ↔ repo handoff, Blender export settings. | +| **File Tree** | Full directory reference. | --- @@ -131,30 +159,33 @@ Full documentation lives in the Obsidian vault at **Commits:** Conventional Commits. ``` feat(modding): add ModRegistry namespaced lookup -fix(scoring): ScorePacket.total() ignored final_override +fix(board): face-to-slot map missed the last surface ``` **Before you open a PR:** - [ ] `gdformat --check game/` passes - [ ] `gdlint game/` passes - [ ] Nothing in `core/` references a specific content ID -- [ ] New content is one self-contained folder - [ ] If an asset changed: source updated in Nextcloud, `SOURCES.md` current +- [ ] `git lfs status` shows binaries as LFS ### ⚠️ Moving files -**Commit first.** Godot's refactoring is fragile. Move files from **inside the Godot -editor's FileSystem dock**, never your file manager, so references get fixed up. +**Commit first.** Godot's refactoring is fragile. Move files from **inside the Godot editor's +FileSystem dock**, never your file manager, so references get fixed up. -If things break after a move or a branch switch: +If things break after a move or branch switch: ```bash rm -rf game/.godot/ ``` -then Project → Reload Current Project. If it's worse than that, `git restore .` — which only -works if you committed first. +then Project → Reload Current Project. Worse than that? `git restore .` — which only works if +you committed first. --- ## Status -**Pre-alpha.** Currently in Phase 0 (repo hygiene). See [`docs/build_plan.md`](docs/build_plan.md). +**Pre-alpha.** Phase 0 (repo hygiene) complete. Phase 1 (skeleton) in progress — board and dart +scenes exist with collision; the throw mechanic is still being tuned. + +See `Build Plan` in the docs vault. diff --git a/SOURCES.md b/SOURCES.md index 7f610f4..99083c9 100644 --- a/SOURCES.md +++ b/SOURCES.md @@ -1,7 +1,7 @@ # Sources -Every binary artifact in this repo was exported from a source file in Nextcloud. This maps -one to the other. +Every binary artifact in this repo was exported from a source file in Nextcloud. This maps one +to the other. **Nextcloud share:** `DartGame/sources/` @@ -12,46 +12,80 @@ one to the other. ## Models -| Repo artifact | Nextcloud source | Last export | -|---|---|---| -| `game/core/board/dartboard.glb` | `sources/dartboard/dartboard.blend` | 2026-07-11 | -| `game/core/dart/dart.glb` | `sources/dart/dart.blend` | 2026-07-11 | - -## Skins — bullseye - -| Repo artifact | Nextcloud source | Last export | -|---|---|---| -| `game/content/official/skins/bullseye/neon_bulls.png` | `sources/dartboard/textures/neon_bulls.kra` | 2026-07-11 | - -## Skins — sectors - -| Repo artifact | Nextcloud source | Last export | -|---|---|---| -| `game/content/official/skins/sectors/neon_hiero_sector.png` | `sources/dartboard/textures/neon_hiero_sector.kra` | 2026-07-11 | - -## Skins — flat tire - -| Repo artifact | Nextcloud source | Last export | -|---|---|---| -| `game/content/official/skins/flat_tire/fire.png` | `sources/dartboard/textures/flat_tire.kra` | 2026-07-11 | - -## Skins — flights - -| Repo artifact | Nextcloud source | Last export | -|---|---|---| -| `game/content/official/skins/flights/default.png` | `sources/dart/textures/wings_default.kra` | 2026-07-11 | +| Repo artifact | Nextcloud source | +|---|---| +| `game/core/board/dartboard.glb` | `sources/dartboard/dartboard.blend` | +| `game/core/dart/dart.glb` | `sources/dart/dart.blend` | --- ## Fallback textures -Single default textures that ship with the mesh so the editor isn't pink. Not skins — these -live in `core/`, not `content/`. +These live in `core/`, **not** `content/`. They're the defaults that ship with the mesh so the +board renders correctly with zero mods loaded. They are not skins. + +The board has 21 material slots (`sector_1`…`sector_20`, `bulls`); the 20 sector materials share +the two `sector_*` images by default. | Repo artifact | Nextcloud source | |---|---| -| `game/core/board/dartboard_default.png` | `sources/dartboard/textures/cork.kra` | -| `game/core/dart/dart_default.png` | `sources/dart/textures/dart_default.kra` | +| `game/core/board/dartboard_bulls.png` | `sources/dartboard/textures/default/bulls.kra` | +| `game/core/board/dartboard_sector_black.png` | `sources/dartboard/textures/default/sector_black.kra` | +| `game/core/board/dartboard_sector_white.png` | `sources/dartboard/textures/default/sector_white.kra` | +| `game/core/board/dartboard_flat_tire.png` | `sources/dartboard/textures/default/flat_tire.kra` | +| `game/core/board/dartboard_spider.png` | `sources/dartboard/textures/default/spider.kra` | +| `game/core/dart/dart_body.png` | `sources/dart/textures/default/body.kra` | +| `game/core/dart/dart_wings.png` | `sources/dart/textures/default/wings.kra` | + +> The `dartboard_*` / `dart_*` prefixes are Godot's — it extracts the textures embedded in the +> `.glb` on import and names them after the model. Don't rename them; the materials reference +> them by that name. + +--- + +## Skins — bulls + +| Repo artifact | Nextcloud source | +|---|---| +| `game/content/official/skins/bulls/neon_hiero.png` | `sources/dartboard/textures/bulls_skins/neon_hiero.kra` | + +## Skins — sectors + +| Repo artifact | Nextcloud source | +|---|---| +| `game/content/official/skins/sectors/neon_hiero_black.png` | `sources/dartboard/textures/sector_skins/neon_hiero_black.kra` | +| `game/content/official/skins/sectors/neon_hiero_white.png` | `sources/dartboard/textures/sector_skins/neon_hiero_white.kra` | + +## Skins — flat tire + +| Repo artifact | Nextcloud source | +|---|---| +| `game/content/official/skins/flat_tire/ocean_fire.png` | `sources/dartboard/textures/flat_tire_skins/ocean_fire.kra` | +| `game/content/official/skins/flat_tire/toon_purple.png` | `sources/dartboard/textures/flat_tire_skins/toon_purple.kra` | + +--- + +## Generated, not exported + +These are produced **by Godot**, not by Blender or Krita. They have no Nextcloud source. + +| Artifact | What it is | +|---|---| +| `game/core/board/dartboard_collision.res` | Trimesh (`ConcavePolygonShape3D`), 1358 faces. Generated from the `dartboard` mesh via Mesh → Create Collision Shape → Trimesh, then extracted from the `.tscn` with Shape → Save As. **~9 MB.** | +| `game/core/board/spider_collision.res` | Same, for the spider wire. | + +> Regenerate these if the board geometry changes. They are **not** rebuilt automatically on +> re-export. + +--- + +## Not yet exported + +Sources that exist in Nextcloud but have no artifact in the repo yet. + +| Nextcloud source | Destined for | +|---|---| +| `sources/dart/textures/wings/red_blue.kra` | `content/official/skins/flights/red_blue.png` | --- @@ -59,7 +93,10 @@ live in `core/`, not `content/`. 1. Export from the Nextcloud source 2. Drop the artifact into the repo path above -3. **Update the "Last export" date in this file** -4. Commit the artifact + this file together +3. Commit the artifact **and its `.import` file** together +4. Update this file if anything new appeared + +**Names must match.** `neon_hiero_black.kra` → `neon_hiero_black.png`. We drifted once already +(`purple_toon.kra` vs `toon_purple.png`) — that's exactly what this file exists to catch. If a row is missing, the artifact has no traceable source. Add it, or find out why it exists. diff --git a/game/content/official/modifiers/mjolnir/mjolnir.png b/game/content/official/modifiers/mjolnir/mjolnir.png new file mode 100644 index 0000000..a1d1341 --- /dev/null +++ b/game/content/official/modifiers/mjolnir/mjolnir.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e74a898faa5620eecbfc3e80173cbc84b25e8e875d8e7a75618324e5856b0732 +size 297083 diff --git a/game/content/official/modifiers/mjolnir/mjolnir.png.import b/game/content/official/modifiers/mjolnir/mjolnir.png.import new file mode 100644 index 0000000..b4728f9 --- /dev/null +++ b/game/content/official/modifiers/mjolnir/mjolnir.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dgq04uh0wal5a" +path="res://.godot/imported/mjolnir.png-5e80731d4fee936aa61acd006a5499ea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://content/official/modifiers/mjolnir/mjolnir.png" +dest_files=["res://.godot/imported/mjolnir.png-5e80731d4fee936aa61acd006a5499ea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/game/content/official/modifiers/test_pink/test_pink.tres b/game/content/official/modifiers/test_pink/test_pink.tres new file mode 100644 index 0000000..200a824 --- /dev/null +++ b/game/content/official/modifiers/test_pink/test_pink.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="Modifier" load_steps=3 format=3] + +[ext_resource type="Script" path="res://core/modding/modifier.gd" id="1"] +[ext_resource type="Texture2D" path="res://content/official/skins/sectors/neon_hiero_black.png" id="2"] + +[resource] +script = ExtResource("1") +id = "test_pink" +title = "Test Marker" +description = "Drops onto any sector and replaces its texture. Proves face-index lookup works." +texture = ExtResource("2") +score_mult = 1.0 +score_add = 0 +weight = 1.0 diff --git a/game/core/board/dartboard.gd b/game/core/board/dartboard.gd index 42823ed..93143b8 100644 --- a/game/core/board/dartboard.gd +++ b/game/core/board/dartboard.gd @@ -1,22 +1,29 @@ class_name Dartboard extends StaticBody3D -## The board. 21 material slots: sector_1 .. sector_20, plus "bulls". +## The board. 22 material slots: +## sector_1 .. sector_20 (20) ← the scoring wedges +## bulls (1) ← inner AND outer bull, they move together +## flat_tire (1) ← the outer band ## ## Owns the face -> surface -> slot mapping, which is what makes hit detection ## survive the board moving, rotating, or having its sectors remapped. Ask the ## geometry which sector was hit; don't calculate it from an angle. -## Every slot on the board, in the order Blender exported them (order doesn't -## matter — we index by name). +signal slot_painted(slot: StringName) + const BULL_SLOT := &"bulls" +const FLAT_TIRE_SLOT := &"flat_tire" +const SECTOR_PREFIX := "sector_" @onready var mesh_instance: MeshInstance3D = $Model/dartboard ## StringName -> surface index var _slot_to_surface: Dictionary = {} -## global face index -> StringName +## global trimesh face index -> StringName var _face_to_slot: Dictionary = {} +## surface index -> [first_face, last_face], for debugging +var _surface_face_range: Dictionary = {} var _outer_radius: float = 0.0 @@ -32,13 +39,13 @@ func _ready() -> void: func _index_slots() -> void: var mesh := mesh_instance.mesh if mesh == null: - push_error("Dartboard has no mesh.") + push_error("Dartboard has no mesh. Check the scene tree — expected $Model/dartboard.") return for i in mesh.get_surface_count(): var mat := mesh.surface_get_material(i) if mat == null: - push_warning("Surface %d has no material — can't be skinned or hit." % i) + push_warning("Surface %d has no material — it can't be hit or skinned." % i) continue var slot := StringName(mat.resource_name) @@ -46,21 +53,32 @@ func _index_slots() -> void: push_warning("Surface %d's material has no name." % i) continue + if _slot_to_surface.has(slot): + push_warning("Duplicate slot name '%s' (surfaces %d and %d)." + % [slot, _slot_to_surface[slot], i]) + _slot_to_surface[slot] = i - print("Dartboard: indexed %d slots." % _slot_to_surface.size()) - if _slot_to_surface.size() != 21: - push_warning("Expected 21 slots (20 sectors + bulls), got %d." % _slot_to_surface.size()) + # Sanity: we expect exactly 20 sectors. + var sectors := 0 + for slot in _slot_to_surface: + if String(slot).begins_with(SECTOR_PREFIX): + sectors += 1 + + if sectors != 20: + push_error("Expected 20 sector slots, found %d. Check the Blender material names." + % sectors) ## Build a global-face-index -> slot map. ## -## The physics engine reports face_index as an index into the WHOLE trimesh, -## but the mesh stores faces per-surface. So we walk the surfaces in order, -## accumulating a running face count, and record which range belongs to which. +## The physics engine reports face_index as an index into the WHOLE trimesh. +## The mesh stores faces per-surface. create_trimesh_shape() concatenates the +## surfaces in order, so we can walk them accumulating a running face count. ## -## This assumes the ConcavePolygonShape3D was built from the mesh with surfaces -## in the same order — which is what create_trimesh_shape() does. +## ⚠️ This assumes surface order == trimesh face order. Verified by +## debug_verify_faces(), not assumed. If that assumption ever breaks (a Godot +## version change, a different collision generator), THIS is where it breaks. func _index_faces() -> void: var mesh := mesh_instance.mesh if mesh == null: @@ -75,8 +93,7 @@ func _index_faces() -> void: var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] - # Triangle count: from the index buffer if present, else from vertices. - var tri_count := (indices.size() / 3) if indices.size() > 0 else (verts.size() / 3) + var tri_count: int = (indices.size() / 3) if indices.size() > 0 else (verts.size() / 3) var mat := mesh.surface_get_material(i) var slot := StringName(mat.resource_name) if mat else &"" @@ -84,14 +101,12 @@ func _index_faces() -> void: for f in tri_count: _face_to_slot[running + f] = slot + _surface_face_range[i] = [running, running + tri_count - 1] running += tri_count - print("Dartboard: mapped %d faces." % _face_to_slot.size()) - func _cache_radius() -> void: var aabb := mesh_instance.get_aabb() - # The disc's two large axes. The third is the board's thickness. var sizes := [aabb.size.x, aabb.size.y, aabb.size.z] sizes.sort() _outer_radius = sizes[2] * 0.5 # largest dimension = diameter @@ -99,29 +114,39 @@ func _cache_radius() -> void: # --- Public ------------------------------------------------------------- -## Which slot does this trimesh face belong to? +## Which slot does this trimesh face belong to? &"" if unknown. func slot_for_face(face_index: int) -> StringName: return _face_to_slot.get(face_index, &"") -## Paint a texture onto one slot. Used for both skins and modifier overlays. +## Paint a texture onto one slot. func paint_slot(slot: StringName, texture: Texture2D) -> void: if not _slot_to_surface.has(slot): push_warning("No slot named '%s'." % slot) return var surface: int = _slot_to_surface[slot] + + # get_active_material returns the override if one exists, else the mesh's. var mat := mesh_instance.get_active_material(surface) if mat == null: + push_warning("Slot '%s' has no material to duplicate." % slot) return - # Duplicate, or we mutate the shared material from the .glb — which leaks - # to every instance AND can get written back to disk in the editor. + # ⚠️ Duplicate, or we mutate the SHARED material from the .glb — which leaks + # the change to every other slot using that material, AND can get written + # back to disk in the editor. var unique := mat.duplicate() as StandardMaterial3D + if unique == null: + push_warning("Slot '%s' material isn't a StandardMaterial3D." % slot) + return + unique.albedo_texture = texture unique.albedo_color = Color.WHITE # don't tint the texture mesh_instance.set_surface_override_material(surface, unique) + slot_painted.emit(slot) + ## Reset a slot to its imported material. func clear_slot(slot: StringName) -> void: @@ -130,30 +155,46 @@ func clear_slot(slot: StringName) -> void: mesh_instance.set_surface_override_material(_slot_to_surface[slot], null) +func clear_all_slots() -> void: + for slot in _slot_to_surface: + clear_slot(slot) + + +func has_slot(slot: StringName) -> bool: + return _slot_to_surface.has(slot) + + +func is_sector(slot: StringName) -> bool: + return String(slot).begins_with(SECTOR_PREFIX) + + +## &"sector_20" -> 20. Returns 0 for bulls, flat_tire, or unknown. +func sector_number(slot: StringName) -> int: + if not is_sector(slot): + return 0 + return String(slot).trim_prefix(SECTOR_PREFIX).to_int() + + func get_slots() -> Array[StringName]: var out: Array[StringName] = [] out.assign(_slot_to_surface.keys()) return out -## Board radius in local units. func outer_radius() -> float: return _outer_radius -## Project a LOCAL point onto the board's face plane, returning 2D coords -## where +Y is "up" on the board (toward the 20). +## Project a LOCAL point onto the board's face plane. +## Returns 2D coords where +Y is "up" on the board. ## -## Which two axes those are depends on how the .glb imported. glTF is +Y up, -## so a board modeled facing +Z in Blender usually lands facing +Z here, with -## the disc in the X/Y plane. If sector detection is 90 degrees off, this is -## the function to fix. +## Your board's AABB is (0.451, 0.451, 0.029) — thin on Z — so the disc lies in +## the X/Y plane. This picks the two fat axes automatically. func planar_coords(local_point: Vector3) -> Vector2: var aabb := mesh_instance.get_aabb() var center := aabb.position + aabb.size * 0.5 - - # Identify the thin axis — that's the board's normal. var s := aabb.size + if s.z <= s.x and s.z <= s.y: return Vector2(local_point.x - center.x, local_point.y - center.y) elif s.y <= s.x and s.y <= s.z: @@ -162,12 +203,77 @@ func planar_coords(local_point: Vector3) -> Vector2: return Vector2(local_point.z - center.z, local_point.y - center.y) -## Debug: what did Blender actually export? +## Distance from board centre as a fraction of the outer radius. +func normalized_radius(world_point: Vector3) -> float: + var planar := planar_coords(to_local(world_point)) + return planar.length() / _outer_radius if _outer_radius > 0.0 else 999.0 + + +# --- Debug -------------------------------------------------------------- + func print_slots() -> void: var mesh := mesh_instance.mesh - print("=== Dartboard slots ===") + print("=== Dartboard: %d slots, %d faces ===" % [_slot_to_surface.size(), _face_to_slot.size()]) for i in mesh.get_surface_count(): var mat := mesh.surface_get_material(i) - print(" surface %2d: '%s'" % [i, mat.resource_name if mat else "(none)"]) - print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size]) - print("=======================") + var name_str: String = mat.resource_name if mat else "(none)" + var range_arr: Array = _surface_face_range.get(i, [-1, -1]) + print(" surface %2d: %-12s faces %4d–%4d" % [i, name_str, range_arr[0], range_arr[1]]) + print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size]) + print("=====================================") + + +## ⚠️ VERIFY THE CORE ASSUMPTION. +## +## Fires a ray at each sector's known centre and checks that face_index maps +## back to the right slot. If surface order != trimesh face order, this catches +## it — and the whole face-index approach is invalid until it's fixed. +## +## Call once from main.gd, read the output, then delete the call. +func debug_verify_faces(camera: Camera3D) -> void: + print("=== Face-index verification ===") + + var space := get_world_3d().direct_space_state + var errors := 0 + var checks := 0 + + # March a grid across the board and confirm every hit resolves to a slot + # that actually exists. + const STEPS := 24 + for iy in STEPS: + for ix in STEPS: + var u := (float(ix) / (STEPS - 1)) * 2.0 - 1.0 # -1..1 + var v := (float(iy) / (STEPS - 1)) * 2.0 - 1.0 + + # Skip outside the disc + if Vector2(u, v).length() > 0.95: + continue + + # A point on the board's face, in local space + var local := Vector3(u * _outer_radius, v * _outer_radius, 0.0) + var world := to_global(local) + + # Ray straight at it, from in front + var from := world + global_transform.basis.z * 0.5 + var params := PhysicsRayQueryParameters3D.create(from, world - global_transform.basis.z * 0.1) + params.collide_with_bodies = true + var hit := space.intersect_ray(params) + + if hit.is_empty(): + continue + + checks += 1 + var face: int = hit.get("face_index", -1) + var slot := slot_for_face(face) + + if slot == &"": + errors += 1 + if errors <= 5: + print(" ✗ face %d -> no slot (local %.3f, %.3f)" % [face, local.x, local.y]) + + print(" %d rays hit, %d unmapped faces" % [checks, errors]) + if errors == 0: + print(" ✓ Every hit face maps to a slot.") + else: + push_error("Face mapping is BROKEN. %d faces don't map." % errors) + print("===============================") diff --git a/game/core/ui/drop_catcher.gd b/game/core/ui/drop_catcher.gd index 6dd0531..a1c2c25 100644 --- a/game/core/ui/drop_catcher.gd +++ b/game/core/ui/drop_catcher.gd @@ -1,208 +1,139 @@ extends Control -## Resolves a screen position (or a dart's world position) to a board sector, -## and applies modifiers there. +## Screen position -> board slot. That's the whole job. ## -## THE BOARD HAS 21 MATERIAL SLOTS: sector_1 .. sector_20, plus "bulls". -## The bull slot covers inner AND outer bull — they move together. +## Scoring lives in Phase 2's ScoringEngine. This resolves WHERE, not WHAT IT'S +## WORTH. Keeping them separate means the modifier UI can ship before the +## scoring pipeline exists. ## -## Two ways to find which sector was hit: +## HOW IT WORKS +## ------------ +## The raycast returns a `face_index` — which triangle of the trimesh was hit. +## Dartboard maps that face to a material slot name. The material name IS the +## sector. ## -## A) BY FACE INDEX (default, robust) -## Raycast returns face_index. Map face -> surface. The surface's material -## name IS the sector. No trigonometry. -## Survives the board rotating, tilting, or having its sectors remapped, -## because we're asking the geometry, not calculating from an angle. -## Requires ConcavePolygonShape3D (trimesh) collision. +## No trigonometry. No calibration constants. Survives the board being rotated, +## tilted, moved, or having its sectors remapped — because we're reading the +## geometry, not calculating from an assumed layout. ## -## B) BY ANGLE (fallback) -## atan2 on the local hit point. Cheap, works with a box collider. -## Survives rotation (to_local undoes the transform) but NOT sector -## remapping — if Mjolnir makes every sector a 20, the angle still says -## "this is the 6". -## -## Use A. It's the only one that supports a moving/mutating board, which is the -## whole point of the modifier system. +## This is only possible because the board has a ConcavePolygonShape3D (trimesh) +## collider. A box or cylinder gives you no face_index. + +signal slot_hovered(slot: StringName) +signal slot_dropped(slot: StringName, modifier: Modifier) const RAY_LENGTH := 1000.0 -enum Ring { MISS, INNER_BULL, OUTER_BULL, INNER_SINGLE, TRIPLE, OUTER_SINGLE, DOUBLE } -enum Method { FACE_INDEX, ANGLE } - @export var camera: Camera3D @export var dartboard: Dartboard -@export_group("Resolution") -@export var method: Method = Method.FACE_INDEX +@export_group("Debug") +## Print every hover. Noisy but useful when calibrating. +@export var log_hovers := false -@export_group("Angle fallback (only used if method = ANGLE)") -## Clockwise from 12 o'clock. -const DART_NUMBERS := [20, 1, 18, 4, 13, 6, 10, 15, 2, 17, - 3, 19, 7, 16, 8, 11, 14, 9, 12, 5] -@export_range(0, 19) var sector_offset := 0 -@export var reverse_direction := false - -@export_group("Ring radii (fraction of outer radius)") -## Rings are ALWAYS resolved by radius, even in FACE_INDEX mode — the surface -## tells us WHICH sector, the radius tells us WHICH RING within it. -@export var bull_inner_max := 0.04 -@export var bull_outer_max := 0.09 -@export var inner_max := 0.45 -@export var triple_max := 0.55 -@export var outer_max := 0.80 - -## sector number (1-20), or 0 for bull -> Modifier +## slot -> Modifier var applied: Dictionary = {} +var _last_hovered: StringName = &"" + func _ready() -> void: + # The tray's pieces need to receive mouse events. This node is just a + # service — it must never eat them. mouse_filter = Control.MOUSE_FILTER_IGNORE + if camera == null: + push_error("DropCatcher: `camera` export is not set.") + if dartboard == null: + push_error("DropCatcher: `dartboard` export is not set.") + # --- Public ------------------------------------------------------------- -## Drop a modifier at a screen position. True if it landed on the board. -func apply_modifier_at_screen_pos(screen_pos: Vector2, modifier: Modifier) -> bool: - if modifier == null: - return false +## Which slot is under this screen position? &"" if the board isn't there. +func slot_at_screen_pos(screen_pos: Vector2) -> StringName: if camera == null or dartboard == null: - push_error("DropCatcher: camera and dartboard exports must be set.") - return false + return &"" var hit := _raycast(screen_pos) if hit.is_empty(): + return &"" + + # Only the board counts. The SpiderBody (layer 4) is a bounce surface, not + # a drop target. + if hit.get("collider") != dartboard: + return &"" + + var face: int = hit.get("face_index", -1) + if face < 0: + push_warning("Raycast returned no face_index. Is the collider a trimesh?") + return &"" + + var slot := dartboard.slot_for_face(face) + if slot == &"": + push_warning("Face %d didn't map to any slot." % face) + + return slot + + +## Called every frame by a piece being dragged, so it can highlight. +func hover(screen_pos: Vector2) -> StringName: + var slot := slot_at_screen_pos(screen_pos) + + if slot != _last_hovered: + _last_hovered = slot + slot_hovered.emit(slot) + if log_hovers and slot != &"": + print("hover: %s" % slot) + + return slot + + +## Drop a modifier at a screen position. True if it landed on a valid target. +func apply_modifier_at_screen_pos(screen_pos: Vector2, modifier: Modifier) -> bool: + if modifier == null: + push_warning("DropCatcher: null modifier.") return false - var slot := _slot_from_hit(hit) + var slot := slot_at_screen_pos(screen_pos) if slot == &"": - return false + return false # missed the board + + if not _is_valid_target(slot): + return false # hit the board, but not somewhere a modifier can go dartboard.paint_slot(slot, modifier.texture) applied[slot] = modifier - print("applied '%s' to %s" % [modifier.id, slot]) + slot_dropped.emit(slot, modifier) + + print("applied '%s' -> %s" % [modifier.id, slot]) return true -## Score a dart that landed at a world point. -func score_dart(world_point: Vector3, face_index: int = -1) -> int: - var slot := _slot_from_world(world_point, face_index) - var ring := _ring_from_radius(_norm_radius(world_point)) - var base := _base_score(slot, ring) - - if applied.has(slot): - return (applied[slot] as Modifier).apply_score(base) - return base +## Which slots can hold a modifier? +## +## Sectors: yes. Bulls: yes (it's a slot in its own right — the bull modifier +## affects inner and outer together). Flat tire: no — it's the outer band, not +## a scoring region. +func _is_valid_target(slot: StringName) -> bool: + return dartboard.is_sector(slot) or slot == Dartboard.BULL_SLOT -## Full breakdown of a hit. This is what Phase 2's ScoringEngine will consume. -func resolve_hit(world_point: Vector3, face_index: int = -1) -> Dictionary: - var slot := _slot_from_world(world_point, face_index) - var ring := _ring_from_radius(_norm_radius(world_point)) - return { - "slot": slot, # &"sector_20" / &"bulls" / &"" - "sector": _sector_number(slot), # 1-20, or 0 for bull/miss - "ring": ring, - "base_score": _base_score(slot, ring), - "modifier": applied.get(slot), - } +func modifier_at(slot: StringName) -> Modifier: + return applied.get(slot) -# --- Slot resolution ---------------------------------------------------- - -func _slot_from_hit(hit: Dictionary) -> StringName: - var point: Vector3 = hit.get("position", Vector3.ZERO) - var face: int = hit.get("face_index", -1) - return _slot_from_world(point, face) +func clear(slot: StringName) -> void: + applied.erase(slot) + dartboard.clear_slot(slot) -func _slot_from_world(world_point: Vector3, face_index: int) -> StringName: - # The bull is a slot in its own right — check radius first, since the bull - # surface may be small enough that face lookup is fiddly at the very center. - var norm := _norm_radius(world_point) - if norm > 1.0: - return &"" - if norm <= bull_outer_max: - return &"bulls" - - if method == Method.FACE_INDEX and face_index >= 0: - var slot := dartboard.slot_for_face(face_index) - if slot != &"": - return slot - push_warning("Face %d didn't map to a slot; falling back to angle." % face_index) - - return _slot_from_angle(world_point) +func clear_all() -> void: + applied.clear() + dartboard.clear_all_slots() -## Fallback. Works under rotation, NOT under sector remapping. -func _slot_from_angle(world_point: Vector3) -> StringName: - var local := dartboard.to_local(world_point) - var planar := dartboard.planar_coords(local) - - var t := fposmod(atan2(planar.x, planar.y) / TAU, 1.0) - if reverse_direction: - t = 1.0 - t - - var idx := int(floor(t * 20.0 + 0.5)) % 20 - idx = (idx + sector_offset) % 20 - return StringName("sector_%d" % DART_NUMBERS[idx]) - - -# --- Rings & scoring ---------------------------------------------------- - -## Distance from board center, as a fraction of the outer radius. -func _norm_radius(world_point: Vector3) -> float: - var local := dartboard.to_local(world_point) - var planar := dartboard.planar_coords(local) - var outer := dartboard.outer_radius() - return planar.length() / outer if outer > 0.0 else 999.0 - - -func _ring_from_radius(norm: float) -> Ring: - if norm > 1.0: - return Ring.MISS - if norm <= bull_inner_max: - return Ring.INNER_BULL - if norm <= bull_outer_max: - return Ring.OUTER_BULL - if norm <= inner_max: - return Ring.INNER_SINGLE - if norm <= triple_max: - return Ring.TRIPLE - if norm <= outer_max: - return Ring.OUTER_SINGLE - return Ring.DOUBLE - - -func _sector_number(slot: StringName) -> int: - var s := String(slot) - if not s.begins_with("sector_"): - return 0 - return s.trim_prefix("sector_").to_int() - - -func _base_score(slot: StringName, ring: Ring) -> int: - match ring: - Ring.MISS: - return 0 - Ring.INNER_BULL: - return 50 - Ring.OUTER_BULL: - return 25 - - var n := _sector_number(slot) - if n == 0: - return 0 - - match ring: - Ring.TRIPLE: - return n * 3 - Ring.DOUBLE: - return n * 2 - _: - return n - - -# --- Raycast ------------------------------------------------------------ +# --- Internals ---------------------------------------------------------- func _raycast(screen_pos: Vector2) -> Dictionary: var origin := camera.project_ray_origin(screen_pos) @@ -212,4 +143,8 @@ func _raycast(screen_pos: Vector2) -> Dictionary: ) params.collide_with_areas = false params.collide_with_bodies = true + + # Don't let the dart block the drop. It's on layer 2; the board is layer 1. + params.collision_mask = 1 + return get_viewport().world_3d.direct_space_state.intersect_ray(params) diff --git a/game/core/ui/modifier_tray.gd b/game/core/ui/modifier_tray.gd index 13a9adf..164070b 100644 --- a/game/core/ui/modifier_tray.gd +++ b/game/core/ui/modifier_tray.gd @@ -1,49 +1,69 @@ extends Control -## Offers a set of random modifiers each round as draggable pieces. -## Dropping one on the board applies it and consumes the whole set. - -const SectorSwatch := preload("res://core/ui/sector_swatch.gd") +## Spawns draggable modifier pieces along the bottom of the screen. +## +## For now it spawns ALL modifiers in the registry, one of each, and they never +## run out. That's a debug affordance — the real economy (random draws, rounds, +## consumption) is a later phase. Right now we just want to prove drag-and-drop +## works. @export var drop_catcher: Control -@export var pieces_per_round := 3 + +@export_group("Layout") @export var piece_size := Vector2(90, 110) @export var spacing := 24.0 @export var bottom_margin := 30.0 +@export_group("Behaviour") +## If false, pieces stay in the tray after a successful drop. Useful while +## testing; set true when the real economy lands. +@export var consume_on_use := false + +const SectorSwatch := preload("res://core/ui/sector_swatch.gd") + var _pieces: Array[Control] = [] func _ready() -> void: + # The tray itself must not eat mouse events — only its pieces should. mouse_filter = Control.MOUSE_FILTER_IGNORE - spawn_round() - -func spawn_round() -> void: - _clear_pieces() - - # ModRegistry is the autoload. (Was "ModifierCatalog" — renamed.) - var choices: Array = ModRegistry.pick_random(pieces_per_round) - if choices.is_empty(): - push_warning("ModifierTray: registry returned no modifiers.") + if drop_catcher == null: + push_error("ModifierTray: `drop_catcher` export is not set.") return - for mod in choices: - var piece := Control.new() - piece.set_script(SectorSwatch) - piece.custom_minimum_size = piece_size - piece.size = piece_size - piece.modifier = mod - piece.paint_texture = mod.texture - piece.drop_catcher = drop_catcher - piece.tray = self - add_child(piece) - _pieces.append(piece) - - _layout_pieces() + spawn_all() -func _layout_pieces() -> void: +## Spawn one piece per registered modifier. +func spawn_all() -> void: + _clear_pieces() + + var mods: Array[Modifier] = ModRegistry.modifiers + if mods.is_empty(): + push_warning("ModifierTray: registry has no modifiers. Nothing to spawn.") + return + + for mod in mods: + _spawn_piece(mod) + + _layout() + + +func _spawn_piece(mod: Modifier) -> void: + var piece := Control.new() + piece.set_script(SectorSwatch) + piece.custom_minimum_size = piece_size + piece.size = piece_size + piece.modifier = mod + piece.paint_texture = mod.texture + piece.drop_catcher = drop_catcher + piece.tray = self + add_child(piece) + _pieces.append(piece) + + +func _layout() -> void: var count := _pieces.size() if count == 0: return @@ -57,9 +77,14 @@ func _layout_pieces() -> void: _pieces[i].position = Vector2(start_x + i * (piece_size.x + spacing), y) -## Called by a piece when it lands successfully — the whole set is spent. -func consume() -> void: - _clear_pieces() +## Called by a piece after a successful drop. +func on_piece_used(piece: Control) -> void: + if not consume_on_use: + return # piece snaps home; it's reusable + + _pieces.erase(piece) + piece.queue_free() + _layout() func _clear_pieces() -> void: @@ -69,7 +94,6 @@ func _clear_pieces() -> void: _pieces.clear() -# TESTING ONLY — SPACE rolls a new set. -func _unhandled_input(event: InputEvent) -> void: - if event is InputEventKey and event.pressed and event.keycode == KEY_SPACE: - spawn_round() +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + _layout() diff --git a/game/core/ui/sector_swatch.gd b/game/core/ui/sector_swatch.gd index 5c1dc82..f451344 100644 --- a/game/core/ui/sector_swatch.gd +++ b/game/core/ui/sector_swatch.gd @@ -1,10 +1,10 @@ extends Control -## A draggable modifier piece in the tray. Follows the cursor directly (no -## ghost). On release over the board it asks DropCatcher to apply itself. +## A draggable modifier piece. Follows the cursor directly — no ghost, no +## Godot drag-and-drop API (which fights with 3D raycasting). ## -## Was TrapezoidSwatch.gd — renamed because modifiers now attach to whole -## sectors, not individual segments. +## While dragging it asks the DropCatcher which slot is under the cursor, so +## the player can SEE what they're about to hit before committing. @export var paint_texture: Texture2D: set(value): @@ -13,7 +13,7 @@ extends Control @export_range(0.0, 1.0) var top_width_ratio := 0.55 -# Set by the tray when it spawns this piece. +# Injected by the tray on spawn. var drop_catcher: Control var tray: Node var modifier: Modifier @@ -21,6 +21,7 @@ var modifier: Modifier var _dragging := false var _home_position := Vector2.ZERO var _drag_offset := Vector2.ZERO +var _hovered_slot: StringName = &"" func _ready() -> void: @@ -50,9 +51,17 @@ func _draw() -> void: else: draw_colored_polygon(points, Color(0.8, 0.8, 0.8)) + # Outline turns green when hovering a valid slot — the player's only cue + # that the drop will land. + var outline_col := Color(0, 0, 0, 0.6) + var outline_w := 2.0 + if _dragging and _hovered_slot != &"": + outline_col = Color(0.3, 1.0, 0.4, 0.9) + outline_w = 4.0 + var outline := points outline.append(points[0]) - draw_polyline(outline, Color(0, 0, 0, 0.6), 2.0) + draw_polyline(outline, outline_col, outline_w) func _gui_input(event: InputEvent) -> void: @@ -67,6 +76,7 @@ func _start_drag() -> void: _dragging = true _home_position = global_position _drag_offset = global_position - get_global_mouse_position() + # top_level lets us move by global_position without reparenting. # Reparenting mid-drag breaks the release event. top_level = true @@ -77,31 +87,39 @@ func _start_drag() -> void: func _end_drag() -> void: if not _dragging: return + _dragging = false + _hovered_slot = &"" modulate.a = 1.0 z_index = 0 var applied := false if drop_catcher == null: - push_error("SectorSwatch has no drop_catcher (tray export not set?).") - elif not drop_catcher.has_method(&"apply_modifier_at_screen_pos"): - push_error("drop_catcher has no apply_modifier_at_screen_pos method.") + push_error("SectorSwatch has no drop_catcher.") else: applied = drop_catcher.apply_modifier_at_screen_pos( get_global_mouse_position(), modifier ) - if applied: - if tray != null and tray.has_method(&"consume"): - tray.consume() - else: - queue_free() - else: - # Missed — snap home. - top_level = false - global_position = _home_position + # Always snap home. The tray decides whether to consume the piece. + top_level = false + global_position = _home_position + queue_redraw() + + if applied and tray != null and tray.has_method(&"on_piece_used"): + tray.on_piece_used(self) func _process(_delta: float) -> void: - if _dragging: - global_position = get_global_mouse_position() + _drag_offset + if not _dragging: + return + + var mouse := get_global_mouse_position() + global_position = mouse + _drag_offset + + # Live hover feedback — which slot would this land on right now? + if drop_catcher != null and drop_catcher.has_method(&"hover"): + var slot: StringName = drop_catcher.hover(mouse) + if slot != _hovered_slot: + _hovered_slot = slot + queue_redraw() diff --git a/game/project.godot b/game/project.godot index 1efb211..7d60299 100644 --- a/game/project.godot +++ b/game/project.godot @@ -25,7 +25,7 @@ ModRegistry="*uid://btm4dwci50xmu" [display] -window/stretch/mode="viewport" +window/stretch/mode="canvas_items" window/stretch/aspect="expand" [filesystem] @@ -53,3 +53,4 @@ restart={ [rendering] renderer/rendering_method="mobile" +anti_aliasing/quality/msaa_3d=2 diff --git a/game/scenes/main.tscn b/game/scenes/main.tscn index 685c8c9..073a3ec 100644 --- a/game/scenes/main.tscn +++ b/game/scenes/main.tscn @@ -27,17 +27,21 @@ transform = Transform3D(1, 0, 0, 0, 0.83676445, 0.5475633, 0, -0.54756325, 0.836 [node name="DropCatcher" type="Control" parent="UI" unique_id=1874461265 node_paths=PackedStringArray("camera", "dartboard")] layout_mode = 3 -anchors_preset = 0 -offset_right = 40.0 -offset_bottom = 40.0 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 script = ExtResource("5_o6xl0") camera = NodePath("../../Camera3D") dartboard = NodePath("../../Dartboard") [node name="ModifierTray" type="Control" parent="UI" unique_id=1639987503 node_paths=PackedStringArray("drop_catcher")] layout_mode = 3 -anchors_preset = 0 -offset_right = 40.0 -offset_bottom = 40.0 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 script = ExtResource("6_tipki") drop_catcher = NodePath("../DropCatcher")