A: Storyteller.multiply_drops() stochastic-rounding helper drives crop_growth, harvest_yield, chop, mine consumption sites. sleep_decay multiplied in Pawn sleep tick. B: Pawn._sick_speed_penalty() (0% healthy → 75% severity 3, clamped to 25% min speed). JobRunner._work_speed_mult coin-flips per-tick progress on INTERACT/BUILD/CRAFT toils. Sleep/eat/treat unaffected. C: CraftingProvider builds N deposit trips for ingredient2_count > 1. JobRunner._tick_craft validates+consumes the full count from buffer. Cremation now actually requires and consumes 5 wood. crop._stage_accum round-trips through save/load to preserve buff- accumulated fractional growth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
7.1 KiB
GDScript
175 lines
7.1 KiB
GDScript
## Rock entity — mineable by a pawn with a Mine job. Drops a stone Item node
|
||
## when mined out.
|
||
##
|
||
## Mirrors Tree's chopping model; stone is harder so MINE_TICKS is longer.
|
||
## A MineProvider (Opus, Phase 4) creates a Job whose INTERACT toil calls
|
||
## on_mine_tick() once per sim tick via JobRunner.
|
||
##
|
||
## World registration (World.register_rock / World.unregister_rock) is called
|
||
## here but the methods land in World during Opus integration.
|
||
|
||
class_name Rock extends Node2D
|
||
|
||
const TILE_SIZE_PX: int = 16
|
||
|
||
## Sim ticks to mine a rock at 1× speed (120 ticks = 6 sim seconds at 20 Hz).
|
||
## Stone is harder than wood — MINE_TICKS > Tree.CHOP_TICKS.
|
||
const MINE_TICKS: int = 120
|
||
## Stone Items dropped on a successful mine.
|
||
const STONE_DROPS_ON_MINE: int = 1
|
||
|
||
# ── state ─────────────────────────────────────────────────────────────────────
|
||
|
||
var 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 this rock. MineProvider
|
||
## ignores undesignated rocks (Rimworld parity — pawns don't auto-mine).
|
||
var mine_designated: bool = false
|
||
|
||
# Preloaded scene for spawned stone items.
|
||
const ITEM_SCENE: PackedScene = preload("res://scenes/entities/item.tscn")
|
||
|
||
## Rock sprite atlas — re-uses the Grasslands tileset (already imported for the
|
||
## decoration overlay). These coords pick standalone single-tile boulders from
|
||
## the rock band (x=16..29, y=3 and y=5) — chosen because each has a clean
|
||
## green margin on all four sides, so they read as separate small rocks rather
|
||
## than chunks of a tiled cluster. The earlier (24,7)/(28,7)/(12,13) coords
|
||
## were autotile interior pieces; replaced 2026-05-12 after the user flagged
|
||
## the obvious tile-cluster artifacts on small rocks.
|
||
##
|
||
## Multi-tile cluster formations live at (22..23, 3..4) brown and (30..31, 3..4)
|
||
## gray — those are reserved for the upcoming BigRock entity (2×2 boulder).
|
||
const _ROCK_TEX: Texture2D = preload("res://art/tiles/FG_Grasslands_Spring.png")
|
||
const _ROCK_VARIANT_COORDS: Array[Vector2i] = [
|
||
Vector2i(16, 3), # brown round, medium
|
||
Vector2i(20, 3), # brown peaked, smaller
|
||
Vector2i(16, 5), # brown squat, low
|
||
Vector2i(24, 3), # gray round, medium
|
||
Vector2i(28, 3), # gray peaked, smaller
|
||
Vector2i(24, 5), # gray squat, low
|
||
]
|
||
|
||
|
||
# ── lifecycle ─────────────────────────────────────────────────────────────────
|
||
|
||
func _ready() -> void:
|
||
position = _tile_to_world(tile)
|
||
_build_sprite()
|
||
World.register_rock(self)
|
||
|
||
|
||
## Adds a Sprite2D child with one of the rock variants. Variant chosen
|
||
## deterministically from the tile coord so the same tile renders the same
|
||
## rock 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: Vector2i = _ROCK_VARIANT_COORDS[(tile.x * 31 + tile.y * 17) % _ROCK_VARIANT_COORDS.size()]
|
||
sprite.region_rect = Rect2(coord.x * TILE_SIZE_PX, coord.y * TILE_SIZE_PX, TILE_SIZE_PX, TILE_SIZE_PX)
|
||
sprite.centered = true
|
||
sprite.offset = Vector2.ZERO # 16×16 tile, sits centered on the tile
|
||
add_child(sprite)
|
||
|
||
|
||
func _exit_tree() -> void:
|
||
World.unregister_rock(self)
|
||
|
||
|
||
# ── public API ────────────────────────────────────────────────────────────────
|
||
|
||
## One-shot initialiser. Call after add_child() so _ready() already fired.
|
||
func setup(start_tile: Vector2i) -> void:
|
||
tile = start_tile
|
||
mine_progress = 0
|
||
position = _tile_to_world(tile)
|
||
queue_redraw()
|
||
Audit.log("rock", "spawned at %s" % tile)
|
||
|
||
|
||
## True when the rock hasn't been fully mined yet.
|
||
func is_mineable() -> bool:
|
||
return mine_progress < MINE_TICKS
|
||
|
||
|
||
## Walk destination for a pawn approaching this rock. Small rocks are walkable
|
||
## (Phase 4 simplification), so the pawn stands on the rock tile while mining.
|
||
## BigRock overrides this to return a perimeter neighbour because its footprint
|
||
## is blocked. MineProvider always asks the entity for this rather than reading
|
||
## `.tile` directly, so single Rock and BigRock plug into the same walk toil.
|
||
func approach_tile_for(_pawn_tile: Vector2i) -> Vector2i:
|
||
return tile
|
||
|
||
|
||
## Called by the INTERACT toil in JobRunner once per sim tick while the pawn
|
||
## works this rock. Advances mine_progress and triggers mined() when complete.
|
||
func on_mine_tick() -> void:
|
||
if not is_mineable():
|
||
return
|
||
mine_progress += 1
|
||
queue_redraw()
|
||
if mine_progress >= MINE_TICKS:
|
||
mined()
|
||
|
||
|
||
## Drop stone Item(s) and free this node. Called automatically by on_mine_tick()
|
||
## but also accessible for scripted removal (debug, storyteller events).
|
||
func mined() -> void:
|
||
# Apply mine buff (veins_of_iron): multiply stone drops with stochastic rounding.
|
||
var drop_count: int = Storyteller.multiply_drops(STONE_DROPS_ON_MINE, Storyteller.get_buff_multiplier(&"mine"))
|
||
var item: Item = ITEM_SCENE.instantiate()
|
||
get_parent().add_child(item)
|
||
item.setup(Item.TYPE_STONE, drop_count, tile)
|
||
Audit.log("rock", "mined at %s; %d stone drop(s) (buff mult=%.2f)" % [tile, drop_count, Storyteller.get_buff_multiplier(&"mine")])
|
||
if Audio != null:
|
||
Audio.play_sfx(&"mine_tick")
|
||
World.clear_designation_at(tile)
|
||
queue_free()
|
||
|
||
|
||
# ── save / load ───────────────────────────────────────────────────────────────
|
||
|
||
func to_dict() -> Dictionary:
|
||
return {
|
||
"class_id": &"rock",
|
||
"tile_x": tile.x,
|
||
"tile_y": tile.y,
|
||
"mine_progress": mine_progress,
|
||
"mine_designated": mine_designated,
|
||
}
|
||
|
||
|
||
static func from_dict(d: Dictionary) -> Dictionary:
|
||
return {
|
||
"tile_x": int(d.get("tile_x", 0)),
|
||
"tile_y": int(d.get("tile_y", 0)),
|
||
"mine_progress": int(d.get("mine_progress", 0)),
|
||
"mine_designated": bool(d.get("mine_designated", false)),
|
||
}
|
||
|
||
|
||
# ── render ────────────────────────────────────────────────────────────────────
|
||
|
||
func _draw() -> void:
|
||
# Rock body comes from the Sprite2D child (see _build_sprite).
|
||
# This _draw renders only the mine-progress crack overlaid on the sprite.
|
||
if mine_progress > 0:
|
||
var ratio := float(mine_progress) / float(MINE_TICKS)
|
||
var crack_len := ratio * 5.0
|
||
draw_line(
|
||
Vector2(-1.0, -2.0),
|
||
Vector2(-1.0 + crack_len, 1.0),
|
||
Color(0.15, 0.12, 0.10, 0.85),
|
||
1.5
|
||
)
|
||
|
||
|
||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
func _tile_to_world(t: Vector2i) -> Vector2:
|
||
return Vector2(
|
||
t.x * TILE_SIZE_PX + TILE_SIZE_PX / 2.0,
|
||
t.y * TILE_SIZE_PX + TILE_SIZE_PX / 2.0
|
||
)
|