Author SHA256 Message Date
Johan Sandoval c221ec6c2a Fix sector drag and drop 2026-07-15 20:40:43 -05:00
Johan Sandoval 69f2028ce3 Restructure into core/ and content/official/ 2026-07-12 01:12:27 -05:00
Johan Sandoval e65c48c1e3 Add LFS rules and gitignore 2026-07-12 01:08:41 -05:00
Johan Sandoval a2331c3b94 Dartboard UV Restructure
Restructured the dartboard to use sector based UVs rather than
individual segments.
2026-07-11 09:37:39 -05:00
Johan Sandoval 5e2c395a88 Merge branch 'main' into dart-models 2026-07-07 10:25:24 -05:00
Johan Sandoval 3f5d30c490 Blend file updates 2026-07-07 10:14:32 -05:00
Johan Sandoval a237d0c887 Dartboard prototype with working raycast 2026-06-30 19:18:45 -05:00
Johan Sandoval b3836e266f Created basic default dart 2026-06-20 16:19:46 -05:00
76 changed files with 2137 additions and 358 deletions
+47 -1
View File
@@ -1,2 +1,48 @@
# Normalize EOL for all files that Git considers text files. # ─── Binary assets → LFS ───────────────────────────────────────────
*.glb filter=lfs diff=lfs merge=lfs -text
*.gltf filter=lfs diff=lfs merge=lfs -text
*.fbx filter=lfs diff=lfs merge=lfs -text
*.obj filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.webp filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
*.svg -filter -diff -merge text
*.ogg filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
*.mp3 filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
# Godot binary resources (collision shapes, baked data)
*.res filter=lfs diff=lfs merge=lfs -text
*.scn filter=lfs diff=lfs merge=lfs -text
# ─── Line endings ──────────────────────────────────────────────────
* text=auto eol=lf * text=auto eol=lf
*.gd text eol=lf
*.tscn text eol=lf
*.tres text eol=lf
*.godot text eol=lf
*.cfg text eol=lf
*.import text eol=lf
*.uid text eol=lf
*.json text eol=lf
*.md text eol=lf
*.sh text eol=lf
# ─── 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
+43 -3
View File
@@ -1,3 +1,43 @@
# Godot 4+ specific ignores # ─── Godot ─────────────────────────────────────────────────────────
.godot/ # Import cache. Regenerates on open. NEVER commit.
/android/ game/.godot/
# Orphaned temp files from interrupted imports
*.import-*
# Android build template
game/android/
# ─── Build output ──────────────────────────────────────────────────
/export/
/build/
*.pck
*.exe
*.x86_64
*.dmg
*.apk
*.aab
# ─── DCC backups (should live in Nextcloud, not here) ──────────────
*.blend[0-9]
*.blend[0-9][0-9]
*.kra~
*.krz
*.psb
*.tmp
# ─── Editors ───────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
# ─── OS ────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ─── Nextcloud sync artifacts ──────────────────────────────────────
.sync_*.db
*.~lock*
+191
View File
@@ -0,0 +1,191 @@
# DartGame
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.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.
**NixOS** — Home Manager (also writes the LFS filter config, so a fresh machine is correct with
no imperative step):
```nix
programs.git = {
enable = true;
lfs.enable = true;
};
```
Or system-wide:
```nix
environment.systemPackages = with pkgs; [ git git-lfs ];
```
**Debian/Ubuntu**`sudo apt install git-lfs`
**macOS**`brew install git-lfs`
Then, once per machine:
```bash
git lfs install
```
---
## Clone
```bash
git clone ssh://git@gitea.lilpenn.org:30009/ced/DartGame.git
cd DartGame
```
> **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`.
### 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
nix-shell -p gdtoolkit_4 # or: pipx install "gdtoolkit==4.*"
# Pre-commit hooks
pre-commit install
```
Then point Godot 4 at `game/project.godot`.
---
## Where things live
| | Nextcloud | This repo |
|---|---|---|
| `.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.
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`](SOURCES.md) maps each repo artifact back to its Nextcloud source.
---
## Layout
```
game/
├── core/ ← THE ENGINE. Contains zero game content. Ever.
│ ├── 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
│ ├── mod.json
│ ├── skins/ bulls/ · sectors/ · flat_tire/
│ └── modifiers/ (empty — Phase 5)
└── scenes/ main.tscn, camera_3d.gd
```
### The two rules
**1. Nothing in `core/` knows about any specific content.**
About to write `if modifier.id == "mjolnir"` in `core/`? Stop — you need a new primitive.
**2. `content/official/` must be deletable.**
Delete it, and the game boots to an empty state without crashing. That's the proof the mod
pipeline is real. If it can't, we don't have a mod system — we have a plugin folder.
---
## 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 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. |
---
## Contributing
**Branches:** `feat/`, `fix/`, `chore/` off `main`. `main` is protected and always runnable.
**Commits:** Conventional Commits.
```
feat(modding): add ModRegistry namespaced lookup
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
- [ ] 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.
If things break after a move or branch switch:
```bash
rm -rf game/.godot/
```
then Project → Reload Current Project. Worse than that? `git restore .` — which only works if
you committed first.
---
## Status
**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.
+102
View File
@@ -0,0 +1,102 @@
# Sources
Every binary artifact in this repo was exported from a source file in Nextcloud. This maps one
to the other.
**Nextcloud share:** `DartGame/sources/`
> ⚠️ **Keep this current.** It is the only thing linking the two halves of the project. A
> programmer looking at `dartboard.glb` has no other way to find out where it came from.
---
## Models
| Repo artifact | Nextcloud source |
|---|---|
| `game/core/board/dartboard.glb` | `sources/dartboard/dartboard.blend` |
| `game/core/dart/dart.glb` | `sources/dart/dart.blend` |
---
## Fallback textures
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_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` |
---
## When you re-export
1. Export from the Nextcloud source
2. Drop the artifact into the repo path above
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.
-23
View File
@@ -1,23 +0,0 @@
[gd_scene format=3 uid="uid://bswbcehkkqbk0"]
[ext_resource type="Script" uid="uid://8kk531f88oqw" path="res://Scenes/DartPhysics.gd" id="1_s1kro"]
[ext_resource type="PackedScene" uid="uid://cjq45anerhxnk" path="res://models/Dart.glb" id="2_mlvfh"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_xxti7"]
radius = 0.27441406
height = 2.3862305
[node name="Dart" type="Node3D" unique_id=367982453]
[node name="RigidBody3D" type="RigidBody3D" parent="." unique_id=1993396351]
transform = Transform3D(1, 0, 0, 0, 1.0000001, 0, 0, 0, 1.0000001, 0, 0, 0)
center_of_mass_mode = 1
script = ExtResource("1_s1kro")
angle_of_plane = -25.0
[node name="CollisionShape3D" type="CollisionShape3D" parent="RigidBody3D" unique_id=1030334431]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.10033989, 0)
shape = SubResource("CapsuleShape3D_xxti7")
[node name="Dart2" parent="RigidBody3D" unique_id=1655536183 instance=ExtResource("2_mlvfh")]
transform = Transform3D(0.4, 0, 0, 0, 0.39999995, 0, 0, 0, 0.39999995, 0, -1.4526575, 0.015561234)
-161
View File
@@ -1,161 +0,0 @@
extends RigidBody3D
class_name Dart
#states
var in_flight: bool = false
var dragging: bool = false
#drag
var drag_plane: Plane
var drag_offset: Vector3 = Vector3.ZERO
var target_pos: Vector3 = Vector3.ZERO
# velocity tracking for release throw
var position_history: Array[Vector3] = []
var time_history: Array[float] = []
const HISTORY_LENGTH = 6
# drag "stiffness" tuning
@export var drag_follow_speed: float = 20.0 # how fast it chases the mouse
@export var max_drag_speed: float = 42.0 # clamp so it doesn't go insane
@export_range(-180,180) var angle_of_plane: float = 15.0
@export var throw_force_multiplier: float = 0.8 # scale raw throw velocity down if it's too fast
@export var max_throw_speed: float = 42.0 # hard cap so fast flicks don't kill the arc
@export var flight_gravity_scale: float = 4.0 # higher = more visible arc
@export var nose_align_speed: float = 1.0 # how fast dart rotates to face velocity
@export var dart_forward_axis: Vector3 = Vector3.FORWARD
func _ready():
freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
freeze = true
func start_drag(camera: Camera3D, mouse_pos: Vector2, hit_point: Vector3):
dragging = true
in_flight = false
freeze = false
# keep it physics-active — don't freeze
gravity_scale = 0.0 # optional: ignore gravity while held so it doesn't sag
linear_damp = 5.0 # optional: a bit of damping for stability while dragged
angular_damp = 10.0
#drag_plane = Plane(camera.global_transform.basis.z, hit_point)
var base_normal = camera.global_transform.basis.z
var tilt = base_normal.rotated(camera.global_transform.basis.x, deg_to_rad(angle_of_plane)) # tilt adjustable around camera's right axis
drag_plane = Plane(tilt.normalized(), hit_point)
drag_offset = global_position - hit_point
target_pos = global_position #fixes a bug where dart drops until mouse is moved.
position_history.clear()
time_history.clear()
func update_drag(camera: Camera3D, mouse_pos: Vector2):
if not dragging:
return
var ray_origin = camera.project_ray_origin(mouse_pos)
var ray_dir = camera.project_ray_normal(mouse_pos)
var intersection = drag_plane.intersects_ray(ray_origin, ray_dir)
if intersection:
target_pos = intersection + drag_offset
func _align_dart_to_velocity(delta: float):
if linear_velocity.length() < 0.5:
return
var vel_dir = linear_velocity.normalized()
var current_forward = global_transform.basis * dart_forward_axis
var rotation_axis = current_forward.cross(vel_dir)
if rotation_axis.length() < 0.001:
return
rotation_axis = rotation_axis.normalized()
var angle = current_forward.angle_to(vel_dir)
var rotate_amount = min(angle, nose_align_speed * delta)
global_transform.basis = global_transform.basis.rotated(rotation_axis, rotate_amount)
global_transform.basis = global_transform.basis.orthonormalized()
func _physics_process(delta):
if dragging:
# chase the target position with velocity instead of teleporting
var to_target = target_pos - global_position
var desired_velocity = to_target * drag_follow_speed
if desired_velocity.length() > max_drag_speed:
desired_velocity = desired_velocity.normalized() * max_drag_speed
linear_velocity = desired_velocity
angular_velocity = angular_velocity.lerp(Vector3.ZERO, 0.2) # optional: damp spin while dragging
# track actual position for momentum calc on release
position_history.append(global_position)
time_history.append(Time.get_ticks_msec() / 1000.0)
if position_history.size() > HISTORY_LENGTH:
position_history.pop_front()
time_history.pop_front()
elif in_flight:
# Debug: confirm Y velocity is dropping (becoming negative) each frame
print("local +X world dir: ", global_transform.basis.x)
print("local +Y world dir: ", global_transform.basis.y)
print("local +Z world dir: ", global_transform.basis.z)
print("velocity dir: ", linear_velocity.normalized())
# Align dart nose to face velocity direction (tip points the way it's flying)
if linear_velocity.length() > 0.5:
_align_dart_to_velocity(delta)
else:
in_flight = false
angular_velocity = Vector3.ZERO
# If your dart tip points along +Z, use global_position + linear_velocity
# If it points along -Z, negate the direction below
var look_target = global_position + linear_velocity.normalized()
var up_ref = Vector3.UP
# Avoid gimbal lock when flying straight up/down
if abs(linear_velocity.normalized().dot(Vector3.UP)) > 0.99:
up_ref = Vector3.FORWARD
var target_basis = Basis.looking_at(linear_velocity.normalized(), up_ref)
global_transform.basis = global_transform.basis.slerp(
target_basis, nose_align_speed * delta
)
func release_drag():
if not dragging:
return
dragging = false
in_flight = true
gravity_scale = flight_gravity_scale
angular_damp = 0.0
linear_damp = 0.0 # reset to whatever your normal dart damping is
var velocity = calculate_throw_velocity() * throw_force_multiplier
if velocity.length() > max_throw_speed:
velocity = velocity.normalized() * max_throw_speed
linear_velocity = velocity
# angular_velocity left as-is from physics, or compute your own spin here
print("throw speed: %.2f" % velocity.length())
print("gravity_scale: ", gravity_scale)
print("linear_damp: ", linear_damp)
func calculate_throw_velocity() -> Vector3:
if position_history.size() < 2:
return linear_velocity # fallback to whatever physics already had
var start_pos = position_history[0]
var end_pos = position_history[position_history.size() - 1]
var start_time = time_history[0]
var end_time = time_history[time_history.size() - 1]
var dt = end_time - start_time
if dt <= 0.0:
return linear_velocity
return (end_pos - start_pos) / dt
-1
View File
@@ -1 +0,0 @@
uid://8kk531f88oqw
-27
View File
@@ -1,27 +0,0 @@
extends Camera3D
var held_dart: Dart = null
func _unhandled_input(event):
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
try_grab_dart(event.position)
else:
if held_dart:
held_dart.release_drag()
held_dart = null
elif event is InputEventMouseMotion and held_dart:
held_dart.update_drag(self, event.position)
func try_grab_dart(mouse_pos: Vector2):
var ray_origin = project_ray_origin(mouse_pos)
var ray_dir = project_ray_normal(mouse_pos)
var ray_end = ray_origin + ray_dir * 1000.0
var space_state = get_world_3d().direct_space_state
var query = PhysicsRayQueryParameters3D.create(ray_origin, ray_end)
var result = space_state.intersect_ray(query)
if result and result.collider is Dart:
held_dart = result.collider
held_dart.start_drag(self, mouse_pos, result.position)
-5
View File
@@ -1,5 +0,0 @@
extends Node3D
func _unhandled_input(event):
if event.is_action_pressed("restart"):
get_tree().reload_current_scene()
-40
View File
@@ -1,40 +0,0 @@
[gd_scene format=3 uid="uid://doh7p84eej78c"]
[ext_resource type="Script" uid="uid://geqmah8kbko0" path="res://Scenes/main.gd" id="1_8gbba"]
[ext_resource type="Script" uid="uid://ds3uiri3oc05v" path="res://Scenes/camera_3d.gd" id="1_bo1nx"]
[ext_resource type="PackedScene" uid="uid://bswbcehkkqbk0" path="res://Scenes/Dart.tscn" id="1_jjgbg"]
[sub_resource type="BoxShape3D" id="BoxShape3D_qu08v"]
size = Vector3(1, 0.01, 1)
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_6q475"]
albedo_color = Color(0, 0.42745098, 0.05490196, 1)
[sub_resource type="BoxMesh" id="BoxMesh_xxti7"]
material = SubResource("StandardMaterial3D_6q475")
size = Vector3(1, 0.01, 1)
[node name="Main" type="Node3D" unique_id=819165214]
script = ExtResource("1_8gbba")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1570426839]
transform = Transform3D(-1, 2.4096988e-08, -8.4036174e-08, 0, 0.9612618, 0.2756374, 8.742278e-08, 0.2756374, -0.9612618, 0, 5.0674033, -6.36445)
fov = 90.0
script = ExtResource("1_bo1nx")
[node name="Dart" parent="." unique_id=367982453 instance=ExtResource("1_jjgbg")]
transform = Transform3D(1, 0, 0, 0, 0.8363807, -0.5481493, 0, 0.5481492, 0.8363808, 0, 4.3246593, 0)
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1891469178]
transform = Transform3D(-0.789482, 0.24223976, -0.56394875, -0.56595325, 0.06825608, 0.821607, 0.23751883, 0.9678125, 0.083209395, -46.816067, 15.942318, -50.370865)
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=889230389]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 63.39778)
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=229129629]
transform = Transform3D(100, 0, 0, 0, 100, 0, 0, 0, 100, 0, 0, 0)
shape = SubResource("BoxShape3D_qu08v")
[node name="MeshInstance3D" type="MeshInstance3D" parent="StaticBody3D/CollisionShape3D" unique_id=849495197]
mesh = SubResource("BoxMesh_xxti7")
skeleton = NodePath("")
-15
View File
@@ -1,15 +0,0 @@
extends Camera3D
const RAY_LENGTH = 1000
func ray_cast():
var space_state = get_world_3d().direct_space_state
var cam = $"."
var mousepos = get_viewport().get_mouse_position()
var origin = cam.project_ray_origin(mousepos)
var end = origin + cam.project_ray_normal(mousepos) * RAY_LENGTH
var query = PhysicsRayQueryParameters3D.create(origin,end)
query.collide_with_areas = true
var result = space_state.intersect_ray(query)
result = result.get("collider")
return result
-1
View File
@@ -1 +0,0 @@
uid://ckbdoamrm313u
-50
View File
@@ -1,50 +0,0 @@
extends Node3D
var draggingCollider
var mousePosition
var doDrag = false
func _input(event):
var intersect
if event is InputEventMouse:
intersect = get_mouse_intersect(event.position)
if intersect: mousePosition = intersect.position
#snap on collider
#if intersect: mousePosition = intersect.collider.global_position
if event is InputEventMouseButton:
var leftButtonPressed = event.button_index == MOUSE_BUTTON_LEFT && event.pressed
var leftButtonReleased = event.button_index == MOUSE_BUTTON_LEFT && !event.pressed
if leftButtonReleased:
doDrag = false
drag_and_drop(intersect)
elif leftButtonPressed:
doDrag = true
drag_and_drop(intersect)
func _process(delta):
if draggingCollider:
draggingCollider.global_position = mousePosition
func drag_and_drop(intersect):
var canMove = intersect.collider in get_tree().get_nodes_in_group("Dart")
if !draggingCollider && doDrag && canMove:
draggingCollider = intersect.collider
elif draggingCollider:
draggingCollider = null
func get_mouse_intersect(mousePosition):
var currentCamera = get_viewport().get_camera_3d()
var params = PhysicsRayQueryParameters3D.new()
params.from = currentCamera.project_ray_origin(mousePosition)
params.to = currentCamera.project_position(mousePosition, 1000)
if draggingCollider: params.exclude = [draggingCollider]
var worldspace = get_world_3d().direct_space_state
var result = worldspace.intersect_ray(params)
return result
-16
View File
@@ -1,16 +0,0 @@
extends MeshInstance3D
@onready var camera = get_node("Camera3D")
@onready var area = get_node("../../../Dart")
func _input(event):
if Input.is_action_just_pressed("leftClick"):
print(camera.ray_cast().position)
if camera.ray_cast() != null and Input.is_action_pressed("midleclic"):
var mousepos = get_viewport().get_mouse_position()
var origin = camera.projectray_origin(mousepos)
var end = camera.project_ray_normal(mousepos)
var depth = origin.distence_to(area.global_position)
var final_position = origin + end * depth
area.global_position=final_position
-1
View File
@@ -1 +0,0 @@
uid://cc5yy1yc3j2uu
+9
View File
@@ -0,0 +1,9 @@
{
"id": "official",
"display_name": "DartGame",
"author": "Ced Games",
"version": "0.1.0",
"api_version": 1,
"depends": [],
"description": "The base game content."
}
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
Binary file not shown.
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bayaus0tgltb3"
path="res://.godot/imported/neon_hiero.png-401e600b4e3a81c5e49b654215a0e850.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://content/official/skins/bulls/neon_hiero.png"
dest_files=["res://.godot/imported/neon_hiero.png-401e600b4e3a81c5e49b654215a0e850.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
Binary file not shown.
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b672fbyuxxktw"
path="res://.godot/imported/ocean_fire.png-b1f0d5fea08640de091cb5cf724309e6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://content/official/skins/flat_tire/ocean_fire.png"
dest_files=["res://.godot/imported/ocean_fire.png-b1f0d5fea08640de091cb5cf724309e6.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
Binary file not shown.
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btg3hosfx1sdg"
path="res://.godot/imported/toon_purple.png-f1a192acdf1c762039bd65e86672f7e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://content/official/skins/flat_tire/toon_purple.png"
dest_files=["res://.godot/imported/toon_purple.png-f1a192acdf1c762039bd65e86672f7e4.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
Binary file not shown.
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://brqn2ve8pm3i8"
path="res://.godot/imported/neon_hiero_black.png-7709dfdedde53a261eb17c86736a737f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://content/official/skins/sectors/neon_hiero_black.png"
dest_files=["res://.godot/imported/neon_hiero_black.png-7709dfdedde53a261eb17c86736a737f.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
Binary file not shown.
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bataamlsyyup6"
path="res://.godot/imported/neon_hiero_white.png-c93ce5ab952a3d2618c297fc1cae11f4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://content/official/skins/sectors/neon_hiero_white.png"
dest_files=["res://.godot/imported/neon_hiero_white.png-c93ce5ab952a3d2618c297fc1cae11f4.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
+279
View File
@@ -0,0 +1,279 @@
class_name Dartboard
extends StaticBody3D
## 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.
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 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
func _ready() -> void:
_index_slots()
_index_faces()
_cache_radius()
# --- Indexing -----------------------------------------------------------
func _index_slots() -> void:
var mesh := mesh_instance.mesh
if mesh == null:
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 — it can't be hit or skinned." % i)
continue
var slot := StringName(mat.resource_name)
if slot == &"":
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
# 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.
## 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 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:
return
var running := 0
for i in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(i)
if arrays.is_empty():
continue
var indices: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
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 &""
for f in tri_count:
_face_to_slot[running + f] = slot
_surface_face_range[i] = [running, running + tri_count - 1]
running += tri_count
func _cache_radius() -> void:
var aabb := mesh_instance.get_aabb()
var sizes := [aabb.size.x, aabb.size.y, aabb.size.z]
sizes.sort()
_outer_radius = sizes[2] * 0.5 # largest dimension = diameter
# --- Public -------------------------------------------------------------
## 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.
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
# 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:
if not _slot_to_surface.has(slot):
return
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
func outer_radius() -> float:
return _outer_radius
## Project a LOCAL point onto the board's face plane.
## Returns 2D coords where +Y is "up" on the board.
##
## 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
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:
return Vector2(local_point.x - center.x, local_point.z - center.z)
else:
return Vector2(local_point.z - center.z, local_point.y - center.y)
## 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: %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)
var name_str: String = mat.resource_name if mat else "(none)"
var range_arr: Array = _surface_face_range.get(i, [-1, -1])
print(" surface %2d: %-12s faces %4d%4d" % [i, name_str, range_arr[0], range_arr[1]])
print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size])
print("=====================================")
## ⚠️ VERIFY THE CORE ASSUMPTION.
##
## Fires a ray at each sector's known centre and checks that face_index maps
## back to the right slot. If surface order != trimesh face order, this catches
## it — and the whole face-index approach is invalid until it's fixed.
##
## Call once from main.gd, read the output, then delete the call.
func debug_verify_faces(camera: Camera3D) -> void:
print("=== Face-index verification ===")
var space := get_world_3d().direct_space_state
var errors := 0
var checks := 0
# March a grid across the board and confirm every hit resolves to a slot
# that actually exists.
const STEPS := 24
for iy in STEPS:
for ix in STEPS:
var u := (float(ix) / (STEPS - 1)) * 2.0 - 1.0 # -1..1
var v := (float(iy) / (STEPS - 1)) * 2.0 - 1.0
# Skip outside the disc
if Vector2(u, v).length() > 0.95:
continue
# A point on the board's face, in local space
var local := Vector3(u * _outer_radius, v * _outer_radius, 0.0)
var world := to_global(local)
# Ray straight at it, from in front
var from := world + global_transform.basis.z * 0.5
var params := PhysicsRayQueryParameters3D.create(from, world - global_transform.basis.z * 0.1)
params.collide_with_bodies = true
var hit := space.intersect_ray(params)
if hit.is_empty():
continue
checks += 1
var face: int = hit.get("face_index", -1)
var slot := slot_for_face(face)
if slot == &"":
errors += 1
if errors <= 5:
print(" ✗ face %d -> no slot (local %.3f, %.3f)" % [face, local.x, local.y])
print(" %d rays hit, %d unmapped faces" % [checks, errors])
if errors == 0:
print(" ✓ Every hit face maps to a slot.")
else:
push_error("Face mapping is BROKEN. %d faces don't map." % errors)
print("===============================")
+1
View File
@@ -0,0 +1 @@
uid://cme5xqn2hlrlu
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://djr5vbnb652ax"
path="res://.godot/imported/dartboard.glb-8db0957180c245aae0575b1ad098f883.scn"
[deps]
source_file="res://core/board/dartboard.glb"
dest_files=["res://.godot/imported/dartboard.glb-8db0957180c245aae0575b1ad098f883.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
+27
View File
@@ -0,0 +1,27 @@
[gd_scene format=3 uid="uid://dojbd5wl7ntp0"]
[ext_resource type="Script" uid="uid://cme5xqn2hlrlu" path="res://core/board/dartboard.gd" id="1"]
[ext_resource type="PackedScene" uid="uid://djr5vbnb652ax" path="res://core/board/dartboard.glb" id="2"]
[ext_resource type="Shape3D" uid="uid://vr03k1qlo10y" path="res://core/board/dartboard_collision.res" id="3_6aacy"]
[ext_resource type="Shape3D" uid="uid://c0vqtoe8q7yly" path="res://core/board/spider_collision.res" id="4_khw4q"]
[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_uomu6"]
friction = 0.15
bounce = 0.6
[node name="Dartboard" type="StaticBody3D" unique_id=838863701]
script = ExtResource("1")
[node name="Model" parent="." unique_id=1556270899 instance=ExtResource("2")]
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1180985747]
shape = ExtResource("3_6aacy")
[node name="SpiderBody" type="StaticBody3D" parent="." unique_id=216948518]
collision_layer = 8
physics_material_override = SubResource("PhysicsMaterial_uomu6")
[node name="CollisionShape3D" type="CollisionShape3D" parent="SpiderBody" unique_id=1618627145]
shape = ExtResource("4_khw4q")
[editable path="Model"]
Binary file not shown.
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj6wb70lh5s4f"
path.s3tc="res://.godot/imported/dartboard_bulls.png-354417d2f083e713950eed8be5c54f73.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "ce1804f3e86fea26503097d5e278c919"
}
[deps]
source_file="res://core/board/dartboard_bulls.png"
dest_files=["res://.godot/imported/dartboard_bulls.png-354417d2f083e713950eed8be5c54f73.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://da2elnsphlc5i"
path.s3tc="res://.godot/imported/dartboard_flat_tire.png-97f4bd426c06f71b009cd942664be6fa.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "d24ebe44ff702bdbd5528c9d29427568"
}
[deps]
source_file="res://core/board/dartboard_flat_tire.png"
dest_files=["res://.godot/imported/dartboard_flat_tire.png-97f4bd426c06f71b009cd942664be6fa.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cjw6x7ig1ogbo"
path.s3tc="res://.godot/imported/dartboard_sector_black.png-39f98bf69c481faad6b8976a0994ecb5.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "831beecb2a48df4a1f6850e3e8c938dd"
}
[deps]
source_file="res://core/board/dartboard_sector_black.png"
dest_files=["res://.godot/imported/dartboard_sector_black.png-39f98bf69c481faad6b8976a0994ecb5.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cry28pcmt01gk"
path.s3tc="res://.godot/imported/dartboard_sector_white.png-6dbf28d122f3327d568bcbb29e7f70a0.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "aa25e4080cfa45ac8064cbf217904fb3"
}
[deps]
source_file="res://core/board/dartboard_sector_white.png"
dest_files=["res://.godot/imported/dartboard_sector_white.png-6dbf28d122f3327d568bcbb29e7f70a0.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b87cl0ullks7x"
path.s3tc="res://.godot/imported/dartboard_spider.png-c5fe87c929f66762aaaaefebd3359c9d.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "693560c0ab922fba23ec09a26ead7f48"
}
[deps]
source_file="res://core/board/dartboard_spider.png"
dest_files=["res://.godot/imported/dartboard_spider.png-c5fe87c929f66762aaaaefebd3359c9d.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
+192
View File
@@ -0,0 +1,192 @@
class_name Dart
extends RigidBody3D
## A single dart. Dragged from the tray, thrown at the board.
##
## Lifecycle: FROZEN -> DRAGGING -> IN_FLIGHT -> LANDED
## The scene root (main.gd) routes mouse input here; the dart does not
## hunt for the camera or raycast for itself.
signal landed(dart: Dart, hit_point: Vector3)
enum State { IDLE, DRAGGING, IN_FLIGHT, LANDED }
# --- Tuning -------------------------------------------------------------
@export_group("Drag")
## How hard the dart chases the cursor while held.
@export var drag_follow_speed: float = 20.0
## Clamp so a fast flick doesn't launch it across the room mid-drag.
@export var max_drag_speed: float = 42.0
## Tilt of the drag plane relative to the camera, in degrees.
@export_range(-180.0, 180.0) var drag_plane_angle: float = 15.0
@export var drag_linear_damp: float = 5.0
@export var drag_angular_damp: float = 10.0
@export_group("Throw")
@export var forward_impulse: float = 8.0
## Scale the raw flick velocity down. Raw mouse speed is usually too fast.
@export var throw_force_multiplier: float = 0.5
@export var max_throw_speed: float = 42.0
## Higher = more visible arc. Real gravity looks too floaty at dart scale.
@export var flight_gravity_scale: float = 2.0
@export_group("Flight")
## How fast the nose swings to face the direction of travel.
@export var nose_align_speed: float = 1.0
## Which local axis is the dart's tip. Check this against the model.
@export var forward_axis: Vector3 = Vector3.FORWARD
## Below this speed the dart is considered landed.
@export var landed_speed_threshold: float = 0.5
# --- State --------------------------------------------------------------
var state: State = State.IDLE
var _drag_plane: Plane
var _drag_offset: Vector3 = Vector3.ZERO
var _target_pos: Vector3 = Vector3.ZERO
## Sampled positions, used to compute throw velocity on release.
const HISTORY_LENGTH := 6
var _pos_history: Array[Vector3] = []
var _time_history: Array[float] = []
func _ready() -> void:
freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
freeze = true
add_to_group(&"dart")
# --- Public API (called by main.gd) --------------------------------------
func begin_drag(camera: Camera3D, hit_point: Vector3) -> void:
state = State.DRAGGING
freeze = false
gravity_scale = 0.0 # don't sag while held
linear_damp = drag_linear_damp
angular_damp = drag_angular_damp
# The plane the dart slides along while dragged. Tilted off the camera's
# view axis so pulling back also pulls the dart down, which feels natural.
var base_normal := camera.global_transform.basis.z
var tilted := base_normal.rotated(
camera.global_transform.basis.x,
deg_to_rad(drag_plane_angle)
)
_drag_plane = Plane(tilted.normalized(), hit_point)
_drag_offset = global_position - hit_point
# Seed the target to the current position. Without this the dart drops
# to the plane origin until the mouse first moves.
_target_pos = global_position
_pos_history.clear()
_time_history.clear()
func update_drag(camera: Camera3D, mouse_pos: Vector2) -> void:
if state != State.DRAGGING:
return
var ray_origin := camera.project_ray_origin(mouse_pos)
var ray_dir := camera.project_ray_normal(mouse_pos)
var hit = _drag_plane.intersects_ray(ray_origin, ray_dir)
if hit != null:
_target_pos = hit + _drag_offset
func release_drag() -> void:
if state != State.DRAGGING:
return
state = State.IN_FLIGHT
gravity_scale = flight_gravity_scale
linear_damp = 0.0
angular_damp = 0.0
# The flick gives aim (up/down/left/right). It CANNOT give forward speed —
# the drag plane faces the camera, so motion toward the board is ~zero.
var flick := _calculate_throw_velocity() * throw_force_multiplier
# The forward component has to come from somewhere. Use the camera's
# forward, so "throw" always means "toward what you're looking at".
var cam := get_viewport().get_camera_3d()
var forward := -cam.global_transform.basis.z * forward_impulse
var throw := flick + forward
if throw.length() > max_throw_speed:
throw = throw.normalized() * max_throw_speed
linear_velocity = throw
var raw := _calculate_throw_velocity()
print("flick: %s forward: %s final: %s (%.1f m/s)" % [flick, forward, throw, throw.length()])
# --- Physics ------------------------------------------------------------
func _physics_process(delta: float) -> void:
match state:
State.DRAGGING:
_process_drag()
State.IN_FLIGHT:
_process_flight(delta)
func _process_drag() -> void:
# Chase the target with velocity rather than teleporting, so the dart
# still collides with things and carries momentum into the throw.
var to_target := _target_pos - global_position
var desired := to_target * drag_follow_speed
if desired.length() > max_drag_speed:
desired = desired.normalized() * max_drag_speed
linear_velocity = desired
angular_velocity = angular_velocity.lerp(Vector3.ZERO, 0.2)
_pos_history.append(global_position)
_time_history.append(Time.get_ticks_msec() / 1000.0)
if _pos_history.size() > HISTORY_LENGTH:
_pos_history.pop_front()
_time_history.pop_front()
func _process_flight(delta: float) -> void:
if linear_velocity.length() > landed_speed_threshold:
_align_to_velocity(delta)
else:
_on_landed()
func _align_to_velocity(delta: float) -> void:
var vel_dir := linear_velocity.normalized()
var current_forward := global_transform.basis * forward_axis
var axis := current_forward.cross(vel_dir)
if axis.length() < 0.001:
return # already aligned, or exactly opposed
axis = axis.normalized()
var angle := current_forward.angle_to(vel_dir)
var step := minf(angle, nose_align_speed * delta)
global_transform.basis = global_transform.basis.rotated(axis, step)
global_transform.basis = global_transform.basis.orthonormalized()
func _on_landed() -> void:
state = State.LANDED
angular_velocity = Vector3.ZERO
landed.emit(self, global_position)
func _calculate_throw_velocity() -> Vector3:
if _pos_history.size() < 2:
return linear_velocity
var dt: float = _time_history[-1] - _time_history[0]
if dt <= 0.0:
return linear_velocity
return (_pos_history[-1] - _pos_history[0]) / dt
BIN
View File
Binary file not shown.
@@ -3,13 +3,13 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://cjq45anerhxnk" uid="uid://fp0gcwcyfd7g"
path="res://.godot/imported/Dart.glb-e68d17366fac688e4c81cf4cc62e1175.scn" path="res://.godot/imported/dart.glb-ca8e61ccf0bb4431c3c990bf6c68da95.scn"
[deps] [deps]
source_file="res://models/Dart.glb" source_file="res://core/dart/dart.glb"
dest_files=["res://.godot/imported/Dart.glb-e68d17366fac688e4c81cf4cc62e1175.scn"] dest_files=["res://.godot/imported/dart.glb-ca8e61ccf0bb4431c3c990bf6c68da95.scn"]
[params] [params]
+21
View File
@@ -0,0 +1,21 @@
[gd_scene format=3 uid="uid://fshohiewm7et"]
[ext_resource type="Script" uid="uid://sueo33rxr07e" path="res://core/dart/dart.gd" id="1"]
[ext_resource type="PackedScene" uid="uid://fp0gcwcyfd7g" path="res://core/dart/dart.glb" id="2"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_1"]
radius = 0.008
height = 0.15
[node name="Dart" type="RigidBody3D" unique_id=1124867376]
collision_layer = 2
script = ExtResource("1")
[node name="MeshInstance3D" parent="." unique_id=936158903 instance=ExtResource("2")]
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=2015440784]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.07, 0)
shape = SubResource("CapsuleShape3D_1")
[node name="Tip" type="Marker3D" parent="." unique_id=2014607155]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.14, 0)
Binary file not shown.
@@ -2,8 +2,8 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://62njxkpga6e8" uid="uid://b3n3th3102f2g"
path.s3tc="res://.godot/imported/Dart_Dart-Default.png-ffb5dce2e6bbf58fe2b00949ee100702.s3tc.ctex" path.s3tc="res://.godot/imported/dart_body.png-b1ba77c6cead7a089d94545960115fac.s3tc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc"],
"vram_texture": true "vram_texture": true
@@ -14,8 +14,8 @@ generator_parameters={
[deps] [deps]
source_file="res://models/Dart_Dart-Default.png" source_file="res://core/dart/dart_body.png"
dest_files=["res://.godot/imported/Dart_Dart-Default.png-ffb5dce2e6bbf58fe2b00949ee100702.s3tc.ctex"] dest_files=["res://.godot/imported/dart_body.png-b1ba77c6cead7a089d94545960115fac.s3tc.ctex"]
[params] [params]
Binary file not shown.
@@ -2,8 +2,8 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://b7bsi4ury2lve" uid="uid://c85g6b4hhn83g"
path.s3tc="res://.godot/imported/Dart_Wings-Default.png-dcf030c3d00e4d9573d0a3e9c88fff0a.s3tc.ctex" path.s3tc="res://.godot/imported/dart_wings.png-45f87827353eb4dc5f134b80f5e80ea7.s3tc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc"],
"vram_texture": true "vram_texture": true
@@ -14,8 +14,8 @@ generator_parameters={
[deps] [deps]
source_file="res://models/Dart_Wings-Default.png" source_file="res://core/dart/dart_wings.png"
dest_files=["res://.godot/imported/Dart_Wings-Default.png-dcf030c3d00e4d9573d0a3e9c88fff0a.s3tc.ctex"] dest_files=["res://.godot/imported/dart_wings.png-45f87827353eb4dc5f134b80f5e80ea7.s3tc.ctex"]
[params] [params]
+92
View File
@@ -0,0 +1,92 @@
extends Node
## Loads modifier definitions and serves them by id.
##
## ⚠️ PROTOTYPE. This is scaffolding, not the real mod loader.
##
## What's wrong with it, to be fixed in Phase 3:
## - DirAccess cannot walk into a PCK. This works in the editor and silently
## returns NOTHING in an exported build. Needs a build-time manifest.
## - No namespacing. Ids are bare strings, so two mods can collide.
## - No mod.json, no api_version, no dependency resolution.
## - Only reads content/official/. Doesn't touch user://mods/.
##
## Autoload name is ModRegistry (PascalCase), so call sites read as
## ModRegistry.get_by_id(...) rather than looking like a local variable.
const MODIFIER_DIR := "res://content/official/modifiers/"
var modifiers: Array[Modifier] = []
var _by_id: Dictionary = {}
func _ready() -> void:
_load_from_folder(MODIFIER_DIR)
_reindex()
print("ModRegistry: loaded %d modifiers." % modifiers.size())
## Recursive, because each modifier now lives in its own folder:
## modifiers/mjolnir/mjolnir.tres
func _load_from_folder(path: String) -> void:
var dir := DirAccess.open(path)
if dir == null:
push_warning("ModRegistry: can't open %s" % path)
return
dir.list_dir_begin()
var fname := dir.get_next()
while fname != "":
var full := path.path_join(fname)
if dir.current_is_dir():
if not fname.begins_with("."):
_load_from_folder(full)
elif fname.ends_with(".tres"):
var res := load(full)
if res is Modifier:
modifiers.append(res)
else:
push_warning("ModRegistry: %s is not a Modifier" % full)
fname = dir.get_next()
dir.list_dir_end()
func _reindex() -> void:
_by_id.clear()
for m in modifiers:
if m.id.is_empty():
push_warning("ModRegistry: modifier with empty id, skipping.")
continue
if _by_id.has(m.id):
push_warning("ModRegistry: duplicate id '%s'." % m.id)
_by_id[m.id] = m
func get_by_id(id: String) -> Modifier:
return _by_id.get(id)
## Pick `count` distinct modifiers, weighted by rarity.
func pick_random(count: int) -> Array[Modifier]:
var pool := modifiers.duplicate()
var chosen: Array[Modifier] = []
count = mini(count, pool.size())
for _i in count:
var total := 0.0
for m in pool:
total += maxf(m.weight, 0.0001)
var r := randf() * total
var acc := 0.0
var picked := 0
for j in pool.size():
acc += maxf(pool[j].weight, 0.0001)
if r <= acc:
picked = j
break
chosen.append(pool[picked])
pool.remove_at(picked) # distinct — don't offer the same one twice
return chosen
+1
View File
@@ -0,0 +1 @@
uid://btm4dwci50xmu
+40
View File
@@ -0,0 +1,40 @@
extends Resource
class_name Modifier
# One modifier definition: title + description + texture + scoring rule.
# Create instances of this as .tres files in the editor (right-click in
# FileSystem -> New Resource -> Modifier), OR build them in code in the catalog.
#
# Because a texture always maps to the same modifier, the texture lives HERE.
# A stable unique id (e.g. "stars_x5"). Useful for saving which modifiers are
# applied to which segments, and for lookups. Keep it unique across the catalog.
@export var id: String = ""
# Shown to the player when offered the piece.
@export var title: String = ""
@export_multiline var description: String = ""
# The texture painted onto the segment when this modifier is applied.
@export var texture: Texture2D
# --- Scoring rule ---
# Simple, data-only rules cover most cases without writing code per modifier.
# value = (base_value * mult + add) for the affected segment.
@export var score_mult: float = 1.0
@export var score_add: int = 0
# Optional: rarity weight for random selection (higher = more common).
@export var weight: float = 1.0
# Apply this modifier's rule to a segment's base value.
func apply_score(base_value: int) -> int:
return int(round(base_value * score_mult)) + score_add
# A short human-readable summary of the effect, for tooltips/UI.
func effect_text() -> String:
var parts: Array[String] = []
if score_mult != 1.0:
parts.append("x%s" % str(score_mult))
if score_add != 0:
parts.append(("+%d" % score_add) if score_add > 0 else str(score_add))
return " ".join(parts) if parts.size() > 0 else "no change"
+1
View File
@@ -0,0 +1 @@
uid://duh0lxb7gdimr
+150
View File
@@ -0,0 +1,150 @@
extends Control
## Screen position -> board slot. That's the whole job.
##
## 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.
##
## 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.
##
## 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.
##
## 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
@export var camera: Camera3D
@export var dartboard: Dartboard
@export_group("Debug")
## Print every hover. Noisy but useful when calibrating.
@export var log_hovers := false
## 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 -------------------------------------------------------------
## 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:
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_at_screen_pos(screen_pos)
if slot == &"":
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
slot_dropped.emit(slot, modifier)
print("applied '%s' -> %s" % [modifier.id, slot])
return true
## 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
func modifier_at(slot: StringName) -> Modifier:
return applied.get(slot)
func clear(slot: StringName) -> void:
applied.erase(slot)
dartboard.clear_slot(slot)
func clear_all() -> void:
applied.clear()
dartboard.clear_all_slots()
# --- Internals ----------------------------------------------------------
func _raycast(screen_pos: Vector2) -> Dictionary:
var origin := camera.project_ray_origin(screen_pos)
var params := PhysicsRayQueryParameters3D.create(
origin,
origin + camera.project_ray_normal(screen_pos) * RAY_LENGTH
)
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
View File
@@ -0,0 +1 @@
uid://x5kcjofb5pek
+99
View File
@@ -0,0 +1,99 @@
extends Control
## 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_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
if drop_catcher == null:
push_error("ModifierTray: `drop_catcher` export is not set.")
return
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
piece.size = piece_size
piece.modifier = mod
piece.paint_texture = mod.texture
piece.drop_catcher = drop_catcher
piece.tray = self
add_child(piece)
_pieces.append(piece)
func _layout() -> void:
var count := _pieces.size()
if count == 0:
return
var total_w := count * piece_size.x + (count - 1) * spacing
var screen := get_viewport_rect().size
var start_x := (screen.x - total_w) * 0.5
var y := screen.y - piece_size.y - bottom_margin
for i in count:
_pieces[i].position = Vector2(start_x + i * (piece_size.x + spacing), y)
## 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:
for p in _pieces:
if is_instance_valid(p):
p.queue_free()
_pieces.clear()
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
_layout()
+1
View File
@@ -0,0 +1 @@
uid://dnek55fao0l72
+125
View File
@@ -0,0 +1,125 @@
extends Control
## A draggable modifier piece. Follows the cursor directly — no ghost, no
## Godot drag-and-drop API (which fights with 3D raycasting).
##
## 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):
paint_texture = value
queue_redraw()
@export_range(0.0, 1.0) var top_width_ratio := 0.55
# Injected by the tray on spawn.
var drop_catcher: Control
var tray: Node
var modifier: Modifier
var _dragging := false
var _home_position := Vector2.ZERO
var _drag_offset := Vector2.ZERO
var _hovered_slot: StringName = &""
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
if custom_minimum_size == Vector2.ZERO:
custom_minimum_size = Vector2(90, 110)
size = custom_minimum_size
func _draw() -> void:
var w := size.x
var h := size.y
var inset := w * (1.0 - top_width_ratio) * 0.5
var points := PackedVector2Array([
Vector2(inset, 0), Vector2(w - inset, 0),
Vector2(w, h), Vector2(0, h),
])
if paint_texture != null:
var uvs := PackedVector2Array([
Vector2(inset / w, 0), Vector2((w - inset) / w, 0),
Vector2(1, 1), Vector2(0, 1),
])
var white := PackedColorArray([Color.WHITE, Color.WHITE, Color.WHITE, Color.WHITE])
draw_polygon(points, white, uvs, paint_texture)
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, outline_col, outline_w)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_start_drag()
else:
_end_drag()
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
z_index = 100
modulate.a = 0.85
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.")
else:
applied = drop_catcher.apply_modifier_at_screen_pos(
get_global_mouse_position(), modifier
)
# 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 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()
+1
View File
@@ -0,0 +1 @@
uid://d0cdxnymxd6bs
View File

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 994 B

+6
View File
@@ -18,6 +18,8 @@ dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.cte
compress/mode=0 compress/mode=0
compress/high_quality=false compress/high_quality=false
compress/lossy_quality=0.7 compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1 compress/hdr_compression=1
compress/normal_map=0 compress/normal_map=0
compress/channel_pack=0 compress/channel_pack=0
@@ -25,6 +27,10 @@ mipmaps/generate=false
mipmaps/limit=-1 mipmaps/limit=-1
roughness/mode=0 roughness/mode=0
roughness/src_normal="" 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/fix_alpha_border=true
process/premult_alpha=false process/premult_alpha=false
process/normal_map_invert_y=false process/normal_map_invert_y=false
+11 -2
View File
@@ -19,14 +19,22 @@ run/main_scene="uid://doh7p84eej78c"
config/features=PackedStringArray("4.7", "Mobile") config/features=PackedStringArray("4.7", "Mobile")
config/icon="res://icon.svg" config/icon="res://icon.svg"
[autoload]
ModRegistry="*uid://btm4dwci50xmu"
[display] [display]
window/stretch/mode="viewport" window/stretch/mode="canvas_items"
window/stretch/aspect="expand" window/stretch/aspect="expand"
[filesystem]
import/blender/enabled=false
[global_group] [global_group]
Dart="All throwable darts" darts="All throwable darts"
[input] [input]
@@ -45,3 +53,4 @@ restart={
[rendering] [rendering]
renderer/rendering_method="mobile" renderer/rendering_method="mobile"
anti_aliasing/quality/msaa_3d=2
+56
View File
@@ -0,0 +1,56 @@
extends Camera3D
## Owns dart input. The camera does the raycast because the raycast IS a
## camera operation — projecting a screen point into the world.
##
## The dart exposes begin_drag / update_drag / release_drag and never looks
## for the camera itself. This script decides who to call them on.
const RAY_LENGTH := 1000.0
var _held_dart: Dart = null
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index != MOUSE_BUTTON_LEFT:
return
if mb.pressed:
_try_grab(mb.position)
else:
_release()
elif event is InputEventMouseMotion and _held_dart != null:
_held_dart.update_drag(self, (event as InputEventMouseMotion).position)
func _try_grab(screen_pos: Vector2) -> void:
var hit := _raycast(screen_pos)
if hit.is_empty():
return
var body = hit.get("collider")
if body is Dart:
_held_dart = body as Dart
# NOTE: two args, not three. The old start_drag(camera, mouse_pos, point)
# passed mouse_pos but never used it — begin_drag only needs the hit point.
_held_dart.begin_drag(self, hit["position"])
func _release() -> void:
if _held_dart == null:
return
_held_dart.release_drag()
_held_dart = null
func _raycast(screen_pos: Vector2) -> Dictionary:
var params := PhysicsRayQueryParameters3D.create(
project_ray_origin(screen_pos),
project_ray_origin(screen_pos) + project_ray_normal(screen_pos) * RAY_LENGTH
)
if _held_dart != null:
params.exclude = [_held_dart.get_rid()]
return get_world_3d().direct_space_state.intersect_ray(params)
+12
View File
@@ -0,0 +1,12 @@
extends Node3D
## Scene root. Deliberately tiny.
##
## Dart input lives in camera_3d.gd (the raycast is a camera operation).
## Board behaviour lives in dartboard.gd. Modifier UI lives in core/ui/.
## This script wires nothing and owns nothing — it's just the tree root.
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed(&"restart"):
get_tree().reload_current_scene()
+47
View File
@@ -0,0 +1,47 @@
[gd_scene format=3 uid="uid://doh7p84eej78c"]
[ext_resource type="Script" uid="uid://geqmah8kbko0" path="res://scenes/main.gd" id="1_sugp2"]
[ext_resource type="Script" uid="uid://ds3uiri3oc05v" path="res://scenes/camera_3d.gd" id="2_jyhfs"]
[ext_resource type="PackedScene" uid="uid://fshohiewm7et" path="res://core/dart/dart.tscn" id="3_tbgi4"]
[ext_resource type="PackedScene" uid="uid://dojbd5wl7ntp0" path="res://core/board/dartboard.tscn" id="3_tefeu"]
[ext_resource type="Script" uid="uid://x5kcjofb5pek" path="res://core/ui/drop_catcher.gd" id="5_o6xl0"]
[ext_resource type="Script" uid="uid://dnek55fao0l72" path="res://core/ui/modifier_tray.gd" id="6_tipki"]
[node name="Main" type="Node3D" unique_id=819165214]
script = ExtResource("1_sugp2")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1570426839]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.3, 2.37)
fov = 30.0
script = ExtResource("2_jyhfs")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1891469178]
transform = Transform3D(-0.789482, 0.24223976, -0.56394875, -0.56595325, 0.06825608, 0.821607, 0.23751883, 0.9678125, 0.08320943, -46.816067, 15.942318, -50.370865)
[node name="Dartboard" parent="." unique_id=408060787 instance=ExtResource("3_tefeu")]
[node name="Dart" parent="." unique_id=367982453 instance=ExtResource("3_tbgi4")]
transform = Transform3D(1, 0, 0, 0, 0.83676445, 0.5475633, 0, -0.54756325, 0.8367645, 0, -0.43, 1.8)
[node name="UI" type="CanvasLayer" parent="." unique_id=1068293458]
[node name="DropCatcher" type="Control" parent="UI" unique_id=1874461265 node_paths=PackedStringArray("camera", "dartboard")]
layout_mode = 3
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 = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("6_tipki")
drop_catcher = NodePath("../DropCatcher")
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB