Restructure into core/ and content/official/

This commit is contained in:
Johan Sandoval
2026-07-12 01:12:27 -05:00
parent e65c48c1e3
commit 69f2028ce3
132 changed files with 1269 additions and 1323 deletions
+173
View File
@@ -0,0 +1,173 @@
class_name Dartboard
extends StaticBody3D
## The board. 21 material slots: sector_1 .. sector_20, plus "bulls".
##
## 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).
const BULL_SLOT := &"bulls"
@onready var mesh_instance: MeshInstance3D = $Model/dartboard
## StringName -> surface index
var _slot_to_surface: Dictionary = {}
## global face index -> StringName
var _face_to_slot: 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.")
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)
continue
var slot := StringName(mat.resource_name)
if slot == &"":
push_warning("Surface %d's material has no name." % i)
continue
_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())
## 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.
##
## This assumes the ConcavePolygonShape3D was built from the mesh with surfaces
## in the same order — which is what create_trimesh_shape() does.
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]
# 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 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
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
# --- Public -------------------------------------------------------------
## Which slot does this trimesh face belong to?
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.
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]
var mat := mesh_instance.get_active_material(surface)
if mat == null:
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.
var unique := mat.duplicate() as StandardMaterial3D
unique.albedo_texture = texture
unique.albedo_color = Color.WHITE # don't tint the texture
mesh_instance.set_surface_override_material(surface, unique)
## 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 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).
##
## 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.
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:
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)
## Debug: what did Blender actually export?
func print_slots() -> void:
var mesh := mesh_instance.mesh
print("=== Dartboard slots ===")
for i in mesh.get_surface_count():
var mat := mesh.surface_get_material(i)
print(" surface %2d: '%s'" % [i, mat.resource_name if mat else "(none)"])
print(" radius: %.4f aabb: %s" % [_outer_radius, mesh_instance.get_aabb().size])
print("=======================")
+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
+1
View File
@@ -0,0 +1 @@
uid://sueo33rxr07e
BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://fp0gcwcyfd7g"
path="res://.godot/imported/dart.glb-ca8e61ccf0bb4431c3c990bf6c68da95.scn"
[deps]
source_file="res://core/dart/dart.glb"
dest_files=["res://.godot/imported/dart.glb-ca8e61ccf0bb4431c3c990bf6c68da95.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
+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.
+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b3n3th3102f2g"
path.s3tc="res://.godot/imported/dart_body.png-b1ba77c6cead7a089d94545960115fac.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "9c4718c8d2830abe8cc9971ef621b46a"
}
[deps]
source_file="res://core/dart/dart_body.png"
dest_files=["res://.godot/imported/dart_body.png-b1ba77c6cead7a089d94545960115fac.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.
+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c85g6b4hhn83g"
path.s3tc="res://.godot/imported/dart_wings.png-45f87827353eb4dc5f134b80f5e80ea7.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "52ee9a89e7c6ed5f537103a41115551e"
}
[deps]
source_file="res://core/dart/dart_wings.png"
dest_files=["res://.godot/imported/dart_wings.png-45f87827353eb4dc5f134b80f5e80ea7.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
+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
+215
View File
@@ -0,0 +1,215 @@
extends Control
## Resolves a screen position (or a dart's world position) to a board sector,
## and applies modifiers there.
##
## THE BOARD HAS 21 MATERIAL SLOTS: sector_1 .. sector_20, plus "bulls".
## The bull slot covers inner AND outer bull — they move together.
##
## Two ways to find which sector was hit:
##
## 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.
##
## 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.
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("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
var applied: Dictionary = {}
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# --- 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
if camera == null or dartboard == null:
push_error("DropCatcher: camera and dartboard exports must be set.")
return false
var hit := _raycast(screen_pos)
if hit.is_empty():
return false
var slot := _slot_from_hit(hit)
if slot == &"":
return false
dartboard.paint_slot(slot, modifier.texture)
applied[slot] = modifier
print("applied '%s' to %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
## 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),
}
# --- 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 _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)
## 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 ------------------------------------------------------------
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
return get_viewport().world_3d.direct_space_state.intersect_ray(params)
+1
View File
@@ -0,0 +1 @@
uid://x5kcjofb5pek
+75
View File
@@ -0,0 +1,75 @@
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")
@export var drop_catcher: Control
@export var pieces_per_round := 3
@export var piece_size := Vector2(90, 110)
@export var spacing := 24.0
@export var bottom_margin := 30.0
var _pieces: Array[Control] = []
func _ready() -> void:
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.")
return
for mod in choices:
var piece := Control.new()
piece.set_script(SectorSwatch)
piece.custom_minimum_size = piece_size
piece.size = piece_size
piece.modifier = mod
piece.paint_texture = mod.texture
piece.drop_catcher = drop_catcher
piece.tray = self
add_child(piece)
_pieces.append(piece)
_layout_pieces()
func _layout_pieces() -> 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 when it lands successfully — the whole set is spent.
func consume() -> void:
_clear_pieces()
func _clear_pieces() -> void:
for p in _pieces:
if is_instance_valid(p):
p.queue_free()
_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()
+1
View File
@@ -0,0 +1 @@
uid://dnek55fao0l72
+107
View File
@@ -0,0 +1,107 @@
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.
##
## Was TrapezoidSwatch.gd — renamed because modifiers now attach to whole
## sectors, not individual segments.
@export var paint_texture: Texture2D:
set(value):
paint_texture = value
queue_redraw()
@export_range(0.0, 1.0) var top_width_ratio := 0.55
# Set by the tray when it spawns this piece.
var drop_catcher: Control
var tray: Node
var modifier: Modifier
var _dragging := false
var _home_position := Vector2.ZERO
var _drag_offset := Vector2.ZERO
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))
var outline := points
outline.append(points[0])
draw_polyline(outline, Color(0, 0, 0, 0.6), 2.0)
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
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.")
else:
applied = drop_catcher.apply_modifier_at_screen_pos(
get_global_mouse_position(), modifier
)
if applied:
if tray != null and tray.has_method(&"consume"):
tray.consume()
else:
queue_free()
else:
# Missed — snap home.
top_level = false
global_position = _home_position
func _process(_delta: float) -> void:
if _dragging:
global_position = get_global_mouse_position() + _drag_offset
+1
View File
@@ -0,0 +1 @@
uid://d0cdxnymxd6bs