rimlike/scenes/world/camera_rig.gd
megaproxy 3da7353387 Phase 15: Storyteller (25 events, daily roll, banner+modal UI)
Three-agent fan-out reusing the contracts-first pattern: Opus pre-wrote
EventDef class + 5 EventBus signals + Storyteller autoload stub before
dispatch. Pattern proven across Phases 12/13/14/15.

EventDef + 25-event corpus (Agent A):
- scenes/storyteller/event_def.gd — data class with id/title/body/
  category/display/cooldown_days/base_weight/choices/auto_pause/
  focus_tile/trigger_predicate/on_resolve
- scenes/storyteller/event_catalog.gd — class_name EventCatalog with
  register_all() dispatcher + 25 _event_NN() static factories covering
  all 8 categories (nudge×4, seasonal×4, wanderer×4, threat×4, disease×3,
  resource×3, lore×2, milestone×1)
- Strings catalog: 50 keys added (event.<id>.title + event.<id>.body)
  + ui.go_there / ui.dismiss for UI buttons
- on_resolve effects: real-wired for a_bad_cut (StatusCatalog.bleeding),
  one_year_survived + refugee_family + sleeplessness (colony mood thoughts);
  stubbed-with-log for wanderer spawns (Phase 17 recruit UI), resource
  buffs (Phase 17 work-buff system), wolf spawn (EventBus signal pending),
  fever (StatusCatalog.sick pending), seasonal effects

Storyteller real implementation (Agent B):
- autoload/storyteller.gd — replaced stub with full logic:
  * Daily 6 AM roll via Clock.phase_changed(&dawn), one-per-day guard
  * Per-event cooldown via _event_last_fired Dict; per-category via
    _category_last_fired Dict + CATEGORY_COOLDOWN_DAYS (nudge=2,
    seasonal=12, wanderer=5, threat=3, disease=4, resource=3, lore=6,
    milestone=30) — both gates must pass
  * Tension model: 0..100, −3/roll decay, +15 on THREAT fire (net +12)
    Category multipliers: THREAT = lerp(2.0, 0.3, t/100),
    RESOURCE = lerp(0.5, 1.5, t/100), others = 1.0
  * State-trigger 3× weight boost when predicate currently true
  * Auto-pause Sim before showing UI for auto_pause events
  * Ghost state: _on_pawn_died flips on World.pawns empty,
    _ghost_wanderer_target_day = today + randi_range(3, 5),
    daily roll bypasses pool and force-fires WANDERER (prefers a_traveler)
  * Full save/load round-trip incl. cooldown dicts (StringName↔String)

Banner + Modal UI (Agent C):
- scenes/ui/storyteller_banner.gd — class_name StorytellerBanner extends
  CanvasLayer (layer 15), top-center under top-bar, 6-sec auto-dismiss
  Timer, tap-to-dismiss-early, internal queue for back-to-back events
- scenes/ui/storyteller_modal.gd — class_name StorytellerModal extends
  CanvasLayer (layer 20), center PanelContainer, full-screen 0.45 dim
  ColorRect, 0/1/2 choice button layouts
- camera_rig.gd: pan_to_tile(tile) public helper using existing
  _centre_on tween slot
- Both UI scenes runtime-instantiated in main.gd as CanvasLayer children
  (no .tscn edit needed)
- %pawn% substitution at display time (World.pawns[0].pawn_name fallback)

Modal auto-hide-on-resolve fix (Opus mid-flight):
- Original Agent C modal only hid on internal button click. Added
  EventBus.storyteller_event_resolved subscriber → _set_visible(false)
  so external resolve_current calls (test scripts, ghost-state auto-fire)
  also dismiss the dialog.

MCP runtime verified across two boots:
- Boot 1: day 0 roll → lone_wolf THREAT, modal 'A starving wolf circles
  your livestock.' with Prepare/Dismiss + auto-pause (tick 1 frozen).
  Resolve → tension 27→42, sim resumed.
- Boot 2: day 0 roll → an_old_map LORE, top-center banner, non-blocking.
  Banner path + modal path both visually confirmed.

Deferred to Phase 17 polish:
- EventBus.request_wolf_spawn signal — wolf-spawn effects log-stub today
- Wanderer recruit UI (modal currently dismisses, pawn add deferred)
- Resource buff system (next-N-jobs multipliers)
- 3+ choice modals (current UI renders first 2)
- .tres event resources (currently code-as-data factories)

Delegation: 3× gdscript-refactor (Sonnet) agents in parallel;
modal-hide fix on Opus; integration + MCP verify on Opus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:01:35 +01:00

126 lines
4.4 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.

extends Camera2D
## World-view camera rig — pinch-zoom, drag-pan, double-tap-centre.
## All input handled via _unhandled_input so CanvasLayer UI overlays swallow first.
## Call set_world_bounds(rect) once from the world scene after map generation.
const MIN_ZOOM: float = 0.5 # strategic / whole-map-ish
const MAX_ZOOM: float = 4.0 # close / sprite-readable
const BOUNDS_BLEED_PX: int = 32 # 2 tiles of bleed at map edge
const DOUBLE_TAP_MS: int = 300 # window for double-tap detection
const DOUBLE_TAP_DIST_PX: float = 16.0 # 1 tile radius
var target_zoom: float = 1.0
var _dragging: bool = false # desktop left-mouse drag tracking
var _last_tap_time_ms: int = 0
var _last_tap_world_pos: Vector2 = Vector2.ZERO
var _centre_tween: Tween = null
func _ready() -> void:
position_smoothing_enabled = false
# Phase 5 visual polish: default 2.5× — at 1× the world feels sparse on phone
# and pawns become 6-pixel dots; 2.5× shows ~30 tiles wide which is the
# "comfortable inspection" zoom level.
target_zoom = 2.5
zoom = Vector2(target_zoom, target_zoom)
func _process(delta: float) -> void:
# Lerp zoom toward target each render frame.
# Plain 0.15 factor is correct at 60 fps; frame-rate independent form used for safety.
var t: float = 1.0 - pow(1.0 - 0.15, delta * 60.0)
zoom = zoom.lerp(Vector2(target_zoom, target_zoom), t)
func _unhandled_input(event: InputEvent) -> void:
# --- Pinch-zoom via magnify gesture (trackpad + native touch pinch) ---
if event is InputEventMagnifyGesture:
target_zoom = clampf(target_zoom * event.factor, MIN_ZOOM, MAX_ZOOM)
return
# --- Scroll-wheel zoom (desktop) ---
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
target_zoom = clampf(target_zoom * 1.1, MIN_ZOOM, MAX_ZOOM)
return
if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
target_zoom = clampf(target_zoom / 1.1, MIN_ZOOM, MAX_ZOOM)
return
# --- Desktop left-mouse: drag-pan tracking + double-tap detection ---
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_dragging = true
_check_double_tap(event.position)
else:
_dragging = false
return
# --- Touch drag-pan ---
if event is InputEventScreenDrag:
_apply_pan(event.relative)
return
# --- Desktop mouse-motion drag-pan (only while left mouse held) ---
if event is InputEventMouseMotion and _dragging:
_apply_pan(event.relative)
return
# --- Touch press: double-tap detection ---
if event is InputEventScreenTouch and event.pressed:
_check_double_tap(event.position)
func _apply_pan(relative: Vector2) -> void:
# zoom.x > 1 means more zoomed-in; each screen pixel = fewer world units.
position -= relative / zoom.x
func _check_double_tap(screen_pos: Vector2) -> void:
var now_ms: int = Time.get_ticks_msec()
var world_pos: Vector2 = get_canvas_transform().affine_inverse() * screen_pos
var dt: int = now_ms - _last_tap_time_ms
var dist: float = world_pos.distance_to(_last_tap_world_pos)
if dt < DOUBLE_TAP_MS and dist < DOUBLE_TAP_DIST_PX:
_centre_on(world_pos)
# Reset so a triple-tap doesn't re-trigger immediately
_last_tap_time_ms = 0
else:
_last_tap_time_ms = now_ms
_last_tap_world_pos = world_pos
func _centre_on(world_pos: Vector2) -> void:
# Kill any in-progress centre tween before starting a new one
if _centre_tween != null and _centre_tween.is_valid():
_centre_tween.kill()
_centre_tween = create_tween()
_centre_tween.set_ease(Tween.EASE_OUT)
_centre_tween.set_trans(Tween.TRANS_QUAD)
_centre_tween.tween_property(self, "position", world_pos, 0.2)
## Public API — smooth-pan the camera to the centre of a tile (world space).
## Used by StorytellerBanner and StorytellerModal "Go there" buttons.
## Reuses the same _centre_tween slot so a rapid second call replaces the first.
func pan_to_tile(tile: Vector2i) -> void:
const TILE_PX: int = 16
var world_pos := Vector2(tile.x * TILE_PX + TILE_PX / 2, tile.y * TILE_PX + TILE_PX / 2)
_centre_on(world_pos)
Audit.log("camera", "pan_to_tile %s → world %s" % [tile, world_pos])
## Public API — call once from the world scene after map bounds are known.
func set_world_bounds(rect: Rect2) -> void:
limit_left = int(rect.position.x) - BOUNDS_BLEED_PX
limit_top = int(rect.position.y) - BOUNDS_BLEED_PX
limit_right = int(rect.end.x) + BOUNDS_BLEED_PX
limit_bottom = int(rect.end.y) + BOUNDS_BLEED_PX
Audit.log("camera", "bounds set: %s (bleed %d)" % [rect, BOUNDS_BLEED_PX])