Fix sector drag and drop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <gitea-url>/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.
|
||||
|
||||
+73
-36
@@ -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.
|
||||
|
||||
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
@@ -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)"])
|
||||
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("=======================")
|
||||
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
@@ -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)
|
||||
|
||||
@@ -1,34 +1,56 @@
|
||||
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:
|
||||
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()
|
||||
piece.set_script(SectorSwatch)
|
||||
piece.custom_minimum_size = piece_size
|
||||
@@ -40,10 +62,8 @@ func spawn_round() -> void:
|
||||
add_child(piece)
|
||||
_pieces.append(piece)
|
||||
|
||||
_layout_pieces()
|
||||
|
||||
|
||||
func _layout_pieces() -> void:
|
||||
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()
|
||||
|
||||
@@ -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.
|
||||
# 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()
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+10
-6
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user