rimlike/scenes/entities/big_rock.gd
megaproxy d819c13a9d Phase 18 — Audio (music director + SFX catalog + bus wiring)
Adds an AudioManager autoload with three buses (Master, Music routed to
Master, SFX routed to Master), a small catalog of looping music + one-shot
SFX, and a single persistent AudioStreamPlayer for the music director.

Music
* Day and night loops swap on Clock.phase_changed (night during the night
  phase, day everywhere else). Tracks pulled from Retro Farming Music 1
  (day) and Cozy Melodies Pack 1 (night), both loopable OGG.

SFX
* Tree.fell, Rock.mined, BigRock.mined → tree_fell / mine_tick.
* EventBus.pawn_took_damage → combat_hit (Sword Pack 1).
* EventBus.storyteller_event_fired → ui_confirm sting.
* EventBus.alert_added → ui_click.
* play_sfx is rate-limited per key (80ms cooldown) so fast-sim doesn't
  saturate the mixer.

Settings + suspend
* SettingsMenu master/music/sfx sliders now live-bind to the bus dB via
  Audio.set_*_linear (linear → dB internally, 0 → -80dB silence). The
  ambient slider is intentionally unwired; no ambient bus this pass.
* NOTIFICATION_APPLICATION_PAUSED + FOCUS_OUT mute the Master bus to
  match the existing "no background sim" rule. Resume + focus restore it.

Bundle housekeeping
* Two zipped packs in the ElvGames bundle (Cozy Melodies Pack 1, Retro
  Farming Music 1) extracted in place to keep pack identity intact for
  the license/credits string. 8 OGG files curated into audio/ at ~5.3MB.

Verified end-to-end via MCP runtime: buses online, day_loop plays at
boot, manual phase swap day→night→day round-trips, slider linear→dB
mapping correct (0.5 → -6.02dB, 0.0 → -80dB), tree_fell SFX triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:54:36 +01:00

247 lines
9.2 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## BigRock — 2×2 boulder formation. Same mining contract as Rock but occupies
## four tiles, drops four stone, and blocks pathfinding on its footprint so
## pawns route around it.
##
## Duck-typed to Rock's public interface (tile, is_mineable, on_mine_tick,
## approach_tile_for) so MineProvider can scan World.rocks without checking
## the entity's concrete type.
##
## Position semantics (different from single Rock):
## • origin_tile is the TOP-LEFT of the 2×2 footprint (player-friendly anchor).
## • The Sprite2D is 32×32 and is centred at the intersection of the four
## tiles, so its visual centre sits at (origin_tile + (1, 1)) * 16 px.
## • For MineProvider distance, `tile` aliases to origin_tile (cheap, stable).
## • For the pawn's walk destination, approach_tile_for(pawn_tile) returns
## the closest walkable perimeter neighbour, so the pawn stands BESIDE the
## boulder instead of trying to path onto a blocked tile.
class_name BigRock extends Node2D
const TILE_SIZE_PX: int = 16
## Sim ticks to mine a big rock. Roughly 4× a small rock (120 ticks) since the
## footprint represents four tiles' worth of stone.
const MINE_TICKS: int = 480
## Stone Items dropped on a successful mine (one per footprint tile).
const STONE_DROPS_ON_MINE: int = 4
## Footprint dimensions. Locked at 2×2 for the first BigRock pass; if larger
## boulders ever ship, generalise here and the perimeter math in approach_tile_for.
const FOOTPRINT_W: int = 2
const FOOTPRINT_H: int = 2
# Preloaded scene for spawned stone items.
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
## ElvGames Grasslands tileset — 2×2 cluster sprites starting at these
## top-left coords. Visually confirmed against /tmp/rocks_labeled_grid.png
## in the 2026-05-12 visual pass.
const _ROCK_TEX: Texture2D = preload("res://art/tiles/FG_Grasslands_Spring.png")
const _BIG_ROCK_ATLAS_COORDS: Array[Vector2i] = [
Vector2i(22, 3), # brown 2×2 boulder
Vector2i(30, 3), # gray 2×2 boulder
]
# ── state ─────────────────────────────────────────────────────────────────────
## Top-left tile of the 2×2 footprint.
var origin_tile: Vector2i = Vector2i.ZERO
## 0..MINE_TICKS. Advanced by on_mine_tick(); rock is mined when equal to MINE_TICKS.
var mine_progress: int = 0
## True once a player has painted a mine designation on any footprint tile.
## MineProvider ignores undesignated boulders (Rimworld parity).
var mine_designated: bool = false
# ── lifecycle ─────────────────────────────────────────────────────────────────
## All init work happens inside setup() (not _ready) because callers
## always do `add_child(node); node.setup(origin)` — _ready fires inside
## add_child with origin_tile still at its zero default, so anything that
## reads origin_tile from _ready would stamp the pathfinder / position at
## the wrong tile. setup() is the single authoritative entry point.
func _ready() -> void:
pass
func _exit_tree() -> void:
World.unregister_rock(self)
_set_footprint_walkable(true)
# ── public API ────────────────────────────────────────────────────────────────
## One-shot initialiser. Builds the sprite, positions the node, marks the four
## footprint tiles unwalkable, and registers with World.rocks. Call after
## add_child().
func setup(p_origin: Vector2i) -> void:
origin_tile = p_origin
mine_progress = 0
position = Vector2(
(origin_tile.x + 1) * TILE_SIZE_PX,
(origin_tile.y + 1) * TILE_SIZE_PX,
)
_build_sprite()
_set_footprint_walkable(false)
World.register_rock(self)
queue_redraw()
Audit.log("big_rock", "spawned 2×2 at %s" % origin_tile)
## Alias used by MineProvider's distance calc (`rock.tile`). Returns the
## top-left so the manhattan distance is stable; refining to the centre would
## require fractional tiles.
var tile: Vector2i:
get:
return origin_tile
## True when the boulder hasn't been fully mined yet.
func is_mineable() -> bool:
return mine_progress < MINE_TICKS
## Returns the perimeter tile closest to `pawn_tile` that the pawn can stand
## on while mining. Mirrors Rock.approach_tile_for so MineProvider can ask the
## entity for its walk destination without knowing which kind of rock it is.
## Falls back to the origin tile when no perimeter is walkable — the pathfinder
## will then return an empty path and the job runner cancels cleanly.
func approach_tile_for(pawn_tile: Vector2i) -> Vector2i:
var perimeter := _perimeter_tiles()
var best: Vector2i = origin_tile
var best_d: int = 0x7fffffff
var found_any: bool = false
for t in perimeter:
if World.pathfinder == null:
continue
if not World.pathfinder.is_walkable(t):
continue
var d: int = abs(t.x - pawn_tile.x) + abs(t.y - pawn_tile.y)
if d < best_d:
best_d = d
best = t
found_any = true
if not found_any:
return origin_tile
return best
## Called by the INTERACT toil in JobRunner once per sim tick while the pawn
## works this boulder. Advances mine_progress and triggers mined() when done.
func on_mine_tick() -> void:
if not is_mineable():
return
mine_progress += 1
queue_redraw()
if mine_progress >= MINE_TICKS:
mined()
## Drop four stone Items (one per footprint tile) and free this node. Called
## by on_mine_tick() automatically; can also be called for scripted removal.
func mined() -> void:
for ft in footprint_tiles():
var item: Item = ITEM_SCENE.instantiate()
get_parent().add_child(item)
item.setup(Item.TYPE_STONE, 1, ft)
Audit.log("big_rock", "mined 2×2 at %s; %d stone drops" % [origin_tile, STONE_DROPS_ON_MINE])
if Audio != null:
Audio.play_sfx(&"mine_tick")
queue_free()
## The four tiles this boulder occupies (origin + the three south/east neighbours).
func footprint_tiles() -> Array[Vector2i]:
return [
origin_tile,
origin_tile + Vector2i(1, 0),
origin_tile + Vector2i(0, 1),
origin_tile + Vector2i(1, 1),
]
# ── save / load ───────────────────────────────────────────────────────────────
func to_dict() -> Dictionary:
return {
"class_id": &"big_rock",
"origin_x": origin_tile.x,
"origin_y": origin_tile.y,
"mine_progress": mine_progress,
"mine_designated": mine_designated,
}
static func from_dict(d: Dictionary) -> Dictionary:
return {
"origin_x": int(d.get("origin_x", 0)),
"origin_y": int(d.get("origin_y", 0)),
"mine_progress": int(d.get("mine_progress", 0)),
"mine_designated": bool(d.get("mine_designated", false)),
}
# ── render ────────────────────────────────────────────────────────────────────
func _draw() -> void:
# Sprite child draws the boulder body. _draw renders only the mine-progress
# crack overlay so the player sees mining damage.
if mine_progress > 0:
var ratio := float(mine_progress) / float(MINE_TICKS)
var crack_len := ratio * 12.0
draw_line(
Vector2(-4.0, -2.0),
Vector2(-4.0 + crack_len, 4.0),
Color(0.15, 0.12, 0.10, 0.85),
1.5
)
# ── internal ──────────────────────────────────────────────────────────────────
## Build a single 32×32 Sprite2D from a 2×2 region of FG_Grasslands_Spring.
## Variant chosen deterministically from origin_tile so the same boulder renders
## the same colour across boots and load/save.
func _build_sprite() -> void:
var sprite := Sprite2D.new()
sprite.name = "Sprite"
sprite.texture = _ROCK_TEX
sprite.region_enabled = true
var coord_idx: int = (origin_tile.x * 31 + origin_tile.y * 17) % _BIG_ROCK_ATLAS_COORDS.size()
var coord: Vector2i = _BIG_ROCK_ATLAS_COORDS[coord_idx]
sprite.region_rect = Rect2(
coord.x * TILE_SIZE_PX,
coord.y * TILE_SIZE_PX,
TILE_SIZE_PX * FOOTPRINT_W,
TILE_SIZE_PX * FOOTPRINT_H,
)
sprite.centered = true
sprite.offset = Vector2.ZERO
add_child(sprite)
## Mark or unmark the four footprint tiles in the pathfinder. Called from
## _ready / _exit_tree so the boulder appears as an obstacle while it exists.
func _set_footprint_walkable(walkable: bool) -> void:
if World.pathfinder == null:
return
for ft in footprint_tiles():
World.pathfinder.set_cell_walkable(ft, walkable)
## The eight perimeter tiles around the 2×2 footprint (cardinal + diagonals
## of the bounding rect). Used by approach_tile_for to find a stand-and-mine
## tile near the pawn.
func _perimeter_tiles() -> Array[Vector2i]:
var out: Array[Vector2i] = []
# Top + bottom rows.
for dx in range(-1, FOOTPRINT_W + 1):
out.append(origin_tile + Vector2i(dx, -1))
out.append(origin_tile + Vector2i(dx, FOOTPRINT_H))
# Left + right columns (skip the corners — already in the top/bottom rows).
for dy in range(0, FOOTPRINT_H):
out.append(origin_tile + Vector2i(-1, dy))
out.append(origin_tile + Vector2i(FOOTPRINT_W, dy))
return out