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
+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