diff --git a/apps/game/localization/moonlit.csv b/apps/game/localization/moonlit.csv index ff1671b..21a8f44 100644 --- a/apps/game/localization/moonlit.csv +++ b/apps/game/localization/moonlit.csv @@ -379,3 +379,27 @@ STORY_CYCLE_8_A,셋으로는 모자란다. 밤마다 다시 먹힌다,Three is n STORY_CYCLE_8_B,봉화를 하나로 이어야 한다. 끝까지 가자,The beacons must join into one line. Go to the end,烽火をひとつに繋がねばならない。最後まで行こう,必须把烽火连成一条线。走到最后,必須把烽火連成一條線。走到最後 STORY_CYCLE_9_A,여덟 번째 선이 이어졌다. 약속은 지켰다,The eighth line is joined. The promise is kept,八本目の線が繋がった。約束は守った,第八道界线连上了。承诺已经兑现,第八道界線連上了。承諾已經兌現 STORY_CYCLE_9_B,여기부터는 지도에 없는 밤이다. 얼마나 깊은지 보자,Past here the night is off the map. Let us see how deep it goes,ここから先は地図にない夜だ。どこまで深いか見てやろう,再往前是地图上没有的黑夜。看看它有多深,再往前是地圖上沒有的黑夜。看看它有多深 +STORY_OPEN_A,파수단은 무너졌다. 나만 남았다,The Wardens fell. Only I remain,見張り団は倒れた。残ったのは私だけだ,守烽人倒下了。只剩下我,守烽人倒下了。只剩下我 +STORY_OPEN_B,봉화를 잇는 게 내가 밤에 진 빚이다,Joining the beacons is my debt to the night,烽火を繋ぐのが夜への借りだ,连起烽火是我欠黑夜的债,連起烽火是我欠黑夜的債 +STORY_EPITAPH_WIN,선이 이어졌다. 이 땅의 밤은 다시 지도에 있다,The line is joined. This land's night is on the map again,線は繋がった。この地の夜は再び地図にある,界线连上了。这片土地的夜重回地图,界線連上了。這片土地的夜重回地圖 +STORY_EPITAPH_ESCAPE,오늘 밤은 여기까지다. 불씨를 간직하자,Tonight ends here. Keep the ember,今宵はここまでだ。火種を守ろう,今夜到此为止。守住火种,今夜到此為止。守住火種 +STORY_EPITAPH_LOSE,빚은 다음 밤으로 넘어간다,The debt passes to the next night,借りは次の夜へ渡る,债传给了下一夜,債傳給了下一夜 +VOICE_MEET_DRIFTER_1,밤박쥐다 · 움직이는 빛을 문다,Night bats · they bite what moves and glows,夜蝠だ · 動く光に噛みつく,夜蝠 · 专咬会动的光,夜蝠 · 專咬會動的光 +VOICE_MEET_EMBER_1,꺼지지 않은 불씨다 · 타는 데 동무를 구한다,A cinder unquenched · it wants company in burning,消え残った火種だ · 燃える仲間を求めている,没熄透的火种 · 想找个一起烧的伴,沒熄透的火種 · 想找個一起燒的伴 +VOICE_MEET_CASTER_1,주목을 구해 주문을 외운다 · 그 탄은 기도다,It chants to be noticed · its shots are prayers,注目を求めて呪文を唱える · その弾は祈りだ,它念咒求关注 · 那些弹是祷告,它念咒求關注 · 那些彈是禱告 +VOICE_MEET_WEAVER_1,빛 위에 어둠을 깁는다 · 꿰매는 중이라 믿는다,Stitching dark over light · it thinks it mends,光の上に闇を縫う · 繕っているつもりだ,它把黑暗缝在光上 · 以为自己在缝补,它把黑暗縫在光上 · 以為自己在縫補 +VOICE_MEET_STALKER_1,지기였던 걸 기억한다 · 잃은 불을 사냥한다,Once a keeper · it hunts the flame it lost,守り人だったことを覚えている · 失くした火を狩る,它记得自己当过守烽人 · 在猎它弄丢的火,它記得自己當過守烽人 · 在獵它弄丟的火 +VOICE_MEET_SWARM_1,날개 달린 허기다 · 생각 없이 입만 있다,Hunger with wings · mouths without minds,翼ある飢えだ · 考えはなく口だけだ,长翅膀的饥饿 · 没脑子只有嘴,長翅膀的飢餓 · 沒腦子只有嘴 +VOICE_MEET_WISP_1,호기심 많은 불씨 · 몰리면 무서워 쏜다,Curious little flame · it stings when crowded,好奇心の強い小さな火 · 群れると怖くて刺す,好奇的小火 · 被围住吓到就蜇人,好奇的小火 · 被圍住嚇到就蜇人 +VOICE_MEET_GUARDIAN_FOREST_1,숲이 빚은 추격자 · 돌진도 빛의 기억이다,Forest pursuer · even its charges remember light,森が練った追跡者 · 突進すら光の記憶だ,森林捏出的追猎者 · 连冲撞都是光的记忆,森林捏出的追獵者 · 連衝撞都是光的記憶 +VOICE_MEET_GUARDIAN_FIELD_1,들에 깔린 빛이 날개가 됐다 · 쏘는 것도 빛이다,Field light grew wings · its shots are light too,野に散った光が翼になった · 放つ弾も光だ,撒在原野的光长出了翅膀 · 射出的也是光,撒在原野的光長出了翅膀 · 射出的也是光 +VOICE_MEET_GUARDIAN_CAMP_1,화덕의 불을 삼킨 장군 · 갑옷 아래 아직 온기가 있다,Hearthswallower · warm under the armor,かまどの火を飲んだ将 · 甲の下はまだ温かい,吞了灶火的将军 · 铠甲下面还暖着,吞了灶火的將軍 · 盔甲下面還暖著 +VOICE_MEET_GUARDIAN_FOREST_THORN_1,가시왕 · 오래된 빛일수록 가시가 깊다,The Thorn King · older light · deeper thorns,茨王 · 古い光ほど茨は深い,荆棘王 · 光越老刺越深,荊棘王 · 光越老刺越深 +VOICE_MEET_GUARDIAN_FIELD_STORM_1,폭풍의 날개 · 화가 난 밤이 벼락을 얹었다,Storm wings · the angry night loaded lightning,嵐の翼 · 怒った夜が雷を載せた,风暴之翼 · 发怒的夜装上了雷,風暴之翼 · 發怒的夜裝上了雷 +VOICE_MEET_GUARDIAN_CAMP_SIEGE_1,공성 장군 · 문을 두드리던 게 이제 문을 부순다,Siege general · what knocked now breaks in,攻城将 · 叩いていたものが今は押し入る,攻城将军 · 从前敲门的如今破门,攻城將軍 · 從前敲門的如今破門 +VOICE_CYCLE_3,지도 밖 밤은 깊이를 모른다 · 한 선씩 긋자,No bottom out here · draw one line at a time,地図外れの夜に底はない · 一本ずつ線を引こう,地图外的夜没底 · 一条一条画线吧,地圖外的夜沒底 · 一條一條畫線吧 +VOICE_CYCLE_4,꺼진 봉화가 밤을 먹인다 · 굶겨라,Each snuffed beacon feeds the night · starve it,消える烽火が夜を肥やす · 飢えさせろ,灭一座烽火夜就饱一分 · 饿着它,滅一座烽火夜就飽一分 · 餓著它 +VOICE_CYCLE_5,수호자도 지친다 · 빛은 지치지 않는다,Even guardians tire · light does not,守護者も疲れる · 光は疲れない,守护者也会累 · 光不会,守護者也會累 · 光不會 +VOICE_CYCLE_6,몇 번째 밤인지 세지 마라 · 선이 몇 개인지 세라,Do not count the nights · count the lines,夜を数えるな · 線を数えろ,别数第几夜 · 数几条线,別數第幾夜 · 數幾條線 +VOICE_CALL_DARK_1,어둠을 부른다 · 날개들이 대답한다,It calls the dark · wings answer,闇を呼ぶ · 翼が答える,它在喊黑暗 · 翅膀应了,它在喊黑暗 · 翅膀應了 +VOICE_CALL_DARK_2,혼자 못 이기겠지 · 그래서 무리를 부른다,It cannot win alone · so it calls the pack,独りでは勝てない · だから群れを呼ぶ,单挑赢不了 · 所以喊群,單挑贏不了 · 所以喊群 diff --git a/apps/game/scenes/ui/result_panel.tscn b/apps/game/scenes/ui/result_panel.tscn index 739da63..7162781 100644 --- a/apps/game/scenes/ui/result_panel.tscn +++ b/apps/game/scenes/ui/result_panel.tscn @@ -36,7 +36,7 @@ anchor_bottom = 0.5 offset_top = -150.0 offset_bottom = -112.0 grow_horizontal = 2 -grow_vertical = 2 +grow_vertical = 0 mouse_filter = 2 theme_override_colors/font_color = Color(1, 0.95, 0.82, 1) theme_override_fonts/font = ExtResource("font") @@ -44,6 +44,23 @@ theme_override_font_sizes/font_size = 33 text = "RESULT_WIN" horizontal_alignment = 1 +[node name="Epitaph" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 14 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_top = -112.0 +offset_bottom = -94.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +theme_override_colors/font_color = Color(0.7, 0.75, 0.88, 1) +theme_override_fonts/font = ExtResource("font") +theme_override_font_sizes/font_size = 11 +horizontal_alignment = 1 +vertical_alignment = 1 + [node name="Detail" type="Label" parent="."] layout_mode = 1 anchors_preset = 8 @@ -56,7 +73,7 @@ offset_top = -94.0 offset_right = 47.0 offset_bottom = 16.0 grow_horizontal = 2 -grow_vertical = 2 +grow_vertical = 1 mouse_filter = 2 theme_override_colors/font_color = Color(0.83, 0.87, 0.96, 1) theme_override_fonts/font = ExtResource("font") @@ -90,8 +107,8 @@ anchors_preset = 14 anchor_top = 0.5 anchor_right = 1.0 anchor_bottom = 0.5 -offset_top = 25.0 -offset_bottom = 43.0 +offset_top = 33.0 +offset_bottom = 51.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -106,8 +123,8 @@ anchors_preset = 14 anchor_top = 0.5 anchor_right = 1.0 anchor_bottom = 0.5 -offset_top = 46.0 -offset_bottom = 64.0 +offset_top = 54.0 +offset_bottom = 72.0 grow_horizontal = 2 grow_vertical = 2 mouse_filter = 2 @@ -124,9 +141,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -279.0 -offset_top = 76.0 +offset_top = 84.0 offset_right = 279.0 -offset_bottom = 108.0 +offset_bottom = 116.0 grow_horizontal = 2 grow_vertical = 2 theme_override_constants/separation = 10 diff --git a/apps/game/scripts/actors/spirit.gd b/apps/game/scripts/actors/spirit.gd index 71fed78..a6c6837 100644 --- a/apps/game/scripts/actors/spirit.gd +++ b/apps/game/scripts/actors/spirit.gd @@ -83,14 +83,23 @@ const FOREST_FOLLOWUP_WINDUP: float = 0.46 const FOREST_CHARGE_SECONDS: float = 0.40 const FOREST_BETWEEN_CHARGES: float = 0.24 const FOREST_RECOVER_SECONDS: float = 0.82 +## After the charge combo lands, slam a shockwave ring. Charges punish standing +## still; the ring punishes the sidestep they taught — hold ground in the gap. +const FOREST_RING_WINDUP: float = 0.78 const FIELD_STRAFE_SECONDS: float = 2.15 const FIELD_VOLLEY_WINDUP: float = 0.78 const FIELD_RECOVER_SECONDS: float = 0.82 +## Strafe used to be dead time. One aimed bolt per strafe keeps the dodge honest. +const FIELD_POKE_INTERVAL: float = 2.1 +const FIELD_POKE_WINDUP: float = 0.35 const CAMP_APPROACH_SECONDS: float = 2.35 const CAMP_VOLLEY_WINDUP: float = 1.0 const CAMP_RECOVER_SECONDS: float = 1.45 +## The second fan re-aims at where the player fled. One fan is a checkpoint; +## two is a question. +const CAMP_FOLLOWUP_WINDUP: float = 0.55 enum GuardianMove { CHASE, @@ -162,6 +171,9 @@ var _guardian_move_haste: float = 1.0 var _guardian_combo_left: int = 0 ## Field barrage 0=cross, 1=radial with a safe gap. var _guardian_pattern: int = 0 +## Time until the field guardian's next aimed bolt while strafing. +## Starts half-ready so the opening strafe pokes once, never on frame one. +var _guardian_poke_left: float = FIELD_POKE_INTERVAL * 0.5 ## Short white edge flash when camp armor blocks damage. var _camp_guard_flash: float = 0.0 var _telegraph: Telegraph = Telegraph.NONE @@ -884,9 +896,27 @@ func _do_guardian_forest(delta: float, to_player: Vector2) -> Vector2: velocity = _locked * _guardian_charge_speed() if _guardian_left <= 0.0: velocity *= 0.2 - var rest: float = FOREST_BETWEEN_CHARGES \ - if _guardian_combo_left > 0 else FOREST_RECOVER_SECONDS - _start_guardian_move(GuardianMove.RECOVER, rest) + if _guardian_combo_left > 0: + _start_guardian_move( + GuardianMove.RECOVER, FOREST_BETWEEN_CHARGES) + else: + # Combo spent. Slam the ring before resting — the gap faces + # the player, so the answer is the opposite of the charges. + _locked = inward + _guardian_pattern = 1 + _start_guardian_move( + GuardianMove.VOLLEY_WINDUP, FOREST_RING_WINDUP) + return _locked + + GuardianMove.VOLLEY_WINDUP: + velocity = velocity.lerp(Vector2.ZERO, 7.0 * delta) + _set_telegraph(Telegraph.VOLLEY, _locked, + 1.0 - _guardian_left / _guardian_move_duration) + if _guardian_left <= 0.0: + _fire_forest_ring() + _clear_telegraph() + _start_guardian_move( + GuardianMove.RECOVER, FOREST_RECOVER_SECONDS) return _locked _: @@ -911,10 +941,22 @@ func _do_guardian_field(delta: float, to_player: Vector2) -> Vector2: match _guardian_move: GuardianMove.CHASE: var wish: Vector2 = _field_strafe(delta, to_player) + # Transition first. When the poke timer and the strafe end on the + # same tick, firing the poke as well stacks a bolt onto the volley. if _guardian_left <= 0.0: _locked = inward _start_guardian_move( GuardianMove.VOLLEY_WINDUP, FIELD_VOLLEY_WINDUP) + return wish + _guardian_poke_left -= delta * _guardian_move_haste + if _guardian_poke_left <= FIELD_POKE_WINDUP \ + and _guardian_poke_left > 0.0: + _set_telegraph(Telegraph.AIM, inward, + 1.0 - _guardian_poke_left / FIELD_POKE_WINDUP) + if _guardian_poke_left <= 0.0: + _clear_telegraph() + _fire(inward) + _guardian_poke_left = FIELD_POKE_INTERVAL return wish GuardianMove.VOLLEY_WINDUP: @@ -932,6 +974,7 @@ func _do_guardian_field(delta: float, to_player: Vector2) -> Vector2: _: velocity = velocity.lerp(Vector2.ZERO, 5.0 * delta) if _guardian_left <= 0.0: + _guardian_poke_left = FIELD_POKE_INTERVAL * 0.5 _start_guardian_move( GuardianMove.CHASE, FIELD_STRAFE_SECONDS) return inward @@ -965,6 +1008,7 @@ func _do_guardian_camp(delta: float, to_player: Vector2) -> Vector2: kind.turn_rate * delta) if _guardian_left <= 0.0: _locked = inward + _guardian_combo_left = 1 _start_guardian_move( GuardianMove.VOLLEY_WINDUP, CAMP_VOLLEY_WINDUP) return inward @@ -976,8 +1020,14 @@ func _do_guardian_camp(delta: float, to_player: Vector2) -> Vector2: if _guardian_left <= 0.0: _fire_camp_fan() _clear_telegraph() - _start_guardian_move( - GuardianMove.RECOVER, _camp_recover_seconds()) + if _guardian_combo_left > 0: + _guardian_combo_left -= 1 + _locked = inward + _start_guardian_move( + GuardianMove.VOLLEY_WINDUP, CAMP_FOLLOWUP_WINDUP) + else: + _start_guardian_move( + GuardianMove.RECOVER, _camp_recover_seconds()) return _locked _: @@ -1024,6 +1074,21 @@ func _fire_field_volley() -> void: bolt.set_speed_scale(0.72) +## Shockwave ring after the charge combo. Fewer spokes than the field radial, +## same promise: the gap faces the player, so the dodge is to hold ground. +func _fire_forest_ring() -> void: + var base: float = _locked.angle() + var extra: int = maxi(guardian_cycle - 1, 0) + if kind != null: + extra += kind.guardian_volley_extra + var spokes: int = 8 + mini(extra, 4 + _late_cycles()) + var hole: int = 2 if extra <= 0 else 1 + for i in spokes: + if i <= hole or i >= spokes - hole: + continue + _fire(Vector2.RIGHT.rotated(base + TAU * float(i) / float(spokes))) + + ## Aim fan. Higher cycles add spokes and narrow the safe lane. func _fire_camp_fan() -> void: var base: float = _locked.angle() diff --git a/apps/game/scripts/gameplay/arena.gd b/apps/game/scripts/gameplay/arena.gd index 27a8971..febe7c8 100644 --- a/apps/game/scripts/gameplay/arena.gd +++ b/apps/game/scripts/gameplay/arena.gd @@ -446,6 +446,12 @@ var _debug_hero_direction_request_signature: String = "" ## Cycles keep "where's the next beacon" coming. ## Hero voice lines. Reset each run so the same line is not heard twice. var _voice: HeroVoice = HeroVoice.new() +## First-sight moments already said this run, by HeroVoice moment key. +## Balloons have no queue, so each kind speaks once and never steals +## the strip again. See notes/plans/world-story-pass.md. +var _seen_spirits: Dictionary = {} +var _seen_guardians: Dictionary = {} +var _guardian_called: bool = false var _voice_panel: VoicePanel = null var _cycle: int = 1 @@ -658,6 +664,9 @@ func _ready() -> void: # all three store screenshots went out that way. Built in code, not a scene file, so it also # follows the reduced arenas capture and tests use. _voice.reset() + _seen_spirits.clear() + _seen_guardians.clear() + _guardian_called = false _voice_panel = VOICE_PANEL.new() var ui_layer: CanvasLayer = get_node_or_null("Ui") as CanvasLayer if ui_layer != null: @@ -1377,7 +1386,7 @@ func _publish_debug_missile_capture(missile_power_before: int) -> void: var world_key: String = str(_world_step().get("name", "")) var time_step: Dictionary = _time_step() var time_key: String = str(time_step.get("name", "")) - var expected_tone: Color = time_step.get("tone", Color.TRANSPARENT) + var expected_tone: Color = time_step.get("tone", Color.TRANSPARENT) * _cycle_shade() var applied_tone: Color = _rooms_root.modulate var time_tone_applied: bool = applied_tone.is_equal_approx(expected_tone) # 03 is an ad shot of the night forest at Lv10 · cycle 1. Pin real Arena state too so the same @@ -2374,7 +2383,7 @@ func debug_store_capture_state(request: Dictionary) -> Dictionary: var world_key: String = str(_world_step().get("name", "")) var time_step: Dictionary = _time_step() var time_key: String = str(time_step.get("name", "")) - var expected_tone: Color = time_step.get("tone", Color.TRANSPARENT) + var expected_tone: Color = time_step.get("tone", Color.TRANSPARENT) * _cycle_shade() var applied_tone: Color = _rooms_root.modulate var expected_barrage_banner: String = tr("MISSILE_COMPLETE") % [ _missile_power, MissileProgression.MAX_POWER] @@ -2468,7 +2477,7 @@ func debug_store_capture_state(request: Dictionary) -> Dictionary: var world_key: String = str(_world_step().get("name", "")) var time_step: Dictionary = _time_step() var time_key: String = str(time_step.get("name", "")) - var expected_tone: Color = time_step.get("tone", Color.TRANSPARENT) + var expected_tone: Color = time_step.get("tone", Color.TRANSPARENT) * _cycle_shade() var applied_tone: Color = _rooms_root.modulate var guardian_banner: Dictionary = _hud.debug_capture_banner_snapshot() var guardian_expected_banner: String = "" @@ -2927,6 +2936,7 @@ func _process(delta: float) -> void: _tick_tutorial() _tick_shake(delta) _tick_overcharge(delta) + _tick_guardian_call() _dash_button.set_ratio(_player.get_dash_ratio()) _point_compass(delta) @@ -3051,8 +3061,16 @@ func _flush_cycle_story() -> void: ## `_A` reacts to what just happened; `_B` is what to do next. One line cannot hold both ## "what happened" and "so what now," so neither sticks. ## Missing lines are skipped quietly, so a cycle with only one line still works. +## +## Cycle 1 opens the run, so the Warden's debt comes first: who remains and what is +## owed. Without it the run starts with instructions and never says why. func _story_lines(cycle: int) -> Array[String]: var lines: Array[String] = [] + if cycle == 1: + for key in ["STORY_OPEN_A", "STORY_OPEN_B"]: + var open_line: String = tr(key) + if open_line != key: + lines.append(open_line) for suffix in ["A", "B"]: var key: String = "STORY_CYCLE_%d_%s" % [cycle, suffix] var line: String = tr(key) @@ -3100,6 +3118,16 @@ func _time_step() -> Dictionary: return TIME_STEPS[clampi(_lit_count, 0, TIME_STEPS.size() - 1)] +## Later cycles read colder at the same beacons. Capture-state checks +## compare against the shaded tone, so update them together. +func _cycle_shade() -> Color: + if _cycle >= 7: + return Color(0.88, 0.92, 1.0, 1.0) + if _cycle >= 4: + return Color(0.95, 0.96, 1.0, 1.0) + return Color.WHITE + + ## Bake one room. ## ## The seed mixes run, cycle, and terrain. The same cycle of the same run keeps the same layout, @@ -3381,7 +3409,7 @@ func _announce_world_rule_if_current(serial: int) -> void: ## Show beacon progress as night → blue dawn → sunrise → day. func _apply_time_tone(animated: bool = true) -> void: var time: Dictionary = _time_step() - var tone: Color = time["tone"] + var tone: Color = time["tone"] * _cycle_shade() if _brighten != null and _brighten.is_valid(): _brighten.kill() @@ -5130,9 +5158,23 @@ func _summon( add_child(spirit) _register_spirit(spirit) spirit.materialize() + _announce_first_sight(kind_path) return spirit +## Say why this kind fights, once per run on first sight. +## +## The moment key is `meet_` from the resource filename, so a new +## spirit is one `.tres` plus one HeroVoice row. Unknown kinds stay quiet +## instead of swallowing the strip with an empty take. +func _announce_first_sight(kind_path: String) -> void: + var moment: String = "meet_" + kind_path.get_file().get_basename() + if _seen_spirits.has(moment) or not HeroVoice.LINES.has(moment): + return + _seen_spirits[moment] = true + _say(moment) + + ## How tough this cycle's spirits are. ## ## **The spine of endless play.** Cycles 1–3 grow 35% each to keep the old tempo; from cycle 4 @@ -5392,8 +5434,42 @@ func _summon_guardian() -> void: # in HP and color — learn this terrain's rule before taking the first pattern. _hud.announce(tr("GUARDIAN_INTRO") % [ tr(boss.display_name), tr(boss.guardian_rule)], boss.boss_accent) + _guardian_called = false + _announce_guardian_meet() + + +## First meeting per guardian per run speaks its own line; later meetings +## fall back to the generic guardian moment. Only one `_say` per spawn — +## the strip has no queue and the last call wins. +func _announce_guardian_meet() -> void: + var moment: String = "meet_" + _guardian_resource_path().get_file().get_basename() + if not _seen_guardians.has(moment) and HeroVoice.LINES.has(moment): + _seen_guardians[moment] = true + _say(moment) + return _say("guardian") + +## At half health, once per guardian, it calls two swarm escorts. +## +## Skipped while capture progress is frozen: a mid-shot summon changes +## the store frame and ejects the capture. +func _tick_guardian_call() -> void: + if _guardian_called or _over or _transitioning or _capture_progress_frozen: + return + if _guardian == null or not is_instance_valid(_guardian): + return + # `_health` is 0 until materialize runs, so the ratio reads 0 at spawn. + # Firing there would overwrite the first-meeting line on the same strip. + if int(_guardian.get("_health")) <= 0: + return + if _guardian.get_health_ratio() >= 0.5: + return + _guardian_called = true + _summon(_guardian.position + Vector2(60, 0), "res://resources/swarm.tres") + _summon(_guardian.position + Vector2(-60, 0), "res://resources/swarm.tres") + _say("call_dark") + # Music changes. The ear knows the finale first. _bgm.stop() _bgm.stream = GUARDIAN_THEME diff --git a/apps/game/scripts/gameplay/hero_voice.gd b/apps/game/scripts/gameplay/hero_voice.gd index e5aaab5..af8c8da 100644 --- a/apps/game/scripts/gameplay/hero_voice.gd +++ b/apps/game/scripts/gameplay/hero_voice.gd @@ -23,7 +23,24 @@ const LINES: Dictionary = { "guardian_down": ["VOICE_GUARDIAN_DOWN_1", "VOICE_GUARDIAN_DOWN_2"], "low_health": ["VOICE_LOW_1", "VOICE_LOW_2"], "moonfire": ["VOICE_MOONFIRE_1", "VOICE_MOONFIRE_2"], - "cycle": ["VOICE_CYCLE_1", "VOICE_CYCLE_2"], + "cycle": [ + "VOICE_CYCLE_1", "VOICE_CYCLE_2", "VOICE_CYCLE_3", + "VOICE_CYCLE_4", "VOICE_CYCLE_5", "VOICE_CYCLE_6", + ], + "meet_drifter": ["VOICE_MEET_DRIFTER_1"], + "meet_ember": ["VOICE_MEET_EMBER_1"], + "meet_caster": ["VOICE_MEET_CASTER_1"], + "meet_weaver": ["VOICE_MEET_WEAVER_1"], + "meet_stalker": ["VOICE_MEET_STALKER_1"], + "meet_swarm": ["VOICE_MEET_SWARM_1"], + "meet_wisp": ["VOICE_MEET_WISP_1"], + "meet_guardian_forest": ["VOICE_MEET_GUARDIAN_FOREST_1"], + "meet_guardian_field": ["VOICE_MEET_GUARDIAN_FIELD_1"], + "meet_guardian_camp": ["VOICE_MEET_GUARDIAN_CAMP_1"], + "meet_guardian_forest_thorn": ["VOICE_MEET_GUARDIAN_FOREST_THORN_1"], + "meet_guardian_field_storm": ["VOICE_MEET_GUARDIAN_FIELD_STORM_1"], + "meet_guardian_camp_siege": ["VOICE_MEET_GUARDIAN_CAMP_SIEGE_1"], + "call_dark": ["VOICE_CALL_DARK_1", "VOICE_CALL_DARK_2"], } ## Translation keys already used this run. diff --git a/apps/game/scripts/ui/result_panel.gd b/apps/game/scripts/ui/result_panel.gd index 583a65d..d69be0b 100644 --- a/apps/game/scripts/ui/result_panel.gd +++ b/apps/game/scripts/ui/result_panel.gd @@ -38,6 +38,7 @@ const ROW_GAP: float = 0.08 const SUM_SECONDS: float = 0.42 @onready var _title: Label = $Title +@onready var _epitaph: Label = $Epitaph @onready var _detail: Label = $Detail @onready var _stamp: Label = $Stamp @onready var _hint: Label = $Hint @@ -88,6 +89,11 @@ func show_result( if won and score.cycles >= LEGEND_CYCLE \ else (tr("RESULT_ESCAPE") if won else tr("RESULT_LOSE")) _title.add_theme_color_override("font_color", WIN_COLOR if won else LOSE_COLOR) + # One closing story line under the title. Same outcome split as the title: + # kept-promise win, safe early return, or a debt passed to the next night. + _epitaph.text = tr("STORY_EPITAPH_WIN") \ + if won and score.cycles >= LEGEND_CYCLE \ + else (tr("STORY_EPITAPH_ESCAPE") if won else tr("STORY_EPITAPH_LOSE")) # Every line holds its place from the start; only the numbers count from 0. # diff --git a/apps/game/tests/test_guardian_moves.gd b/apps/game/tests/test_guardian_moves.gd new file mode 100644 index 0000000..8b33b8b --- /dev/null +++ b/apps/game/tests/test_guardian_moves.gd @@ -0,0 +1,166 @@ +extends SceneTree + +## Pin each guardian's second answer at cycle 3. +## +## One loop per boss made fights predictable: forest only charged, field strafed +## in dead time, camp fired one fan and napped. Forest now slams a shockwave ring +## after its combo, field pokes an aimed bolt per strafe, camp fires a re-aimed +## second fan. Step live Spirits and count real bolts; timing constants alone +## would pass even if the new states never ran. + +const SPIRIT_SCENE: PackedScene = preload("res://scenes/actors/spirit.tscn") +const SPIRIT_SCRIPT: Script = preload("res://scripts/actors/spirit.gd") +const CYCLE_THREE_TOUGHNESS: float = 1.35 * 1.35 +const STEP_SECONDS: float = 0.05 +const MAX_STEPS: int = 240 +const BOLT_META: StringName = &"moonlit_pending_hostile_bolts" + +var _failed: int = 0 +var _checked: int = 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + _test_forest_ring() + _test_field_poke() + _test_camp_double_fan() + _finish() + + +func _test_forest_ring() -> void: + _clear_bolts() + var before: int = _bolt_count() + var spirit: Node2D = _spawn("res://resources/guardian_forest.tres") + var saw_ring_windup: bool = false + var saw_ring_telegraph: bool = false + var saw_recover_after_ring: bool = false + var ring_fired: bool = false + var loop_closed: bool = false + for step in MAX_STEPS: + spirit.call("_physics_process", STEP_SECONDS) + var move: int = int(spirit.get("_guardian_move")) + if move == SPIRIT_SCRIPT.GuardianMove.VOLLEY_WINDUP: + saw_ring_windup = true + if int(spirit.get("_guardian_pattern")) == 1 \ + and int(spirit.get("_telegraph")) == SPIRIT_SCRIPT.Telegraph.VOLLEY: + saw_ring_telegraph = true + if saw_ring_windup and _bolt_count() - before > 0: + ring_fired = true + if ring_fired and move == SPIRIT_SCRIPT.GuardianMove.RECOVER: + saw_recover_after_ring = true + if saw_recover_after_ring and move == SPIRIT_SCRIPT.GuardianMove.CHASE: + loop_closed = true + break + _expect_true(saw_ring_windup, "forest slams a volley after its charge combo") + _expect_true( + saw_ring_telegraph, "forest ring telegraphs radial with a safe gap") + _expect_true(ring_fired, "forest ring fires real bolts") + # Cycle 3: 10 spokes, one hole each side of the player lane — 7 bolts. + _expect_equal(_bolt_count() - before, 7, "forest ring leaves its gap open") + _expect_true( + saw_recover_after_ring, "forest rests after the ring, not before") + _expect_true(loop_closed, "forest loop closes back to chase") + spirit.free() + + +func _test_field_poke() -> void: + _clear_bolts() + var before: int = _bolt_count() + var spirit: Node2D = _spawn("res://resources/guardian_field.tres") + var poke_before_volley: int = -1 + var saw_aim: bool = false + for step in MAX_STEPS: + spirit.call("_physics_process", STEP_SECONDS) + var move: int = int(spirit.get("_guardian_move")) + if move == SPIRIT_SCRIPT.GuardianMove.CHASE \ + and int(spirit.get("_telegraph")) == SPIRIT_SCRIPT.Telegraph.AIM: + saw_aim = true + if move == SPIRIT_SCRIPT.GuardianMove.VOLLEY_WINDUP: + poke_before_volley = _bolt_count() - before + break + _expect_true(saw_aim, "field poke shows an aim line while strafing") + _expect_equal(poke_before_volley, 1, "field fires exactly one poke per strafe") + spirit.free() + + +func _test_camp_double_fan() -> void: + _clear_bolts() + var before: int = _bolt_count() + var spirit: Node2D = _spawn("res://resources/guardian_camp.tres") + # The follow-up windup restarts inside the same move, so count entries by the + # duration reset (1.0s first aim, 0.55s re-aim), not by move edges. + var windup_durations: Dictionary = {} + var saw_recover: bool = false + var loop_closed: bool = false + for step in MAX_STEPS: + spirit.call("_physics_process", STEP_SECONDS) + var move: int = int(spirit.get("_guardian_move")) + if move == SPIRIT_SCRIPT.GuardianMove.VOLLEY_WINDUP and not saw_recover: + windup_durations[float(spirit.get("_guardian_move_duration"))] = true + if move == SPIRIT_SCRIPT.GuardianMove.RECOVER: + saw_recover = true + if saw_recover and move == SPIRIT_SCRIPT.GuardianMove.CHASE: + loop_closed = true + break + _expect_true( + windup_durations.has(1.0) and windup_durations.has(0.55), + "camp re-aims a second fan before resting") + # Cycle 3: 7-bolt fan, twice. + _expect_equal(_bolt_count() - before, 14, "camp double fan fires both volleys") + _expect_true(loop_closed, "camp loop closes back to approach") + spirit.free() + + +func _spawn(resource_path: String) -> Node2D: + var kind: SpiritKind = ( + load(resource_path) as SpiritKind).duplicate() + var spirit: Node2D = SPIRIT_SCENE.instantiate() as Node2D + spirit.set("kind", kind) + spirit.set("toughness", CYCLE_THREE_TOUGHNESS) + spirit.set("guardian_cycle", 3) + spirit.process_mode = Node.PROCESS_MODE_DISABLED + root.add_child(spirit) + var dummy: Node2D = Node2D.new() + dummy.position = Vector2(4000, 0) + root.add_child(dummy) + spirit.set("_target", dummy) + spirit.set("_materialized", true) + return spirit + + +## Bolts fired so far, whether or not the deferred tree join already flushed. +## The reservation lives on the tree itself, not the root window. +func _bolt_count() -> int: + return int(get_meta(BOLT_META, 0)) \ + + get_node_count_in_group("hostile_projectiles") + + +func _clear_bolts() -> void: + set_meta(BOLT_META, 0) + for bolt in get_nodes_in_group("hostile_projectiles"): + if is_instance_valid(bolt): + bolt.free() + + +func _finish() -> void: + if _failed > 0: + printerr("guardian-moves test failed — ", _failed, "/", _checked, " case(s)") + quit(1) + return + print("guardian-moves test passed — ", _checked, " case(s)") + quit(0) + + +func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: + _checked += 1 + if actual == expected: + return + _failed += 1 + printerr(" FAIL ", label, " — expected=", expected, " actual=", actual) + + +func _expect_true(actual: bool, label: String) -> void: + _expect_equal(actual, true, label) diff --git a/apps/game/tests/test_guardian_moves.gd.uid b/apps/game/tests/test_guardian_moves.gd.uid new file mode 100644 index 0000000..e7d085d --- /dev/null +++ b/apps/game/tests/test_guardian_moves.gd.uid @@ -0,0 +1 @@ +uid://whcvfsgqkwsu diff --git a/apps/game/tests/test_result_layout.gd b/apps/game/tests/test_result_layout.gd index 1306c7d..c6625b3 100644 --- a/apps/game/tests/test_result_layout.gd +++ b/apps/game/tests/test_result_layout.gd @@ -45,6 +45,11 @@ func _ready() -> void: (win_panel.get_node("Title") as Label).text == tr("RESULT_ESCAPE"), "%s pre-cycle-8 settlement uses the safe-return title" % locale) _expect_fits(win_panel.get_node("Title") as Label, "%s safe-return title" % locale) + _expect_true( + (win_panel.get_node("Epitaph") as Label).text == tr("STORY_EPITAPH_ESCAPE"), + "%s safe return shows the ember-keeping epitaph" % locale) + _expect_fits( + win_panel.get_node("Epitaph") as Label, "%s safe-return epitaph" % locale) win_panel.queue_free() await get_tree().process_frame @@ -56,6 +61,11 @@ func _ready() -> void: _expect_true( (legend_panel.get_node("Title") as Label).text == tr("RESULT_WIN"), "%s cycle-8 settlement uses the victory title" % locale) + _expect_true( + (legend_panel.get_node("Epitaph") as Label).text == tr("STORY_EPITAPH_WIN"), + "%s cycle-8 settlement shows the line-joined epitaph" % locale) + _expect_fits( + legend_panel.get_node("Epitaph") as Label, "%s victory epitaph" % locale) legend_panel.queue_free() await get_tree().process_frame @@ -70,6 +80,7 @@ func _ready() -> void: func _check_locale(panel: Control, locale: String) -> void: var title: Label = panel.get_node("Title") as Label + var epitaph: Label = panel.get_node("Epitaph") as Label var detail: Label = panel.get_node("Detail") as Label var stamp: Label = panel.get_node("Stamp") as Label var hint: Label = panel.get_node("Hint") as Label @@ -80,6 +91,10 @@ func _check_locale(panel: Control, locale: String) -> void: var shrine: Button = panel.get_node("Actions/Shrine") as Button _expect_fits(title, "%s title" % locale) + _expect_fits(epitaph, "%s closing story line" % locale) + _expect_true( + epitaph.text == tr("STORY_EPITAPH_LOSE"), + "%s defeat shows the debt-passing epitaph" % locale) _expect_fits(detail, "%s score table" % locale) _expect_fits(hint, "%s record and shards" % locale) _expect_fits(goal, "%s purchase goal" % locale) @@ -88,6 +103,7 @@ func _check_locale(panel: Control, locale: String) -> void: _expect_fits(shrine, "%s Shrine button" % locale) var title_rect: Rect2 = title.get_global_rect() + var epitaph_rect: Rect2 = epitaph.get_global_rect() var detail_rect: Rect2 = detail.get_global_rect() var stamp_rect: Rect2 = stamp.get_global_rect() var hint_rect: Rect2 = hint.get_global_rect() @@ -96,6 +112,10 @@ func _check_locale(panel: Control, locale: String) -> void: _expect_true( title_rect.end.y + SAFE_GAP <= detail_rect.position.y, "%s gap between title and score table" % locale) + _expect_true( + title_rect.end.y <= epitaph_rect.position.y + and epitaph_rect.end.y <= detail_rect.position.y, + "%s closing line sits between title and score table" % locale) _expect_true( detail_rect.end.x + SAFE_GAP <= stamp_rect.position.x, "%s gap between score table and rank (%s / %s)" % [ diff --git a/apps/game/tools/run_regression_tests.mjs b/apps/game/tools/run_regression_tests.mjs index 23f350f..577c338 100644 --- a/apps/game/tools/run_regression_tests.mjs +++ b/apps/game/tools/run_regression_tests.mjs @@ -41,6 +41,7 @@ const checks = [ ['IAP hero full-body previews', ['res://tests/test_iap_hero_previews.tscn'], true, false], ['Spirit and guardian custom sheets', ['--script', 'res://tests/test_spirit_visuals.gd'], true, false], ['Guardian burst vs normal-hit balance', ['--script', 'res://tests/test_guardian_balance.gd'], true, false], + ['Guardian second-answer moves', ['--script', 'res://tests/test_guardian_moves.gd'], true, false], ['Relic dual-effect resonance and loss clear', ['--script', 'res://tests/test_relic_resonance.gd'], true, false], ['Missile growth curve', ['--script', 'res://tests/test_missile_progression.gd'], true, false], ['Missile combat loop', ['res://tests/test_missile_loop.tscn'], true, false], diff --git a/notes/plans/world-story-pass.md b/notes/plans/world-story-pass.md new file mode 100644 index 0000000..bb99c87 --- /dev/null +++ b/notes/plans/world-story-pass.md @@ -0,0 +1,100 @@ +# World + story pass (bible) + +Author-only design authority for the story/world/boss/map polish. +Canon first, then the exact keys and wiring. Follow-up slices reference +this file; do not contradict it without updating it. + +## Canon + +- The dark is a hunger, not a villain. Beacons draw lines it cannot + cross (cycle 7). Burning three beacons a night pushes it back. +- Spirits do not hate the living (cycle 4). They follow light the way + moths do; the dark spits out what it cannot digest and they shamble + toward the nearest flame. Scattering them is pushing, not killing — + the score calls it "spirits scattered", never kills. +- Guardians are swallowed light clotted into a body (cycle 2, voice). + Each one keeps a habit of the place it ate: forest charges down + corridors, field answers with crossfire, camp turtles behind armor. + Elites are the same hunger older and angrier (cycle 6). +- The Warden order kept beacon lines across forest, field, and camp. + Playable heroes are its last members; the camp hearth (cycle 3) is + where a line failed before. +- Past cycle 9 the night is off the map. Nothing down there has a name + yet, so the Warden only mutters asides. + +## Spirit motivations (first-sight lines) + +One line each, said once per run on first sight. They explain why it +fights without pausing the game. + +| Kind | Truth | Key | +| --- | --- | --- | +| drifter (night bat) | Drawn to moving light; bites what glows | VOICE_MEET_DRIFTER_1 | +| ember | A cinder that never went out; wants company in burning | VOICE_MEET_EMBER_1 | +| caster | Chants to the dark to be noticed; its shots are prayers | VOICE_MEET_CASTER_1 | +| weaver | Stitches dark over light; thinks it is mending | VOICE_MEET_WEAVER_1 | +| stalker | Remembers being a keeper; hunts the flame it lost | VOICE_MEET_STALKER_1 | +| swarm | Hunger with wings; no thought, only mouths | VOICE_MEET_SWARM_1 | +| wisp | Curious and harmless until crowded; fear makes it sting | VOICE_MEET_WISP_1 | + +## Guardian first meetings + +One line each, said instead of the generic guardian line on the first +meeting per run. Name + dodge rule still come from the HUD banner. + +| Guardian | Key | +| --- | --- | +| Thornwood Pursuer | VOICE_MEET_GUARDIAN_FOREST_1 | +| Azure Fieldwing | VOICE_MEET_GUARDIAN_FIELD_1 | +| Emberclad Warden | VOICE_MEET_GUARDIAN_CAMP_1 | +| Thorn King Pursuer | VOICE_MEET_GUARDIAN_FOREST_THORN_1 | +| Storm Fieldwing | VOICE_MEET_GUARDIAN_FIELD_STORM_1 | +| Siegeclad Warden | VOICE_MEET_GUARDIAN_CAMP_SIEGE_1 | + +## Deep-night asides (cycles 10+) + +`_show_cycle_story` already falls back to `_say("cycle")` past 9. Four +more asides join VOICE_CYCLE_1/2: VOICE_CYCLE_3..6. No pausing dialogue +down there — balloons only. + +## Boss fun: call of the dark + +At 50% HP, once per guardian, it calls two swarm escorts and the Warden +answers (VOICE_CALL_DARK_1/2). Reuses `_summon` with a forced swarm +kind at the guardian's position. The existing 30% haste stays; this is +the visible mid-fight beat that was missing. + +## Map variety: colder nights + +`_apply_time_tone` multiplies the time tone by a cycle shade so later +cycles read colder even at the same beacons: cycles 1-3 identity, +4-6 ×(0.95, 0.96, 1.0), 7+ ×(0.88, 0.92, 1.0). GPT obstacle batch lands +in room Details later; art first, collision never without a decision. + +## Story frame: the debt arc + +Every run opens and closes with the same debt. `_story_lines(1)` +prepends STORY_OPEN_A/B before the cycle-1 lines: the Wardens fell, +one remains, and joining the beacons is what is owed to the night. +The result screen closes it with one epitaph line under the title, +using the same outcome split as the title: STORY_EPITAPH_WIN at a +cycle-8+ settlement (the line is joined), STORY_EPITAPH_ESCAPE on an +early return (keep the ember), STORY_EPITAPH_LOSE on defeat (the debt +passes to the next night). Defeat is therefore a handoff, not an end — +that is what makes the next run feel like the same story. + +## Follow-ups (not this slice) + +- Per-hero voices (table is hero-agnostic today). +- A fourth terrain (needs GPT art + room scene + world-step wiring). + +## Guardian pressure pass (done) + +Each guardian now has a second answer so fights alternate instead of +looping one trick. Forest slams a shockwave ring after its charge combo +(gap faces the player — hold ground, the opposite of the charges). +Field fires one aimed bolt per strafe with a short aim line. Camp fires +a re-aimed second fan before its armor opens. All reuse existing +telegraphs, sheets, and bolts; HP and damage budgets are untouched, so +the balance contract still holds. HUD dodge rules stay as they are — +the main lesson per boss did not change. diff --git a/notes/workflow/gpt-assets.md b/notes/workflow/gpt-assets.md index 599d9d0..c3ea831 100644 --- a/notes/workflow/gpt-assets.md +++ b/notes/workflow/gpt-assets.md @@ -61,3 +61,71 @@ After the drop lands: intake review, convert to the `nature.png` atlas cells or standalone 32/64px sprites, place a test scatter in `night_forest.tscn` on a branch, screenshot-verify on the emulator, then PR. + +## Batch 2 — spirit redesigns (prompt pack) + +Engine side is ready: spirits run facing-less like guardians +(`facings=1`, one horizontal strip, `float_down` always). Do NOT ask +for 4-facings grids — image gen cannot keep 16 cells consistent. + +Drop into `_asset_sources/gpt/batch-2/`, one PNG per kind. + +Common suffix for every prompt: "pixel art sprite sheet, exactly 4 +frames in ONE horizontal row, each frame 48x48 pixels, transparent +background, same character same size same center in all 4 frames, only +subtle hover bob differences between frames, chunky visible pixels, +dark night palette with amber glow accents, no background, no shadow, +no text, no grid lines, no frame numbers". + +1. `drifter`: tattered moon-moth bat, glowing pale eyes, wings mid-flap. +2. `ember`: living cracked coal cinder leaking amber light from cracks. +3. `caster`: small hooded wisp-mage, staff orb pulsing, chanting pose. +4. `weaver`: spider-like stitcher with needle legs, weaving gesture. +5. `stalker`: gaunt keeper-ghost in a broken helm, low lunge crouch. +6. `swarm`: one tiny ember gnat, big wings blurred, hungry eyes. +7. `wisp`: curious droplet flame with eyes, bouncing happily. + +Intake validation per strip: 192×48 canvas, 4 non-empty 48px cells, +alpha-mask overlap between frames ≥80% (pose may bob, design must not +drift). Failures are rejected back to the ChatGPT project, not fixed +by hand. Wire: replace sheet in each `.tres`, set `facings=1 +frames=4`, render-verify every kind, emulator screenshot, PR. + +## Batch 3 — choice card art (prompt pack) + +Relic cards are 168×112 Buttons with Name+Desc labels. Art replaces +the flat 9-patch style only — text stays rendered (localization). +No Godot-drawn boxes; all states are art. + +Drop into `_asset_sources/gpt/batch-3/`. + +Frames (ask at 336×224, downscaled to 168×112 on intake): + +Common suffix: "game UI card art, single card, top-down flat front +view, transparent outside the card, chunky pixel style, deep navy +card with amber border and small carved corner moons, no text, no +letters, no numbers". + +1. `card-normal`: calm resting card. +2. `card-hover`: same card, border brightened, faint amber shimmer. +3. `card-pressed`: same card pressed in, darker, border dimmed. + +Relic icons (ask at 96×96, downscaled to 48×48, transparent): + +Common suffix: "pixel art game item icon, single centered icon, +transparent background, chunky visible pixels, amber and bone-white +on dark navy, no text". + +`dew_hunter` crescent arrow dripping dew · `heavy_arrow` thick +broadhead arrow · `light_step` winged boot · `long_blade` long +curved moonblade · `moon_dash` crescent with speed lines · +`moon_ring` full ring halo · `moon_ripple` expanding ripple rings · +`pierce_arrow` needle arrow through a disc · `quick_arrow` three +small swift arrows · `shadow_veil` dark veil with eyes · +`sharp_moon` crescent blade edge · `swift_hand` quick open hand · +`tough_life` heart with bark skin · `twin_arrow` two crossed arrows · +`warm_beacon` tiny lit brazier · `wide_arc` wide bow arc. + +Intake: swap card StyleBoxTexture set, add icon TextureRect per card, +map relic id → icon in `relic_panel.gd`, render-verify the 3-pick +panel in all states, emulator screenshot, PR.