Fix sector drag and drop

This commit is contained in:
Johan Sandoval
2026-07-15 20:40:43 -05:00
parent 69f2028ce3
commit c221ec6c2a
12 changed files with 564 additions and 345 deletions
+6
View File
@@ -40,3 +40,9 @@
# ─── Don't diff these ────────────────────────────────────────────── # ─── Don't diff these ──────────────────────────────────────────────
*.import -diff *.import -diff
*.uid -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
+79 -48
View File
@@ -3,24 +3,19 @@
A 3D darts game where the core loop is **dragging modifiers onto board sectors** to create 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. 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 Built in **Godot 4.7**. Modding is a first-class concern, not an afterthought — our own content
content ships through the same loader community mods will use. ships through the same loader community mods will use.
--- ---
## ⚠️ Install `git-lfs` BEFORE you clone ## ⚠️ Install `git-lfs` BEFORE you clone
Clone without it and you'll get **130-byte text pointers** where your textures and models 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 should be. Godot will throw import errors that look completely baffling and you will lose an
an hour figuring out why. hour.
**NixOS**`configuration.nix`: **NixOS**Home Manager (also writes the LFS filter config, so a fresh machine is correct with
```nix no imperative step):
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):
```nix ```nix
programs.git = { programs.git = {
enable = true; 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` **Debian/Ubuntu**`sudo apt install git-lfs`
**macOS**`brew install git-lfs` **macOS**`brew install git-lfs`
@@ -38,19 +38,28 @@ git lfs install
--- ---
## Setup ## Clone
```bash ```bash
git clone <gitea-url>/DartGame.git git clone ssh://git@gitea.lilpenn.org:30009/ced/DartGame.git
cd DartGame cd DartGame
git lfs pull
``` ```
**Verify LFS worked.** Open `game/core/board/dartboard.glb` in a text editor. If it's > **Note the `git@`.** Without it, SSH tries to log in as your local username and you get
readable text starting with `version https://git-lfs...`, LFS did **not** run. Stop and fix > `Permission denied (publickey)`. Gitea's SSH user is always `git`.
it before opening the project.
### 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 ```bash
# GDScript formatter + linter # GDScript formatter + linter
@@ -60,9 +69,7 @@ nix-shell -p gdtoolkit_4 # or: pipx install "gdtoolkit==4.*"
pre-commit install pre-commit install
``` ```
### Open the project Then point Godot 4 at `game/project.godot`.
Point Godot 4 at `game/project.godot`.
--- ---
@@ -72,18 +79,19 @@ Point Godot 4 at `game/project.godot`.
|---|---|---| |---|---|---|
| `.blend`, `.kra`, `.psd` | ✅ | ❌ **never** | | `.blend`, `.kra`, `.psd` | ✅ | ❌ **never** |
| `.glb`, `.png` (exported) | ❌ | ✅ (LFS) | | `.glb`, `.png` (exported) | ❌ | ✅ (LFS) |
| `.res` (collision shapes) | ❌ | ✅ (LFS) |
| Code, scenes, `.tres` | ❌ | ✅ | | Code, scenes, `.tres` | ❌ | ✅ |
| Moodboards, references | ✅ | ❌ | | Moodboards, references | ✅ | ❌ |
| **Documentation** | ✅ | ❌ |
Source files live in the **company Nextcloud share**, not here. They churn constantly, never 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 merge, and 99% of their revisions have no corresponding code change — they'd bloat LFS history
history forever, on every clone. forever, on every clone.
Exported artifacts **do** live here, because they must version in lockstep with the code Exported artifacts **do** live here, because they must version in lockstep with the code that
that references them. If a UV layout changes and a script needs updating, those belong in references them. If a UV layout changes and a script needs updating, those belong in one commit.
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/ game/
├── core/ ← THE ENGINE. Contains zero game content. Ever. ├── core/ ← THE ENGINE. Contains zero game content. Ever.
│ ├── board/ dartboard scene, mesh, sector logic │ ├── board/ dartboard scene, mesh, 21-slot logic, trimesh collision
│ ├── dart/ dart scene, physics │ ├── dart/ dart scene, drag/throw/flight physics
│ ├── scoring/ ScorePacket pipeline │ ├── modding/ mod registry, modifier base class
── game/ GameContext ← the mod API ── ui/ drop catcher, modifier tray, sector swatch
│ ├── modding/ loader, registry, base classes, primitives
│ ├── ui/ modifier tray, drag-and-drop
│ └── debug/ hit_simulator.tscn ← you'll use this daily
├── content/official/ ← OUR CONTENT, loaded as a mod like any other ├── content/official/ ← OUR CONTENT, loaded as a mod like any other
│ ├── skins/ │ ├── mod.json
│ ├── modifiers/ │ ├── skins/ bulls/ · sectors/ · flat_tire/
│ └── modes/ │ └── modifiers/ (empty — Phase 5)
└── shared/ fonts, shaders └── scenes/ main.tscn, camera_3d.gd
``` ```
### The two rules ### 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 ## Docs
Full documentation lives in the Obsidian vault at Full documentation is an Obsidian vault at `Nextcloud/DartGame/documentation/`.
`Nextcloud/DartGame/documentation/`. Start with **Build Plan**.
**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. **Commits:** Conventional Commits.
``` ```
feat(modding): add ModRegistry namespaced lookup 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:** **Before you open a PR:**
- [ ] `gdformat --check game/` passes - [ ] `gdformat --check game/` passes
- [ ] `gdlint game/` passes - [ ] `gdlint game/` passes
- [ ] Nothing in `core/` references a specific content ID - [ ] 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 - [ ] If an asset changed: source updated in Nextcloud, `SOURCES.md` current
- [ ] `git lfs status` shows binaries as LFS
### ⚠️ Moving files ### ⚠️ Moving files
**Commit first.** Godot's refactoring is fragile. Move files from **inside the Godot **Commit first.** Godot's refactoring is fragile. Move files from **inside the Godot editor's
editor's FileSystem dock**, never your file manager, so references get fixed up. 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 ```bash
rm -rf game/.godot/ rm -rf game/.godot/
``` ```
then Project → Reload Current Project. If it's worse than that, `git restore .` — which only then Project → Reload Current Project. Worse than that? `git restore .` — which only works if
works if you committed first. you committed first.
--- ---
## Status ## 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.
+73 -36
View File
@@ -1,7 +1,7 @@
# Sources # Sources
Every binary artifact in this repo was exported from a source file in Nextcloud. This maps Every binary artifact in this repo was exported from a source file in Nextcloud. This maps one
one to the other. to the other.
**Nextcloud share:** `DartGame/sources/` **Nextcloud share:** `DartGame/sources/`
@@ -12,46 +12,80 @@ one to the other.
## Models ## Models
| Repo artifact | Nextcloud source | Last export | | Repo artifact | Nextcloud source |
|---|---|---| |---|---|
| `game/core/board/dartboard.glb` | `sources/dartboard/dartboard.blend` | 2026-07-11 | | `game/core/board/dartboard.glb` | `sources/dartboard/dartboard.blend` |
| `game/core/dart/dart.glb` | `sources/dart/dart.blend` | 2026-07-11 | | `game/core/dart/dart.glb` | `sources/dart/dart.blend` |
## 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 |
--- ---
## Fallback textures ## Fallback textures
Single default textures that ship with the mesh so the editor isn't pink. Not skins — these These live in `core/`, **not** `content/`. They're the defaults that ship with the mesh so the
live in `core/`, not `content/`. 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 | | Repo artifact | Nextcloud source |
|---|---| |---|---|
| `game/core/board/dartboard_default.png` | `sources/dartboard/textures/cork.kra` | | `game/core/board/dartboard_bulls.png` | `sources/dartboard/textures/default/bulls.kra` |
| `game/core/dart/dart_default.png` | `sources/dart/textures/dart_default.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 1. Export from the Nextcloud source
2. Drop the artifact into the repo path above 2. Drop the artifact into the repo path above
3. **Update the "Last export" date in this file** 3. Commit the artifact **and its `.import` file** together
4. Commit the artifact + this 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. If a row is missing, the artifact has no traceable source. Add it, or find out why it exists.
Binary file not shown.
@@ -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
@@ -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
+142 -36
View File
@@ -1,22 +1,29 @@
class_name Dartboard class_name Dartboard
extends StaticBody3D 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 ## Owns the face -> surface -> slot mapping, which is what makes hit detection
## survive the board moving, rotating, or having its sectors remapped. Ask the ## survive the board moving, rotating, or having its sectors remapped. Ask the
## geometry which sector was hit; don't calculate it from an angle. ## 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 signal slot_painted(slot: StringName)
## matter — we index by name).
const BULL_SLOT := &"bulls" const BULL_SLOT := &"bulls"
const FLAT_TIRE_SLOT := &"flat_tire"
const SECTOR_PREFIX := "sector_"
@onready var mesh_instance: MeshInstance3D = $Model/dartboard @onready var mesh_instance: MeshInstance3D = $Model/dartboard
## StringName -> surface index ## StringName -> surface index
var _slot_to_surface: Dictionary = {} var _slot_to_surface: Dictionary = {}
## global face index -> StringName ## global trimesh face index -> StringName
var _face_to_slot: Dictionary = {} var _face_to_slot: Dictionary = {}
## surface index -> [first_face, last_face], for debugging
var _surface_face_range: Dictionary = {}
var _outer_radius: float = 0.0 var _outer_radius: float = 0.0
@@ -32,13 +39,13 @@ func _ready() -> void:
func _index_slots() -> void: func _index_slots() -> void:
var mesh := mesh_instance.mesh var mesh := mesh_instance.mesh
if mesh == null: if mesh == null:
push_error("Dartboard has no mesh.") push_error("Dartboard has no mesh. Check the scene tree — expected $Model/dartboard.")
return return
for i in mesh.get_surface_count(): for i in mesh.get_surface_count():
var mat := mesh.surface_get_material(i) var mat := mesh.surface_get_material(i)
if mat == null: 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 continue
var slot := StringName(mat.resource_name) var slot := StringName(mat.resource_name)
@@ -46,21 +53,32 @@ func _index_slots() -> void:
push_warning("Surface %d's material has no name." % i) push_warning("Surface %d's material has no name." % i)
continue 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 _slot_to_surface[slot] = i
print("Dartboard: indexed %d slots." % _slot_to_surface.size()) # Sanity: we expect exactly 20 sectors.
if _slot_to_surface.size() != 21: var sectors := 0
push_warning("Expected 21 slots (20 sectors + bulls), got %d." % _slot_to_surface.size()) 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. ## Build a global-face-index -> slot map.
## ##
## The physics engine reports face_index as an index into the WHOLE trimesh, ## 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, ## The mesh stores faces per-surface. create_trimesh_shape() concatenates the
## accumulating a running face count, and record which range belongs to which. ## surfaces in order, so we can walk them accumulating a running face count.
## ##
## This assumes the ConcavePolygonShape3D was built from the mesh with surfaces ## ⚠️ This assumes surface order == trimesh face order. Verified by
## in the same order — which is what create_trimesh_shape() does. ## 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: func _index_faces() -> void:
var mesh := mesh_instance.mesh var mesh := mesh_instance.mesh
if mesh == null: if mesh == null:
@@ -75,8 +93,7 @@ func _index_faces() -> void:
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX] var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX] var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
# Triangle count: from the index buffer if present, else from vertices. var tri_count: int = (indices.size() / 3) if indices.size() > 0 else (verts.size() / 3)
var tri_count := (indices.size() / 3) if indices.size() > 0 else (verts.size() / 3)
var mat := mesh.surface_get_material(i) var mat := mesh.surface_get_material(i)
var slot := StringName(mat.resource_name) if mat else &"" var slot := StringName(mat.resource_name) if mat else &""
@@ -84,14 +101,12 @@ func _index_faces() -> void:
for f in tri_count: for f in tri_count:
_face_to_slot[running + f] = slot _face_to_slot[running + f] = slot
_surface_face_range[i] = [running, running + tri_count - 1]
running += tri_count running += tri_count
print("Dartboard: mapped %d faces." % _face_to_slot.size())
func _cache_radius() -> void: func _cache_radius() -> void:
var aabb := mesh_instance.get_aabb() 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] var sizes := [aabb.size.x, aabb.size.y, aabb.size.z]
sizes.sort() sizes.sort()
_outer_radius = sizes[2] * 0.5 # largest dimension = diameter _outer_radius = sizes[2] * 0.5 # largest dimension = diameter
@@ -99,29 +114,39 @@ func _cache_radius() -> void:
# --- Public ------------------------------------------------------------- # --- 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: func slot_for_face(face_index: int) -> StringName:
return _face_to_slot.get(face_index, &"") 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: func paint_slot(slot: StringName, texture: Texture2D) -> void:
if not _slot_to_surface.has(slot): if not _slot_to_surface.has(slot):
push_warning("No slot named '%s'." % slot) push_warning("No slot named '%s'." % slot)
return return
var surface: int = _slot_to_surface[slot] 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) var mat := mesh_instance.get_active_material(surface)
if mat == null: if mat == null:
push_warning("Slot '%s' has no material to duplicate." % slot)
return return
# Duplicate, or we mutate the shared material from the .glb — which leaks # ⚠️ 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. # 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 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_texture = texture
unique.albedo_color = Color.WHITE # don't tint the texture unique.albedo_color = Color.WHITE # don't tint the texture
mesh_instance.set_surface_override_material(surface, unique) mesh_instance.set_surface_override_material(surface, unique)
slot_painted.emit(slot)
## Reset a slot to its imported material. ## Reset a slot to its imported material.
func clear_slot(slot: StringName) -> void: 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) 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]: func get_slots() -> Array[StringName]:
var out: Array[StringName] = [] var out: Array[StringName] = []
out.assign(_slot_to_surface.keys()) out.assign(_slot_to_surface.keys())
return out return out
## Board radius in local units.
func outer_radius() -> float: func outer_radius() -> float:
return _outer_radius return _outer_radius
## Project a LOCAL point onto the board's face plane, returning 2D coords ## Project a LOCAL point onto the board's face plane.
## where +Y is "up" on the board (toward the 20). ## 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, ## Your board's AABB is (0.451, 0.451, 0.029) — thin on Z — so the disc lies in
## so a board modeled facing +Z in Blender usually lands facing +Z here, with ## the X/Y plane. This picks the two fat axes automatically.
## the disc in the X/Y plane. If sector detection is 90 degrees off, this is
## the function to fix.
func planar_coords(local_point: Vector3) -> Vector2: func planar_coords(local_point: Vector3) -> Vector2:
var aabb := mesh_instance.get_aabb() var aabb := mesh_instance.get_aabb()
var center := aabb.position + aabb.size * 0.5 var center := aabb.position + aabb.size * 0.5
# Identify the thin axis — that's the board's normal.
var s := aabb.size var s := aabb.size
if s.z <= s.x and s.z <= s.y: if s.z <= s.x and s.z <= s.y:
return Vector2(local_point.x - center.x, local_point.y - center.y) return Vector2(local_point.x - center.x, local_point.y - center.y)
elif s.y <= s.x and s.y <= s.z: 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) 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: func print_slots() -> void:
var mesh := mesh_instance.mesh 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(): for i in mesh.get_surface_count():
var mat := mesh.surface_get_material(i) var mat := mesh.surface_get_material(i)
print(" surface %2d: '%s'" % [i, mat.resource_name if mat else "(none)"]) 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(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size])
print("=======================") 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("===============================")
+97 -162
View File
@@ -1,208 +1,139 @@
extends Control extends Control
## Resolves a screen position (or a dart's world position) to a board sector, ## Screen position -> board slot. That's the whole job.
## and applies modifiers there.
## ##
## THE BOARD HAS 21 MATERIAL SLOTS: sector_1 .. sector_20, plus "bulls". ## Scoring lives in Phase 2's ScoringEngine. This resolves WHERE, not WHAT IT'S
## The bull slot covers inner AND outer bull — they move together. ## 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) ## No trigonometry. No calibration constants. Survives the board being rotated,
## Raycast returns face_index. Map face -> surface. The surface's material ## tilted, moved, or having its sectors remapped — because we're reading the
## name IS the sector. No trigonometry. ## geometry, not calculating from an assumed layout.
## 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.
## ##
## B) BY ANGLE (fallback) ## This is only possible because the board has a ConcavePolygonShape3D (trimesh)
## atan2 on the local hit point. Cheap, works with a box collider. ## collider. A box or cylinder gives you no face_index.
## Survives rotation (to_local undoes the transform) but NOT sector
## remapping — if Mjolnir makes every sector a 20, the angle still says signal slot_hovered(slot: StringName)
## "this is the 6". signal slot_dropped(slot: StringName, modifier: Modifier)
##
## Use A. It's the only one that supports a moving/mutating board, which is the
## whole point of the modifier system.
const RAY_LENGTH := 1000.0 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 camera: Camera3D
@export var dartboard: Dartboard @export var dartboard: Dartboard
@export_group("Resolution") @export_group("Debug")
@export var method: Method = Method.FACE_INDEX ## Print every hover. Noisy but useful when calibrating.
@export var log_hovers := false
@export_group("Angle fallback (only used if method = ANGLE)") ## slot -> Modifier
## 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
var applied: Dictionary = {} var applied: Dictionary = {}
var _last_hovered: StringName = &""
func _ready() -> void: 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 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 ------------------------------------------------------------- # --- Public -------------------------------------------------------------
## Drop a modifier at a screen position. True if it landed on the board. ## Which slot is under this screen position? &"" if the board isn't there.
func apply_modifier_at_screen_pos(screen_pos: Vector2, modifier: Modifier) -> bool: func slot_at_screen_pos(screen_pos: Vector2) -> StringName:
if modifier == null:
return false
if camera == null or dartboard == null: if camera == null or dartboard == null:
push_error("DropCatcher: camera and dartboard exports must be set.") return &""
return false
var hit := _raycast(screen_pos) var hit := _raycast(screen_pos)
if hit.is_empty(): 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 return false
var slot := _slot_from_hit(hit) var slot := slot_at_screen_pos(screen_pos)
if slot == &"": 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) dartboard.paint_slot(slot, modifier.texture)
applied[slot] = modifier 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 return true
## Score a dart that landed at a world point. ## Which slots can hold a modifier?
func score_dart(world_point: Vector3, face_index: int = -1) -> int: ##
var slot := _slot_from_world(world_point, face_index) ## Sectors: yes. Bulls: yes (it's a slot in its own right — the bull modifier
var ring := _ring_from_radius(_norm_radius(world_point)) ## affects inner and outer together). Flat tire: no — it's the outer band, not
var base := _base_score(slot, ring) ## a scoring region.
func _is_valid_target(slot: StringName) -> bool:
if applied.has(slot): return dartboard.is_sector(slot) or slot == Dartboard.BULL_SLOT
return (applied[slot] as Modifier).apply_score(base)
return base
## Full breakdown of a hit. This is what Phase 2's ScoringEngine will consume. func modifier_at(slot: StringName) -> Modifier:
func resolve_hit(world_point: Vector3, face_index: int = -1) -> Dictionary: return applied.get(slot)
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),
}
# --- Slot resolution ---------------------------------------------------- func clear(slot: StringName) -> void:
applied.erase(slot)
func _slot_from_hit(hit: Dictionary) -> StringName: dartboard.clear_slot(slot)
var point: Vector3 = hit.get("position", Vector3.ZERO)
var face: int = hit.get("face_index", -1)
return _slot_from_world(point, face)
func _slot_from_world(world_point: Vector3, face_index: int) -> StringName: func clear_all() -> void:
# The bull is a slot in its own right — check radius first, since the bull applied.clear()
# surface may be small enough that face lookup is fiddly at the very center. dartboard.clear_all_slots()
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)
## Fallback. Works under rotation, NOT under sector remapping. # --- Internals ----------------------------------------------------------
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 ------------------------------------------------------------
func _raycast(screen_pos: Vector2) -> Dictionary: func _raycast(screen_pos: Vector2) -> Dictionary:
var origin := camera.project_ray_origin(screen_pos) 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_areas = false
params.collide_with_bodies = true 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) return get_viewport().world_3d.direct_space_state.intersect_ray(params)
+49 -25
View File
@@ -1,34 +1,56 @@
extends Control extends Control
## Offers a set of random modifiers each round as draggable pieces. ## Spawns draggable modifier pieces along the bottom of the screen.
## Dropping one on the board applies it and consumes the whole set. ##
## For now it spawns ALL modifiers in the registry, one of each, and they never
const SectorSwatch := preload("res://core/ui/sector_swatch.gd") ## 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 drop_catcher: Control
@export var pieces_per_round := 3
@export_group("Layout")
@export var piece_size := Vector2(90, 110) @export var piece_size := Vector2(90, 110)
@export var spacing := 24.0 @export var spacing := 24.0
@export var bottom_margin := 30.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] = [] var _pieces: Array[Control] = []
func _ready() -> void: func _ready() -> void:
# The tray itself must not eat mouse events — only its pieces should.
mouse_filter = Control.MOUSE_FILTER_IGNORE mouse_filter = Control.MOUSE_FILTER_IGNORE
spawn_round()
if drop_catcher == null:
func spawn_round() -> void: push_error("ModifierTray: `drop_catcher` export is not set.")
_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.")
return return
for mod in choices: spawn_all()
## 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() var piece := Control.new()
piece.set_script(SectorSwatch) piece.set_script(SectorSwatch)
piece.custom_minimum_size = piece_size piece.custom_minimum_size = piece_size
@@ -40,10 +62,8 @@ func spawn_round() -> void:
add_child(piece) add_child(piece)
_pieces.append(piece) _pieces.append(piece)
_layout_pieces()
func _layout() -> void:
func _layout_pieces() -> void:
var count := _pieces.size() var count := _pieces.size()
if count == 0: if count == 0:
return return
@@ -57,9 +77,14 @@ func _layout_pieces() -> void:
_pieces[i].position = Vector2(start_x + i * (piece_size.x + spacing), y) _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. ## Called by a piece after a successful drop.
func consume() -> void: func on_piece_used(piece: Control) -> void:
_clear_pieces() if not consume_on_use:
return # piece snaps home; it's reusable
_pieces.erase(piece)
piece.queue_free()
_layout()
func _clear_pieces() -> void: func _clear_pieces() -> void:
@@ -69,7 +94,6 @@ func _clear_pieces() -> void:
_pieces.clear() _pieces.clear()
# TESTING ONLY — SPACE rolls a new set. func _notification(what: int) -> void:
func _unhandled_input(event: InputEvent) -> void: if what == NOTIFICATION_RESIZED:
if event is InputEventKey and event.pressed and event.keycode == KEY_SPACE: _layout()
spawn_round()
+36 -18
View File
@@ -1,10 +1,10 @@
extends Control extends Control
## A draggable modifier piece in the tray. Follows the cursor directly (no ## A draggable modifier piece. Follows the cursor directly — no ghost, no
## ghost). On release over the board it asks DropCatcher to apply itself. ## Godot drag-and-drop API (which fights with 3D raycasting).
## ##
## Was TrapezoidSwatch.gd — renamed because modifiers now attach to whole ## While dragging it asks the DropCatcher which slot is under the cursor, so
## sectors, not individual segments. ## the player can SEE what they're about to hit before committing.
@export var paint_texture: Texture2D: @export var paint_texture: Texture2D:
set(value): set(value):
@@ -13,7 +13,7 @@ extends Control
@export_range(0.0, 1.0) var top_width_ratio := 0.55 @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 drop_catcher: Control
var tray: Node var tray: Node
var modifier: Modifier var modifier: Modifier
@@ -21,6 +21,7 @@ var modifier: Modifier
var _dragging := false var _dragging := false
var _home_position := Vector2.ZERO var _home_position := Vector2.ZERO
var _drag_offset := Vector2.ZERO var _drag_offset := Vector2.ZERO
var _hovered_slot: StringName = &""
func _ready() -> void: func _ready() -> void:
@@ -50,9 +51,17 @@ func _draw() -> void:
else: else:
draw_colored_polygon(points, Color(0.8, 0.8, 0.8)) 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 var outline := points
outline.append(points[0]) 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: func _gui_input(event: InputEvent) -> void:
@@ -67,6 +76,7 @@ func _start_drag() -> void:
_dragging = true _dragging = true
_home_position = global_position _home_position = global_position
_drag_offset = global_position - get_global_mouse_position() _drag_offset = global_position - get_global_mouse_position()
# top_level lets us move by global_position without reparenting. # top_level lets us move by global_position without reparenting.
# Reparenting mid-drag breaks the release event. # Reparenting mid-drag breaks the release event.
top_level = true top_level = true
@@ -77,31 +87,39 @@ func _start_drag() -> void:
func _end_drag() -> void: func _end_drag() -> void:
if not _dragging: if not _dragging:
return return
_dragging = false _dragging = false
_hovered_slot = &""
modulate.a = 1.0 modulate.a = 1.0
z_index = 0 z_index = 0
var applied := false var applied := false
if drop_catcher == null: if drop_catcher == null:
push_error("SectorSwatch has no drop_catcher (tray export not set?).") push_error("SectorSwatch has no drop_catcher.")
elif not drop_catcher.has_method(&"apply_modifier_at_screen_pos"):
push_error("drop_catcher has no apply_modifier_at_screen_pos method.")
else: else:
applied = drop_catcher.apply_modifier_at_screen_pos( applied = drop_catcher.apply_modifier_at_screen_pos(
get_global_mouse_position(), modifier get_global_mouse_position(), modifier
) )
if applied: # Always snap home. The tray decides whether to consume the piece.
if tray != null and tray.has_method(&"consume"):
tray.consume()
else:
queue_free()
else:
# Missed — snap home.
top_level = false top_level = false
global_position = _home_position 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: func _process(_delta: float) -> void:
if _dragging: if not _dragging:
global_position = get_global_mouse_position() + _drag_offset 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()
+2 -1
View File
@@ -25,7 +25,7 @@ ModRegistry="*uid://btm4dwci50xmu"
[display] [display]
window/stretch/mode="viewport" window/stretch/mode="canvas_items"
window/stretch/aspect="expand" window/stretch/aspect="expand"
[filesystem] [filesystem]
@@ -53,3 +53,4 @@ restart={
[rendering] [rendering]
renderer/rendering_method="mobile" renderer/rendering_method="mobile"
anti_aliasing/quality/msaa_3d=2
+10 -6
View File
@@ -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")] [node name="DropCatcher" type="Control" parent="UI" unique_id=1874461265 node_paths=PackedStringArray("camera", "dartboard")]
layout_mode = 3 layout_mode = 3
anchors_preset = 0 anchors_preset = 15
offset_right = 40.0 anchor_right = 1.0
offset_bottom = 40.0 anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("5_o6xl0") script = ExtResource("5_o6xl0")
camera = NodePath("../../Camera3D") camera = NodePath("../../Camera3D")
dartboard = NodePath("../../Dartboard") dartboard = NodePath("../../Dartboard")
[node name="ModifierTray" type="Control" parent="UI" unique_id=1639987503 node_paths=PackedStringArray("drop_catcher")] [node name="ModifierTray" type="Control" parent="UI" unique_id=1639987503 node_paths=PackedStringArray("drop_catcher")]
layout_mode = 3 layout_mode = 3
anchors_preset = 0 anchors_preset = 15
offset_right = 40.0 anchor_right = 1.0
offset_bottom = 40.0 anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("6_tipki") script = ExtResource("6_tipki")
drop_catcher = NodePath("../DropCatcher") drop_catcher = NodePath("../DropCatcher")