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>
247 lines
9.2 KiB
GDScript
247 lines
9.2 KiB
GDScript
## 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
|