diff --git a/README.md b/README.md index 61261ee..b572508 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Rustgrave/ - `#FF8C00` Toxic Glow - `#4A6B6B` Teal/Water -窗口 1280×720,viewport 拉伸,全局纹理过滤 Nearest(像素锐利)。 +窗口 1280×720 设计单位,世界在 640×360 SubViewport 里 NEAREST 光栅化后再整数倍放大。 ## Testing diff --git a/assets/env/ember_nest.png b/assets/env/ember_nest.png new file mode 100644 index 0000000..571c923 Binary files /dev/null and b/assets/env/ember_nest.png differ diff --git a/assets/env/ember_nest.png.import b/assets/env/ember_nest.png.import new file mode 100644 index 0000000..28d0ba5 --- /dev/null +++ b/assets/env/ember_nest.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://hlvg3jdw5n1" +path="res://.godot/imported/ember_nest.png-a58425144475c4042bef8f599f59e710.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/env/ember_nest.png" +dest_files=["res://.godot/imported/ember_nest.png-a58425144475c4042bef8f599f59e710.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/env/ember_nest_coals.png b/assets/env/ember_nest_coals.png new file mode 100644 index 0000000..ad0a61b Binary files /dev/null and b/assets/env/ember_nest_coals.png differ diff --git a/assets/env/ember_nest_coals.png.import b/assets/env/ember_nest_coals.png.import new file mode 100644 index 0000000..bacff4f --- /dev/null +++ b/assets/env/ember_nest_coals.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dpamyejtqmlk4" +path="res://.godot/imported/ember_nest_coals.png-ad3889be02b38c3266d26956d15c450d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/env/ember_nest_coals.png" +dest_files=["res://.godot/imported/ember_nest_coals.png-ad3889be02b38c3266d26956d15c450d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/docs/architecture.md b/docs/architecture.md index f98ea4c..6a4b165 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,6 +2,8 @@ Target: **Godot 4.7** · GDScript only · zero external deps. +显示边界:世界始终在 **640×360 SubViewport** 中以原生像素渲染,`GameCamera.zoom=1`;`GamePresentation` 将世界图像和 1280×720 设计单位的 UI 一起放入最大整数倍率内容矩形。非 16:9 窗口只增加黑边。`stretch/mode` 必须是 `canvas_items`。标题进关走 `GameContext.route_scene()`,存档标识仍是 `Level01_Static.tscn`。HUD / 炉心选择 / Director 字幕挂在 `UiHost` 或运行时 overlay,不进世界视口。一次性粒子(尘、火花、死亡烟、雨溅)进 `WorldEffects`。雨粒按世界坐标下落(尖端即碰撞点),碰到地面 / 毒池水面 / 余烬巢后在接触点溅开并回收到镜头上方;画在独立 CanvasLayer 上以免被 MoodTint 压暗。骑士踏进或蹚过毒池会在水膜上溅。 + ## Autoloads Do not give autoload scripts a `class_name` — the autoload name is the API. @@ -14,7 +16,7 @@ Do not give autoload scripts a `class_name` — the autoload name is the API. | `Fx` | `scripts/autoload/fx.gd` | Particles, ember attach, death puffs. | | `SaveData` | `scripts/autoload/save_data.gd` | `user://` save: player, inventory, persistent world, consumed paths, story `flags`, ending. | | `Director` | `scripts/autoload/director.gd` | Fade, sequenced cutscenes, cinematic letterbox. Never touches `Engine.time_scale`. | -| `WorldClock` | `scripts/autoload/world_clock.gd` | Simulation only: `time_of_day`、`phase`、`weather`、`zone`、`wind_*`。API:`wind_vector()` `sway_radians()` `mood_tint()` `nest_light_*()` `moon_fill_energy()` `indoor_fill_energy()`。不创建 Sprite / Light。演出层是 `WindSway` / `CineFx` / `WeatherFx` / `WorldLight`。 | +| `WorldClock` | `scripts/autoload/world_clock.gd` | Simulation only: `time_of_day`、`phase`、`weather`、`zone`、`wind_*`。API:`wind_vector()` `sway_radians()` `mood_tint()` `outdoor_tint()` `indoor_tint()` `nest_light_*()` `moon_fill_energy()` `outdoor_moon_energy()` `indoor_fill_energy()`。不创建 Sprite / Light。演出层是 `WindSway` / `CineFx` / `WeatherFx` / `WorldLight`。 | ## Level01 (`scenes/levels/Level01_Static.tscn`) @@ -37,7 +39,7 @@ Night is a cold corridor (`mood_tint` ≈ 30–40% luminance). Lights carve warm |---|---|---| | `WorldLight` | `scripts/world/world_light.gd` | Additive PointLight2D. `follow` is `nest` / `indoor` / `heart`. Shadows on. | | `EmberNest/NestLight` + `Halo` | planted by the nest | Lit fire only: warm pool + additive glow sprite. Unlit = energy 0. | -| `DisplayFit` | `scripts/ui/display_fit.gd` | Integer-scale 1280×720 onto 1440p (2×) and 4K (3×). Not an Autoload. | +| `DisplayFit` | `scripts/ui/display_fit.gd` | 保持 canvas-items 与整数内容矩形。不是 Autoload。 | | `MoonFill` | `Level01Parallax` | Weak cool `DirectionalLight2D`. Off indoors and on the title. | | `ForgeShelter/WarmPool` | `Level01EastWing.place_forge_shelter` | Large dim indoor fill. | | `ForgeHeart/HeartLight` | `forge_heart.gd` | Residual heat after `unlock()`. | diff --git a/project.godot b/project.godot index 61d2164..48d6f89 100644 --- a/project.godot +++ b/project.godot @@ -40,9 +40,9 @@ WorldClock="*res://scripts/autoload/world_clock.gd" window/size/viewport_width=1280 window/size/viewport_height=720 window/size/mode=2 -window/stretch/mode="viewport" +window/stretch/mode="canvas_items" window/stretch/aspect="keep" -window/stretch/scale_mode="integer" +window/stretch/scale_mode="fractional" window/dpi/allow_hidpi=true [gui] diff --git a/scenes/levels/Level01_Static.tscn b/scenes/levels/Level01_Static.tscn index 433e186..cfd1219 100644 --- a/scenes/levels/Level01_Static.tscn +++ b/scenes/levels/Level01_Static.tscn @@ -25,12 +25,15 @@ [node name="Level01_Static" type="Node2D"] script = ExtResource("1_level") -[node name="ParallaxBackdrop" type="ParallaxBackground" parent="."] +[node name="ParallaxBackdrop" type="CanvasLayer" parent="."] layer = -10 +follow_viewport_enabled = false -[node name="Far" type="ParallaxLayer" parent="ParallaxBackdrop"] -motion_scale = Vector2(0.08, 0.04) -motion_mirroring = Vector2(668.8, 0) +[node name="Far" type="Parallax2D" parent="ParallaxBackdrop"] +scroll_scale = Vector2(0.08, 0.04) +repeat_size = Vector2(668, 0) +repeat_times = 4 +follow_viewport = false [node name="Sprite" type="Sprite2D" parent="ParallaxBackdrop/Far"] texture_filter = 1 @@ -39,9 +42,11 @@ position = Vector2(0, -24) scale = Vector2(1.9, 1.9) texture = ExtResource("15_sky") -[node name="Mid" type="ParallaxLayer" parent="ParallaxBackdrop"] -motion_scale = Vector2(0.22, 0.06) -motion_mirroring = Vector2(326.4, 0) +[node name="Mid" type="Parallax2D" parent="ParallaxBackdrop"] +scroll_scale = Vector2(0.22, 0.06) +repeat_size = Vector2(326, 0) +repeat_times = 4 +follow_viewport = false [node name="Sprite" type="Sprite2D" parent="ParallaxBackdrop/Mid"] texture_filter = 1 @@ -50,9 +55,11 @@ position = Vector2(0, 48) scale = Vector2(1.7, 1.7) texture = ExtResource("16_far") -[node name="Hills" type="ParallaxLayer" parent="ParallaxBackdrop"] -motion_scale = Vector2(0.48, 0.1) -motion_mirroring = Vector2(710.4, 0) +[node name="Hills" type="Parallax2D" parent="ParallaxBackdrop"] +scroll_scale = Vector2(0.48, 0.1) +repeat_size = Vector2(710, 0) +repeat_times = 4 +follow_viewport = false [node name="Sprite" type="Sprite2D" parent="ParallaxBackdrop/Hills"] texture_filter = 1 @@ -72,8 +79,8 @@ fill = Color(0.545098, 0.270588, 0.0745098, 1) [node name="ToxinPit" parent="Platforms" instance=ExtResource("2_plat")] skin = "ground" -position = Vector2(400, 368) -size = Vector2(112, 32) +position = Vector2(400, 352) +size = Vector2(112, 48) cap_surface = false tone = Color(0.52, 0.47, 0.58, 1) fill = Color(0.290196, 0.164706, 0.0941176, 1) @@ -135,7 +142,7 @@ fill = Color(0.368627, 0.352941, 0.337255, 1) [node name="Plat_760_184" parent="Platforms" instance=ExtResource("2_plat")] skin = "floating" -position = Vector2(760, 184) +position = Vector2(760, 288) size = Vector2(104, 16) fill = Color(0.627451, 0.321569, 0.176471, 1) @@ -191,7 +198,7 @@ position = Vector2(1488, 288) position = Vector2(150, 306) [node name="Spitter1" parent="Props" instance=ExtResource("12_spitter")] -position = Vector2(812, 184) +position = Vector2(812, 288) [node name="Spitter2" parent="Props" instance=ExtResource("12_spitter")] position = Vector2(1018, 180) diff --git a/scenes/ui/GamePresentation.tscn b/scenes/ui/GamePresentation.tscn new file mode 100644 index 0000000..2d48ac3 --- /dev/null +++ b/scenes/ui/GamePresentation.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/ui/game_presentation.gd" id="1"] + +[node name="GamePresentation" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1") diff --git a/scripts/autoload/director.gd b/scripts/autoload/director.gd index 38f79a6..16e1a21 100644 --- a/scripts/autoload/director.gd +++ b/scripts/autoload/director.gd @@ -369,7 +369,8 @@ func _on_fade_out_done(scene: String, duration: float) -> void: _queued_fade_path = "" last_fade_target = target if target != "" and not _is_test_runner(): - get_tree().change_scene_to_file(target) + # Level01 must load inside GamePresentation (640×360 world). + get_tree().change_scene_to_file(GameContext.route_scene(target)) fade_in(dur) diff --git a/scripts/autoload/fx.gd b/scripts/autoload/fx.gd index 29e72cb..3968b49 100644 --- a/scripts/autoload/fx.gd +++ b/scripts/autoload/fx.gd @@ -4,17 +4,17 @@ extends Node2D ## never gameplay state. Headless runs and missing art degrade silently. ## ## Particles are lightweight self-managing Node2D/Polygon2D squares or -## Sprite2D sheet cuts (no GPUParticles2D). One-shot effects parent here in -## world space and free themselves; attach_ember parents to its target so -## scene reloads clean it up automatically. +## Sprite2D sheet cuts (no GPUParticles2D). One-shot effects parent into +## GameContext.world_effects() so they share the world's World2D / camera; +## attach_ember parents to its target so scene reloads clean it up. const WEAPONHIT_SHEET_PATH := "res://assets/kenney_clean/vfx/10_weaponhit_spritesheet.png" const BUBBLES_SHEET_PATH := "res://assets/kenney_clean/vfx/20_magicbubbles_spritesheet.png" const BRIGHTFIRE_SHEET_PATH := "res://assets/kenney_clean/vfx/9_brightfire_spritesheet.png" -## One-shot particles live under this autoload, which sits early in the tree -## (autoloads come before the current scene), so z_index must lift them above -## scene content to stay visible. +## One-shot particles parent into the authored world (SubViewport World2D). +## Autoload canvas would draw them on the 1280×720 UI window instead of the +## 640×360 world image. const FX_Z := 100 const EMBER_FIELD_NAME := "FxEmberField" @@ -23,6 +23,7 @@ var _headless := false var _weaponhit_sheet: Texture2D = null var _bubbles_sheet: Texture2D = null var _brightfire_sheet: Texture2D = null +var _add_mat: CanvasItemMaterial = null func _ready() -> void: @@ -57,11 +58,12 @@ func attach_ember(target: Node2D) -> void: func dust_puff(pos: Vector2, dir: float = 0.0) -> void: if _headless: return + var host := _world_host() for i in randi_range(4, 6): var mote := DustMote.new() mote.launch(pos, dir) mote.z_index = FX_Z - add_child(mote) + host.add_child(mote) ## Weapon-hit flash: first six frames of the CC0 Kenney/CodeManu sheet, @@ -72,7 +74,7 @@ func hit_sparks(pos: Vector2) -> void: var spark := HitSpark.new(_weaponhit_sheet) spark.position = pos spark.z_index = FX_Z - add_child(spark) + _world_host().add_child(spark) ## Rust debris bursting out of a destroyed enemy: gravity, a floor bounce, @@ -80,11 +82,12 @@ func hit_sparks(pos: Vector2) -> void: func rust_debris(pos: Vector2) -> void: if _headless: return + var host := _world_host() for i in randi_range(6, 8): var bit := RustDebris.new() bit.launch(pos) bit.z_index = FX_Z - add_child(bit) + host.add_child(bit) ## 世界坐标一次性帧动画(敌人死亡尸体动画/烟雾等)。播完自毁;headless 或 @@ -99,7 +102,7 @@ func play_frames_once(frames: Array[Texture2D], pos: Vector2, fps: float, player.position = pos + Vector2(0.0, baseline) player.flip_h = flip player.z_index = FX_Z - add_child(player) + _world_host().add_child(player) ## 通用敌人死亡烟雾(Gothicvania cemetery 5 帧,44x52,底部留白 2px)。 @@ -116,10 +119,71 @@ func toxin_bubbles(parent: Node2D, rect: Rect2) -> void: return var bubble := ToxinBubble.new(_bubbles_sheet) bubble.position = rect.position + Vector2(randf() * rect.size.x, randf() * rect.size.y) - bubble.modulate = Color(0.45, 0.78, 0.55, 0.75) + bubble.modulate = Color(0.62, 1.05, 0.78, 0.95) parent.add_child(bubble) +## Rain hitting a surface. `kind` is ground / toxin / nest / nest_lit / rust. +func rain_splash(pos: Vector2, kind: StringName = &"ground") -> void: + if _headless: + return + var placed: Dictionary = _splash_host(pos) + var host: Node = placed["host"] + var at: Vector2 = placed["pos"] + if kind == &"toxin": + var kiss := WaterKiss.new() + kiss.launch(at) + kiss.z_index = FX_Z + host.add_child(kiss) + var count := 5 if kind == &"toxin" else 4 + for i in count: + var mote := RainSplash.new() + mote.launch(at, kind) + mote.z_index = FX_Z + host.add_child(mote) + + +## Knight stepping into the sludge — a slightly bigger surface burst. +func pool_splash(pos: Vector2) -> void: + if _headless: + return + var placed: Dictionary = _splash_host(pos) + var host: Node = placed["host"] + var at: Vector2 = placed["pos"] + var kiss := WaterKiss.new() + kiss.launch(at) + kiss.z_index = FX_Z + host.add_child(kiss) + for i in 7: + var mote := RainSplash.new() + mote.launch(at + Vector2(randf_range(-5.0, 5.0), 0.0), &"toxin") + mote.z_index = FX_Z + host.add_child(mote) + + +func _world_host() -> Node: + var host := GameContext.world_effects() + return host if host != null else self + + +## Shared ADD material. MoodTint multiplies first; additive still reads as a kiss. +func _additive_mat() -> CanvasItemMaterial: + if _add_mat == null: + _add_mat = CanvasItemMaterial.new() + _add_mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD + return _add_mat + + +## Splashes live on WeatherFx's canvas so night CanvasModulate cannot crush them. +func _splash_host(world_pos: Vector2) -> Dictionary: + var tree := get_tree() + if tree != null: + var wx := tree.get_first_node_in_group("weather_fx") as WeatherFx + if wx != null: + return {"host": wx, "pos": wx.world_to_canvas(world_pos)} + return {"host": _world_host(), "pos": world_pos} + + ## 一次性帧序列播放器:固定 fps 播完即自由。像素素材 NEAREST。 class OneShotFrames extends Sprite2D: var _frames: Array[Texture2D] = [] @@ -253,13 +317,14 @@ class DustMote extends Node2D: func _ready() -> void: - var half := randf_range(1.0, 1.5) + var half := randf_range(1.2, 1.8) var poly := Polygon2D.new() poly.polygon = PackedVector2Array([ Vector2(-half, -half), Vector2(half, -half), Vector2(half, half), Vector2(-half, half), ]) - poly.color = Palette.IRON.lerp(Palette.CONCRETE, randf()) + poly.color = Palette.IRON.lerp(Palette.CONCRETE, randf()) * 1.35 + poly.material = Fx._additive_mat() add_child(poly) @@ -271,10 +336,97 @@ class DustMote extends Node2D: _velocity.x *= maxf(0.0, 1.0 - 3.2 * delta) _velocity.y += 90.0 * delta position += _velocity * delta + position = Vector2(roundf(position.x), roundf(position.y)) + var k := _age / _lifetime + modulate.a = minf(k * 10.0, 1.0) * (1.0 - k) + + +## 2 px rain kiss on ground / water / iron. Additive so MoodTint cannot crush it. +class RainSplash extends Node2D: + var _velocity := Vector2.ZERO + var _age := 0.0 + var _lifetime := 0.32 + + + func launch(pos: Vector2, kind: StringName) -> void: + position = Vector2(roundf(pos.x + randf_range(-2.0, 2.0)), roundf(pos.y)) + _lifetime = randf_range(0.24, 0.40) + match kind: + &"toxin": + _velocity = Vector2(randf_range(-24.0, 24.0), randf_range(-54.0, -20.0)) + &"nest_lit": + _velocity = Vector2(randf_range(-12.0, 12.0), randf_range(-56.0, -28.0)) + _: + _velocity = Vector2(randf_range(-28.0, 28.0), randf_range(-40.0, -14.0)) + var half := 1.8 if kind == &"toxin" else 1.6 + var poly := Polygon2D.new() + poly.polygon = PackedVector2Array([ + Vector2(-half, -half), Vector2(half, -half), + Vector2(half, half), Vector2(-half, half), + ]) + match kind: + &"toxin": + poly.color = Color(0.78, 1.15, 0.92) + &"nest": + poly.color = Palette.IRON.lerp(Palette.RUST_LIGHT, randf()) + &"nest_lit": + poly.color = Color(1.15, 0.95, 0.62) + &"rust": + poly.color = Palette.RUST_LIGHT.lerp(Palette.EMBER_ASH, randf() * 0.45) + _: + poly.color = Color(1.05, 1.08, 1.15) + poly.material = Fx._additive_mat() + add_child(poly) + + + func _process(delta: float) -> void: + _age += delta + if _age >= _lifetime: + queue_free() + return + _velocity.y += 120.0 * delta + position += _velocity * delta + position = Vector2(roundf(position.x), roundf(position.y)) var k := _age / _lifetime modulate.a = minf(k * 10.0, 1.0) * (1.0 - k) +## Horizontal scum flash on the toxin film — reads as a water kiss, not dust. +class WaterKiss extends Node2D: + var _age := 0.0 + var _lifetime := 0.30 + var _half := 5.0 + var _poly: Polygon2D + + + func launch(pos: Vector2) -> void: + position = Vector2(roundf(pos.x), roundf(pos.y)) + _half = randf_range(5.0, 9.0) + _lifetime = randf_range(0.24, 0.36) + _poly = Polygon2D.new() + _poly.color = Color(0.82, 1.18, 0.95, 1.0) + _poly.material = Fx._additive_mat() + _set_span(_half) + add_child(_poly) + + + func _set_span(half: float) -> void: + _poly.polygon = PackedVector2Array([ + Vector2(-half, -1.0), Vector2(half, -1.0), + Vector2(half, 1.0), Vector2(-half, 1.0), + ]) + + + func _process(delta: float) -> void: + _age += delta + if _age >= _lifetime: + queue_free() + return + var k := _age / _lifetime + _set_span(_half + k * 5.0) + modulate.a = (1.0 - k) * 0.95 + + ## Sprite-sheet hit flash: 6 frames across, ~0.25 s, scale ~0.5. class HitSpark extends Sprite2D: const DURATION := 0.25 @@ -364,7 +516,7 @@ class ToxinBubble extends Sprite2D: _lifetime = randf_range(0.9, 1.6) _rise = randf_range(7.0, 14.0) _phase = randf() * TAU - var s := randf_range(0.06, 0.1) + var s := randf_range(0.08, 0.13) scale = Vector2(s, s) diff --git a/scripts/autoload/game_context.gd b/scripts/autoload/game_context.gd new file mode 100644 index 0000000..5a775d3 --- /dev/null +++ b/scripts/autoload/game_context.gd @@ -0,0 +1,76 @@ +class_name GameContext +extends RefCounted +## The authored level owns physics and save paths; presentation owns screen UI. + +const WORLD_PATH := "res://scenes/levels/Level01_Static.tscn" +const PRESENTATION_PATH := "res://scenes/ui/GamePresentation.tscn" +static var _blocked_through_frame: int = -1 + + +static func tree() -> SceneTree: + return Engine.get_main_loop() as SceneTree + + +static func world_root(node: Node = null) -> Node: + var cursor := node + while cursor != null: + if cursor.is_in_group("game_world"): + return cursor + cursor = cursor.get_parent() + var scene_tree := tree() + if scene_tree == null: + return null + var worlds := scene_tree.get_nodes_in_group("game_world") + if worlds.size() == 1: + return worlds[0] + return node.get_parent() if node != null else scene_tree.current_scene + + +static func world_scene_path(node: Node = null) -> String: + var world := world_root(node) + return world.scene_file_path if world != null and world.scene_file_path != "" else WORLD_PATH + + +static func world_effects(node: Node = null) -> Node: + var world := world_root(node) + if world == null: + return null + var effects := world.get_node_or_null("WorldEffects") + if effects == null: + effects = Node2D.new() + effects.name = "WorldEffects" + effects.z_index = 8 + world.add_child(effects) + return effects + + +static func free_after(node: Node, seconds: float) -> void: + if node == null or not is_instance_valid(node): + return + var tw := node.create_tween() + tw.tween_interval(seconds) + tw.tween_callback(node.queue_free) + + +static func ui_host(node: Node = null) -> Node: + var scene_tree := tree() + if scene_tree != null: + var presentation := scene_tree.get_first_node_in_group("game_presentation") + if presentation != null: + return presentation.get_node("UiHost") + return world_root(node) + + +static func suppress_gameplay_input() -> void: + _blocked_through_frame = Engine.get_physics_frames() + 1 + + +static func gameplay_input_enabled() -> bool: + var scene_tree := tree() + return scene_tree != null and not scene_tree.paused \ + and not Director.is_input_locked() and not Director.choice_hold \ + and Engine.get_physics_frames() > _blocked_through_frame + + +static func route_scene(path: String) -> String: + return PRESENTATION_PATH if path == WORLD_PATH else path diff --git a/scripts/autoload/game_context.gd.uid b/scripts/autoload/game_context.gd.uid new file mode 100644 index 0000000..2a6366e --- /dev/null +++ b/scripts/autoload/game_context.gd.uid @@ -0,0 +1 @@ +uid://cjoppc4jgfqlg diff --git a/scripts/autoload/save_data.gd b/scripts/autoload/save_data.gd index 36ba3ce..a01cf1e 100644 --- a/scripts/autoload/save_data.gd +++ b/scripts/autoload/save_data.gd @@ -24,6 +24,7 @@ var data: Dictionary = {} ## One-shot spawn override consumed by a level right after it instantiates the ## player. Set by respawn flow so the player appears at the last Ember Nest. var pending_spawn: Vector2 = Vector2.INF +var entering_from_checkpoint: bool = false ## Absolute node paths of one-shot interactables that died (picked up cores, ## used filter gears, melted gates). Persisted even though their nodes are @@ -158,6 +159,8 @@ func delete_save() -> void: lit_nests.clear() flags.clear() ending = "" + pending_spawn = Vector2.INF + entering_from_checkpoint = false WorldClock.reset() @@ -177,7 +180,7 @@ func apply_consumed(node: Node) -> void: if node == null or not is_instance_valid(node): return for p in consumed: - var target := node.get_node_or_null(p) + var target := resolve_saved_node(node, String(p)) if target != null and is_instance_valid(target): target.queue_free() @@ -262,7 +265,7 @@ func _collect_world() -> Dictionary: continue var state: Dictionary = node.get_persistent_state() if not state.is_empty(): - world[String(node.get_path())] = state + world[persist_path(node)] = state return world @@ -273,7 +276,7 @@ func apply_world(node: Node) -> void: if node == null or not is_instance_valid(node): return for key in data["world"]: - var target := node.get_node_or_null(String(key)) + var target := resolve_saved_node(node, String(key)) if target == null or not target.has_method("apply_persistent_state"): continue target.apply_persistent_state(data["world"][key]) @@ -323,6 +326,7 @@ func apply_player(player: Node) -> void: ## Record the scene we should boot into and the spawn point, then reload there. func respawn(scene_path: String, spawn: Vector2) -> void: pending_spawn = spawn + entering_from_checkpoint = true Director.fade_to(scene_path) @@ -334,3 +338,44 @@ func consume_pending_spawn() -> Vector2: func has_pending_spawn() -> bool: return pending_spawn != Vector2.INF + + +func persist_path(node: Node) -> String: + if node == null or not is_instance_valid(node) or not node.is_inside_tree(): + return "" + return _normalize_saved_path(String(node.get_path())) + + +func resolve_saved_node(root: Node, saved_path: String) -> Node: + if root == null or not is_instance_valid(root) or saved_path == "": + return null + saved_path = saved_path.strip_edges() + if saved_path == ".": + return root + var found := root.get_node_or_null(NodePath(saved_path)) + if found != null: + return found + var leaf := saved_path.get_file() + if leaf != "": + found = root.find_child(leaf, true, false) + if found != null: + return found + return null + + +func _normalize_saved_path(path: String) -> String: + if path == "" or path == ".": + return path + if not is_inside_tree(): + return path + var scene := GameContext.world_root() + if scene == null or not scene.is_inside_tree(): + scene = get_tree().current_scene + if scene == null or not scene.is_inside_tree(): + return path + var root := String(scene.get_path()) + if path == root: + return "." + if path.begins_with(root + "/"): + return path.substr(root.length() + 1) + return path diff --git a/scripts/autoload/world_clock.gd b/scripts/autoload/world_clock.gd index 929caf0..6bb427b 100644 --- a/scripts/autoload/world_clock.gd +++ b/scripts/autoload/world_clock.gd @@ -326,11 +326,19 @@ func hud_line() -> String: func mood_tint() -> Color: if zone == Zone.INDOORS: return _indoor_tint() + return outdoor_tint() + + +func outdoor_tint() -> Color: var a := _phase_tint(phase) var b := _weather_mul(_blend_weather()) return Color(a.r * b.r, a.g * b.g, a.b * b.b, 1.0) +func indoor_tint() -> Color: + return _indoor_tint() + + func _indoor_tint() -> Color: var b := _weather_mul(_blend_weather()) var c := Color(INDOOR_TINT.r * b.r, INDOOR_TINT.g * b.g, INDOOR_TINT.b * b.b, 1.0) @@ -406,7 +414,13 @@ func nest_light_radius() -> float: func moon_fill_energy() -> float: - if menu_hold or zone == Zone.INDOORS: + if zone == Zone.INDOORS: + return 0.0 + return outdoor_moon_energy() + + +func outdoor_moon_energy() -> float: + if menu_hold: return 0.0 match phase: Phase.NIGHT: diff --git a/scripts/camera/game_camera.gd b/scripts/camera/game_camera.gd index 93175df..c1e0c3b 100644 --- a/scripts/camera/game_camera.gd +++ b/scripts/camera/game_camera.gd @@ -4,10 +4,10 @@ extends Camera2D ## plus a decaying trauma shake that nudges `offset` (never the limits), and an ## external decaying jolt written by the Juice autoload via `shake_offset`. -## 视口已是 1280x720;zoom=2.0 让可视范围回到 640x360 世界单位, -## 因此速度/碰撞盒/关卡坐标无需改动,只是每个世界单位渲染成 2x2 像素。 -## 改这个值等于改画面取景(WorldScale 不变,看得更多或更少)。 -const ZOOM := 2.0 +## 世界已在 640×360 SubViewport 里光栅化;zoom=1 看见整块世界视口。 +## 再 zoom=2 会把取景砍成 320×180。输出放大由 PresentationMetrics 做。 +const ZOOM := 1.0 +const PIXEL_GRID := 1.0 @export var follow_speed: float = 6.5 @export var look_ahead: float = 48.0 diff --git a/scripts/enemies/executioner_boss.gd b/scripts/enemies/executioner_boss.gd index 93e6a71..6f274ad 100644 --- a/scripts/enemies/executioner_boss.gd +++ b/scripts/enemies/executioner_boss.gd @@ -93,10 +93,7 @@ func _do_slash() -> void: box.global_position = global_position + Vector2(float(_shield_facing) * 22.0, -18.0) box.monitoring = true Sfx.play(&"swing") - get_tree().create_timer(0.16).timeout.connect(func() -> void: - if is_instance_valid(box): - box.queue_free() - ) + GameContext.free_after(box, 0.16) func _fire() -> void: @@ -107,7 +104,10 @@ func _fire() -> void: _anim.play("skill", true) var player := get_tree().get_first_node_in_group("player") as Player var proj := PROJECTILE_SCENE.instantiate() as Projectile - get_tree().current_scene.add_child(proj) + var host := GameContext.world_effects(self) + if host == null: + host = get_tree().current_scene + host.add_child(proj) var origin := global_position + Vector2(_shield_facing * 16.0, -38.0) var aim := Vector2(_shield_facing * 40.0, -18.0) if player: diff --git a/scripts/enemies/gear_shield_enemy.gd b/scripts/enemies/gear_shield_enemy.gd index cf2a3e3..2ead60b 100644 --- a/scripts/enemies/gear_shield_enemy.gd +++ b/scripts/enemies/gear_shield_enemy.gd @@ -159,7 +159,10 @@ func _tick_stagger(delta: float) -> void: func _fire() -> void: var proj := PROJECTILE_SCENE.instantiate() as Projectile - get_tree().current_scene.add_child(proj) + var host := GameContext.world_effects(self) + if host == null: + host = get_tree().current_scene + host.add_child(proj) var player := get_tree().get_first_node_in_group("player") as Player # 帧动画机体更高大:弹体从抬手处出膛;占位机体沿用旧出膛点。 var origin := global_position + (Vector2(_shield_facing * 18.0, -30.0) diff --git a/scripts/enemies/spitter_enemy.gd b/scripts/enemies/spitter_enemy.gd index 9e91d4b..3fc848b 100644 --- a/scripts/enemies/spitter_enemy.gd +++ b/scripts/enemies/spitter_enemy.gd @@ -156,7 +156,10 @@ func _on_anim_finished(anim: StringName) -> void: func _fire(player: Player) -> void: var proj := PROJECTILE_SCENE.instantiate() as Projectile - get_tree().current_scene.add_child(proj) + var host := GameContext.world_effects(self) + if host == null: + host = get_tree().current_scene + host.add_child(proj) # 喷口跟随身体朝向镜像(原图面朝左,scale.x=-1 时面朝右)。 var mpos := muzzle.position if _body != null and _body.scale.x < 0.0: diff --git a/scripts/interactables/ember_nest.gd b/scripts/interactables/ember_nest.gd index d0e717e..69aec8b 100644 --- a/scripts/interactables/ember_nest.gd +++ b/scripts/interactables/ember_nest.gd @@ -4,7 +4,8 @@ extends Interactable ## nest as the respawn point (the knight is fully restored here on death). const SCENE_PATH := "res://scenes/levels/Level01_Static.tscn" -const BASE_PATH := "res://assets/env/rubble_c.png" +const BASE_PATH := "res://assets/env/ember_nest.png" +const COALS_PATH := "res://assets/env/ember_nest_coals.png" const FLAME_PATH := "res://assets/fx/shrine_flame.png" const SPARK_PATH := "res://assets/ui/ember_motes.png" const FLAME_FRAMES := 8 @@ -13,9 +14,13 @@ const LIT_FPS := 12.0 const LIT_MOD := Color(1.0, 0.92, 0.62, 1.0) const LIGHT_COLOR := Color(1.0, 0.70, 0.36) const HALO_PATH := "res://assets/env/glow_soft.png" +const BASE_SIZE := Vector2(48, 36) +const BASE_OFFSET := Vector2(-16, -20) +const BOWL := Vector2(6, -12) var _lit: bool = false var _flame: Sprite2D +var _coals: Sprite2D var _sparks: Node2D var _light: WorldLight var _beam: Sprite2D @@ -27,11 +32,13 @@ var _headless := false func _ready() -> void: super._ready() + add_to_group("ember_nests") add_to_group("persistent") prompt = "E 点燃余烬巢" - # rubble_c 原生 27x33,等比 2/3 → 18x22,脚底贴地不再横向压扁。 - ensure_sprite(BASE_PATH, Vector2(18, 22), Vector2(-2, -2), Palette.RUST_DARK) + # 锈铁火盆 48×36,脚埋进草皮两像素;不再用 18×22 碎石顶着 80px 骑士。 + ensure_sprite(BASE_PATH, BASE_SIZE, BASE_OFFSET, Palette.RUST_DARK) _headless = DisplayServer.get_name() == "headless" + _ensure_coals() _ensure_flame() _ensure_light() _ensure_halo() @@ -53,16 +60,16 @@ func _ensure_flame() -> void: spr.hframes = FLAME_FRAMES spr.vframes = 1 spr.centered = true - # 18x22 石碑顶心约 (7, 0);16x24 焰(8x12 设计格 2x)底坐碑帽,不盖碑身。 - spr.position = Vector2(7, -8) + # 16×24 焰坐在火盆井里,底不盖过锈铁沿。 + spr.position = BOWL spr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - spr.z_index = 1 + spr.z_index = 2 add_child(spr) _flame = spr var sparks := Node2D.new() sparks.name = "Sparks" sparks.position = spr.position - sparks.z_index = 2 + sparks.z_index = 3 add_child(sparks) _sparks = sparks @@ -106,12 +113,33 @@ func _spawn_spark() -> void: _sparks.add_child(NestSpark.new(sheet)) +func _ensure_coals() -> void: + if _coals != null or not ResourceLoader.exists(COALS_PATH): + return + var tex := load(COALS_PATH) as Texture2D + if tex == null: + return + var spr := Sprite2D.new() + spr.name = "Coals" + spr.texture = tex + spr.centered = false + spr.position = BASE_OFFSET + spr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + spr.z_index = 1 + var mat := CanvasItemMaterial.new() + mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD + spr.material = mat + spr.visible = false + add_child(spr) + _coals = spr + + func _ensure_light() -> void: if _light != null: return var light := WorldLight.new() light.name = "NestLight" - light.position = Vector2(7, -8) + light.position = BOWL light.color = LIGHT_COLOR light.follow = &"nest" light.flicker = true @@ -127,7 +155,7 @@ func _ensure_halo() -> void: if ResourceLoader.exists(HALO_PATH): spr.texture = load(HALO_PATH) as Texture2D spr.centered = true - spr.position = Vector2(7, -10) + spr.position = BOWL + Vector2(0, -4) spr.z_index = 0 var mat := CanvasItemMaterial.new() mat.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD @@ -146,8 +174,9 @@ func _sync_halo() -> void: _halo.modulate.a = 0.0 return var k := clampf(WorldClock.nest_light_energy() / 2.60, 0.45, 1.0) - _halo.scale = Vector2(1.85, 1.65) * (0.85 + 0.25 * k) - _halo.modulate = Color(1.0, 0.62, 0.28, 0.38 + 0.28 * k) + # Hug the bowl — the 48px glow at ~1.8x read as a grey UI square. + _halo.scale = Vector2(0.92, 0.78) * (0.85 + 0.25 * k) + _halo.modulate = Color(1.0, 0.62, 0.28, 0.22 + 0.18 * k) func _sync_light(delta: float) -> void: @@ -165,7 +194,7 @@ func _ensure_beam() -> void: var beam := Sprite2D.new() beam.name = "WarmShaft" beam.centered = true - beam.position = Vector2(7, -48) + beam.position = BOWL + Vector2(0, -44) beam.texture = _shaft_tex() beam.modulate = Color(1.0, 0.72, 0.38, 0.0) beam.visible = false @@ -202,6 +231,8 @@ func _lean_flame() -> void: func _apply_flame_state() -> void: + if _coals != null: + _coals.visible = _lit if _flame != null: _flame.visible = _lit _flame.modulate = LIT_MOD @@ -225,6 +256,14 @@ func get_prompt(_actor: Node) -> String: return "E 点燃余烬巢" if not _lit else "余烬巢已点燃" +func is_lit() -> bool: + return _lit + + +func rain_hit_rect() -> Rect2: + return Rect2(global_position + BASE_OFFSET, BASE_SIZE) + + func interact(actor: Node) -> void: if actor is Player: var p := actor as Player @@ -233,9 +272,12 @@ func interact(actor: Node) -> void: GameEvents.player_health_changed.emit(p.health.current, p.health.max_hp) _lit = true _apply_flame_state() - SaveData.register_lit_nest(String(get_path())) + SaveData.register_lit_nest(SaveData.persist_path(self)) GameEvents.announcement.emit("余烬重新点燃 —— 进度已刻入铁锈") - SaveData.save_game(SCENE_PATH, p) + var scene_path := GameContext.world_scene_path(self) + if scene_path == "": + scene_path = SCENE_PATH + SaveData.save_game(scene_path, p) Sfx.play(&"insert") diff --git a/scripts/interactables/forge_heart.gd b/scripts/interactables/forge_heart.gd index 9c9a0b7..f13eab4 100644 --- a/scripts/interactables/forge_heart.gd +++ b/scripts/interactables/forge_heart.gd @@ -31,8 +31,7 @@ func can_interact(_actor: Node) -> bool: func lock() -> void: unlocked = false visible = false - monitoring = false - monitorable = false + _apply_monitor(false) _ensure_light() if _glow != null: _glow.lit = false @@ -42,14 +41,20 @@ func lock() -> void: func unlock() -> void: unlocked = true visible = true - monitoring = true - monitorable = true + _apply_monitor(true) _ensure_light() if _glow != null: _glow.lit = true _glow.apply(true) +func _apply_monitor(on: bool) -> void: + # Boss death unlocks from a hurtbox area_entered stack. Godot rejects + # monitorable writes there ("Function blocked during in/out signal"). + set_deferred("monitoring", on) + set_deferred("monitorable", on) + + func _ensure_light() -> void: if _glow != null: return @@ -75,7 +80,11 @@ func _open_choice() -> void: _layer = CanvasLayer.new() _layer.layer = 25 _layer.process_mode = Node.PROCESS_MODE_ALWAYS - add_child(_layer) + var host := GameContext.ui_host(self) + if host == null: + host = self + host.add_child(_layer) + PresentationMetrics.bind_layer(_layer) var root := Control.new() root.set_anchors_preset(Control.PRESET_FULL_RECT) root.mouse_filter = Control.MOUSE_FILTER_STOP @@ -111,7 +120,7 @@ func _on_chosen(id: StringName) -> void: GameEvents.ending_chosen.emit(id) var player := get_tree().get_first_node_in_group("player") if player: - SaveData.save_game(get_tree().current_scene.scene_file_path, player) + SaveData.save_game(GameContext.world_scene_path(player), player) var line := "陵墓在锈里睁开眼。" if kind == "rekindle" else "余烬落回炉灰。骑士也是。" Director.play([ {"kind": "lock"}, diff --git a/scripts/interactables/toxin_pool.gd b/scripts/interactables/toxin_pool.gd index f25f996..3cc3867 100644 --- a/scripts/interactables/toxin_pool.gd +++ b/scripts/interactables/toxin_pool.gd @@ -19,10 +19,13 @@ const WORLD := 16.0 ## Scum frame swap cadence and bob amplitude (px). const SCUM_FRAME_TIME := 0.45 const SCUM_BOB := 1.0 +const WADE_SPLASH_GAP := 0.22 +const WADE_SPEED := 18.0 var _scum_sprites: Array[Sprite2D] = [] var _scum_frames: Array[Texture2D] = [] var _scum_time := 0.0 +var _wade_cd := 0.0 func _ready() -> void: @@ -30,8 +33,10 @@ func _ready() -> void: collision_mask = 2 monitoring = true monitorable = true - body_entered.connect(func(b: Node2D) -> void: _bodies.append(b)) - body_exited.connect(func(b: Node2D) -> void: _bodies.erase(b)) + z_index = -1 + add_to_group("toxin_pools") + body_entered.connect(_on_body_entered) + body_exited.connect(_on_body_exited) _rebuild_visual() @@ -44,9 +49,18 @@ func _rebuild_visual() -> void: for child in get_children(): if child is CollisionShape2D: continue - child.queue_free() + remove_child(child) + child.free() _scum_sprites.clear() _scum_frames.clear() + # 夜色 CanvasModulate 会把 8,31,30 的淤泥吃成黑坑。先铺一层过亮的体积, + # 再叠 tile,水坑才读得出是液体而不是一条绿线。 + var volume := ColorRect.new() + volume.name = "Volume" + volume.size = _pool_size + volume.color = Color(0.38, 0.86, 0.68, 1.0) + volume.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(volume) var sludge := _load_tex(SLUDGE_PATH) var scum := _load_tex(SCUM_PATH) var scum_b := _load_tex(SCUM_B_PATH) @@ -73,9 +87,9 @@ func _rebuild_visual() -> void: if remain_x < WORLD - 0.01 or remain_y < WORLD - 0.01: spr.region_enabled = true spr.region_rect = Rect2(0, 0, minf(src, remain_x / s), minf(float(sludge.get_height()), remain_y / s)) - # Liquid darkens with depth; texture already fades, rows push further. - var k := lerpf(0.9, 0.45, float(r) / maxf(1.0, float(rows - 1))) - spr.modulate = Color(k, k, k) + # Liquid darkens with depth; keep it above MoodTint's floor. + var k := lerpf(1.20, 0.78, float(r) / maxf(1.0, float(rows - 1))) + spr.modulate = Color(k * 1.15, k * 1.28, k * 1.10) add_child(spr) if scum != null: _scum_frames.append(scum) @@ -92,6 +106,7 @@ func _rebuild_visual() -> void: if remain_x < WORLD - 0.01: spr.region_enabled = true spr.region_rect = Rect2(0, 0, minf(float(scum.get_width()), remain_x), scum_h) + spr.modulate = Color(1.62, 1.52, 1.28) add_child(spr) _scum_sprites.append(spr) else: @@ -130,22 +145,50 @@ func _process(delta: float) -> void: continue if _scum_frames.size() > 1 and spr.texture != _scum_frames[frame]: spr.texture = _scum_frames[frame] - spr.position.y = sin(_scum_time * 1.7 + float(i) * 0.9) * SCUM_BOB - 0.5 + spr.position.y = roundf(sin(_scum_time * 1.7 + float(i) * 0.9) * SCUM_BOB) func _physics_process(delta: float) -> void: # 过场锁输入时人走不开,继续灌毒会在台词里把骑士灌满并触发满溢。 if Director.is_input_locked(): return + _wade_cd = maxf(0.0, _wade_cd - delta) for body in _bodies: if body is Player: - (body as Player).toxin.expose(toxin_per_second * delta) + var knight := body as Player + knight.toxin.expose(toxin_per_second * delta) + if _wade_cd <= 0.0 and knight.velocity.length() > WADE_SPEED: + _wade_cd = WADE_SPLASH_GAP + Fx.pool_splash(splash_point(knight.global_position.x)) _bubble_accum += delta if _bubble_accum >= BUBBLE_INTERVAL: _bubble_accum -= BUBBLE_INTERVAL Fx.toxin_bubbles(self, _bubble_rect()) +func surface_rect() -> Rect2: + return Rect2(global_position, Vector2(_pool_size.x, 8.0)) + + +func volume_rect() -> Rect2: + return Rect2(global_position, _pool_size) + + +func splash_point(x: float) -> Vector2: + var surface := surface_rect() + return Vector2(clampf(x, surface.position.x + 2.0, surface.end.x - 2.0), surface.position.y + 1.0) + + +func _on_body_entered(body: Node2D) -> void: + _bodies.append(body) + if body is Player: + Fx.pool_splash(splash_point(body.global_position.x)) + + +func _on_body_exited(body: Node2D) -> void: + _bodies.erase(body) + + func _bubble_rect() -> Rect2: return Rect2( Vector2(4.0, _pool_size.y * 0.15), diff --git a/scripts/levels/level01_east_wing.gd b/scripts/levels/level01_east_wing.gd index d0b1961..408512e 100644 --- a/scripts/levels/level01_east_wing.gd +++ b/scripts/levels/level01_east_wing.gd @@ -16,6 +16,17 @@ const EAST_FLOOR_X := 1600.0 ## Roofed forge remnant past the Executioner — indoor lighting, rain stops. const FORGE_SHELTER_POS := Vector2(2112, 240) const FORGE_SHELTER_SIZE := Vector2(256, 160) +const PIT_X0 := 400.0 +const PIT_X1 := 512.0 +const PIT_BANK_Y := 320.0 +const PIT_WATER_Y := 336.0 +const PIT_FLOOR_Y := 352.0 +const PIT_FLOOR_SIZE := Vector2(112, 48) +const PIT_WATER_SIZE := Vector2(112, 32) +const PIT_CLIMB_LEFT_POS := Vector2(400, 336) +const PIT_CLIMB_LEFT_SIZE := Vector2(32, 16) +const PIT_CLIMB_RIGHT_POS := Vector2(480, 336) +const PIT_CLIMB_RIGHT_SIZE := Vector2(32, 16) signal boss_gate_entered(body: Node) signal boss_slain @@ -51,9 +62,10 @@ func build(host: Node2D) -> ExecutionerBoss: if platforms == null or hooks == null or pickups == null or props == null: return null - # 毒坑两级踏脚。旧坐标把低梁贴在地面行人头顶(424,300), - # 小台埋进 GroundRight 土层(496,336)。单跳约 37px(v=-268,g=980), - # 低台离地 64px、高台再高 36px,coyote 单跳能从低接到高。 + # 毒坑:抬底 + 两岸矮唇,单跳能爬回地面。高踏石仍是可选路线。 + _tune_toxin_pit(platforms) + _place_pit_step(platforms, "PitClimbLeft", "ground", PIT_CLIMB_LEFT_POS, PIT_CLIMB_LEFT_SIZE) + _place_pit_step(platforms, "PitClimbRight", "ground", PIT_CLIMB_RIGHT_POS, PIT_CLIMB_RIGHT_SIZE) var beam := PLATFORM.instantiate() beam.name = "PitStepHigh" beam.skin = "floating" @@ -146,6 +158,25 @@ static func place_forge_shelter(host: Node2D) -> AtmosphereZone: return shelter +func _tune_toxin_pit(platforms: Node2D) -> void: + var pit := platforms.get_node_or_null("ToxinPit") as SolidPlatform + if pit == null: + return + pit.position = Vector2(PIT_X0, PIT_FLOOR_Y) + pit.size = PIT_FLOOR_SIZE + + +func _place_pit_step(platforms: Node2D, node_name: String, skin: String, pos: Vector2, size: Vector2) -> void: + if platforms.get_node_or_null(node_name) != null: + return + var step: SolidPlatform = PLATFORM.instantiate() + step.name = node_name + step.skin = skin + step.position = pos + step.size = size + platforms.add_child(step) + + func _on_boss_gate(body: Node) -> void: boss_gate_entered.emit(body) diff --git a/scripts/levels/level01_parallax.gd b/scripts/levels/level01_parallax.gd index fe07c9a..4438c1a 100644 --- a/scripts/levels/level01_parallax.gd +++ b/scripts/levels/level01_parallax.gd @@ -5,51 +5,54 @@ extends Node ## Mood / fog / sky follow WorldClock — existing plates stay, only their modulate moves. const FOG_PATH := "res://assets/env/fog_band.png" -## 近景剪影带(motion 0.65):一段 512px 宽的图样平铺,枯树/石碑压成暗紫剪影, +## Godot 4.7 的 ParallaxLayer 走 canvas_set_item_mirroring,把 repeat_times 写死成 1。 +## 远山 192×1.7=326,引擎只再画一份 → 652px,刚好比 640 视口多一点。镜头前探/震屏 +## 把循环缝扫到左边,紫山就像被裁掉。Parallax2D 可以指定 repeat_times,循环单元 +## 仍是一张图的宽度,份数必须盖住视口再加一点余量。 +const COVER_PAD := 128.0 +const MIN_REPEAT_TIMES := 4 +## 近景剪影带(scroll 0.65):一段 512px 宽的图样平铺,枯树/石碑压成暗紫剪影, ## 填在远景 Hills(0.48) 与游玩层(1.0) 之间,背景不再是三张图硬叠。 const SIL_PATTERN := [ - # [path, x, feet_y, scale]。树不要缩太狠:密集枝干缩到一半以下会糊成实心色板。 - [Level01Env.DECOR_STONE_2, 14.0, 300.0, 1.0], - [Level01Env.DECOR_TREE_2, 46.0, 300.0, 0.72], - [Level01Env.DECOR_STONE_1, 224.0, 303.0, 1.0], - [Level01Env.DECOR_STATUE, 282.0, 300.0, 0.85], - [Level01Env.DECOR_TREE_1, 342.0, 302.0, 0.62], - [Level01Env.DECOR_STONE_3, 476.0, 300.0, 1.0], + [Level01Env.DECOR_TREE_2, 64.0, 320.0, 0.44], + [Level01Env.DECOR_BUSH_S, 150.0, 322.0, 0.70], + [Level01Env.DECOR_BUSH_L, 250.0, 322.0, 0.50], + [Level01Env.DECOR_TREE_3, 430.0, 320.0, 0.38], + [Level01Env.DECOR_BUSH_L, 360.0, 322.0, 0.42], ] const SIL_TINT := Color(0.17, 0.14, 0.24, 0.95) const SIL_SPAN := 512.0 const TINT_FOLLOW := 2.8 -var _far_layer: ParallaxLayer -var _fog_far: ParallaxLayer -var _fog_near: ParallaxLayer -var _fog_far_spr: Sprite2D -var _fog_near_spr: Sprite2D -var _sil_layer: ParallaxLayer -var _far_sprite: Sprite2D +var _far_layer: Parallax2D +var _fog_far: Parallax2D +var _fog_near: Parallax2D +var _sil_layer: Parallax2D var _mood_tint: CanvasModulate var _moon_fill: DirectionalLight2D func build(host: Node2D) -> void: - var backdrop := host.get_node_or_null("ParallaxBackdrop") as ParallaxBackground + var backdrop := host.get_node_or_null("ParallaxBackdrop") as Node if backdrop == null: return - _far_layer = backdrop.get_node_or_null("Far") as ParallaxLayer - if _far_layer != null: - _far_sprite = _far_layer.get_node_or_null("Sprite") as Sprite2D + if backdrop is CanvasLayer: + (backdrop as CanvasLayer).follow_viewport_enabled = false + _far_layer = backdrop.get_node_or_null("Far") as Parallax2D + for child in backdrop.get_children(): + if child is Parallax2D: + cover_layer(child as Parallax2D) var fog_tex: Texture2D = null if ResourceLoader.exists(FOG_PATH): fog_tex = load(FOG_PATH) as Texture2D if fog_tex != null: _fog_far = _add_fog_layer(backdrop, "FogFar", Vector2(0.5, 0.1), 234.0, 0.13, fog_tex) - _fog_far_spr = _fog_far.get_child(0) as Sprite2D _sil_layer = _add_silhouette_layer(backdrop) if fog_tex != null: _fog_near = _add_fog_layer(backdrop, "FogNear", Vector2(0.75, 0.14), 276.0, 0.2, fog_tex) - _fog_near_spr = _fog_near.get_child(0) as Sprite2D # 全局微紫色调:把厚涂角色与紫色墓地轻轻拉到同一冷色轴上。 # CanvasModulate 与余烬巢 PointLight2D 叠在同一视口:夜里压暗,点光打出暖圈。 + # 视差板在独立 CanvasLayer 上,不受 MoodTint 牵连,夜晚远山仍能看清剪影。 var tint := host.get_node_or_null("MoodTint") as CanvasModulate if tint == null: tint = CanvasModulate.new() @@ -81,11 +84,11 @@ func build(host: Node2D) -> void: func _process(delta: float) -> void: var drift := WorldClock.wind_vector().x if _far_layer != null: - _far_layer.motion_offset.x += drift * 8.0 * delta + _far_layer.scroll_offset.x += drift * 8.0 * delta if _fog_far != null: - _fog_far.motion_offset.x += drift * 18.0 * delta + _fog_far.scroll_offset.x += drift * 18.0 * delta if _fog_near != null: - _fog_near.motion_offset.x += drift * 32.0 * delta + _fog_near.scroll_offset.x += drift * 32.0 * delta _follow_atmosphere(delta) @@ -97,43 +100,90 @@ func _follow_atmosphere(delta: float) -> void: var k := 1.0 if delta > 10.0 else (1.0 - exp(-TINT_FOLLOW * delta)) if _mood_tint != null: _mood_tint.color = _mood_tint.color.lerp(WorldClock.mood_tint(), k) - if _fog_far_spr != null: + if _fog_far != null: var fa := WorldClock.fog_far_alpha() - _fog_far_spr.modulate = _fog_far_spr.modulate.lerp(Color(1, 1, 1, fa), k) - if _fog_near_spr != null: + _fog_far.modulate = _fog_far.modulate.lerp(Color(1, 1, 1, fa), k) + if _fog_near != null: var na := WorldClock.fog_near_alpha() - _fog_near_spr.modulate = _fog_near_spr.modulate.lerp(Color(1, 1, 1, na), k) + _fog_near.modulate = _fog_near.modulate.lerp(Color(1, 1, 1, na), k) if _sil_layer != null: _sil_layer.modulate = _sil_layer.modulate.lerp(WorldClock.silhouette_modulate(), k) - if _far_sprite != null: - _far_sprite.modulate = _far_sprite.modulate.lerp(WorldClock.sky_modulate(), k) + if _far_layer != null: + _far_layer.modulate = _far_layer.modulate.lerp(WorldClock.sky_modulate(), k) if _moon_fill != null: var e := WorldClock.moon_fill_energy() _moon_fill.energy = e _moon_fill.enabled = e > 0.001 -func _add_fog_layer(backdrop: ParallaxBackground, layer_name: String, motion: Vector2, y: float, alpha: float, tex: Texture2D) -> ParallaxLayer: - var layer := ParallaxLayer.new() +static func tile_width(sprite: Sprite2D) -> float: + if sprite == null or sprite.texture == null: + return 0.0 + return float(sprite.texture.get_width()) * absf(sprite.scale.x) + + +static func copies_for_view(tile: float, view_w: float = 0.0, pad: float = COVER_PAD) -> int: + if tile < 1.0: + return MIN_REPEAT_TIMES + var view := view_w if view_w > 0.0 else float(PresentationMetrics.WORLD_SIZE.x) + return maxi(MIN_REPEAT_TIMES, int(ceil((view + pad) / tile))) + + +## 一层只留一张图,让 Parallax2D 按 repeat_size 重复。repeat_times 必须大于 1, +## 否则又会回到「引擎只再画一份、640 视口露缝」的老问题。 +static func cover_layer(layer: Parallax2D, view_w: float = 0.0) -> void: + if layer == null: + return + layer.follow_viewport = false + var sprites: Array[Sprite2D] = [] + for child in layer.get_children(): + if child is Sprite2D: + sprites.append(child as Sprite2D) + if sprites.is_empty(): + if layer.repeat_size.x >= 1.0: + layer.repeat_times = maxi(layer.repeat_times, MIN_REPEAT_TIMES) + return + var spr := sprites[0] + var tile := tile_width(spr) + if tile < 1.0: + return + for i in range(sprites.size() - 1, 0, -1): + var extra := sprites[i] + layer.remove_child(extra) + extra.free() + # 略小于实际宽度,像素吸附时副本重叠一丁点,避免 326.4 → 326 露出天空缝。 + var step := floorf(tile) + if step < 1.0: + step = tile + layer.repeat_size = Vector2(step, 0.0) + layer.repeat_times = copies_for_view(step, view_w) + + +func _add_fog_layer(backdrop: Node, layer_name: String, motion: Vector2, y: float, alpha: float, tex: Texture2D) -> Parallax2D: + var layer := Parallax2D.new() layer.name = layer_name - layer.motion_scale = motion - layer.motion_mirroring = Vector2(SIL_SPAN, 0) + layer.scroll_scale = motion + layer.follow_viewport = false var spr := Sprite2D.new() spr.texture = tex spr.centered = false spr.position = Vector2(0, y) spr.scale = Vector2(SIL_SPAN / float(tex.get_width()), 1.0) - spr.modulate = Color(1, 1, 1, alpha) + spr.modulate = Color.WHITE layer.add_child(spr) + cover_layer(layer) + layer.modulate = Color(1, 1, 1, alpha) backdrop.add_child(layer) return layer -func _add_silhouette_layer(backdrop: ParallaxBackground) -> ParallaxLayer: - var layer := ParallaxLayer.new() +func _add_silhouette_layer(backdrop: Node) -> Parallax2D: + var layer := Parallax2D.new() layer.name = "NearSilhouette" - layer.motion_scale = Vector2(0.65, 0.12) - layer.motion_mirroring = Vector2(SIL_SPAN, 0) + layer.scroll_scale = Vector2(0.65, 0.12) + layer.follow_viewport = false + layer.repeat_size = Vector2(SIL_SPAN, 0) + layer.repeat_times = MIN_REPEAT_TIMES var any := false for item in SIL_PATTERN: var path := item[0] as String diff --git a/scripts/levels/level01_static.gd b/scripts/levels/level01_static.gd index 87c1c18..3b40924 100644 --- a/scripts/levels/level01_static.gd +++ b/scripts/levels/level01_static.gd @@ -8,10 +8,17 @@ const PLAYER := preload("res://scenes/player/Player.tscn") const CAMERA := preload("res://scenes/camera/GameCamera.tscn") const HUD := preload("res://scenes/ui/HUD.tscn") -@onready var plate: PressurePlate = $Props/PressurePlate -@onready var door: ArenaDoor = $Props/Door +@onready var plate: PressurePlate = get_node_or_null("Props/PressurePlate") +@onready var door: ArenaDoor = get_node_or_null("Props/Door") const DEFAULT_SPAWN := Vector2(96, 290) +const GROUND_TOP := 320.0 +const JUMP_REACH := 37.0 +const STEP_RISE := 32.0 +const TEACH_TERRACE_POS := Vector2(228, 288) +const TEACH_TERRACE_SIZE := Vector2(40, 32) +const TEACH_MID_Y := 256.0 +const PLATFORM := preload("res://scenes/world/Platform.tscn") const EAST_LIMIT := Level01EastWing.EAST_LIMIT const EAST_FLOOR_X := Level01EastWing.EAST_FLOOR_X @@ -20,14 +27,25 @@ var _player: Player var _boss: ExecutionerBoss var _wing: Level01EastWing var _beats: Level01StoryBeats +## Tests that only need the game_world group skip actor/layout boot. +var run_slice := true + + +func _enter_tree() -> void: + add_to_group("game_world") func _ready() -> void: - DisplayFit.apply() + if not run_slice: + return + var restored := false + if SaveData.has_save(): + restored = SaveData.load_game() _extend_east() + _tune_play_layout() _spawn_decor() _build_parallax_extras() - _spawn_actors() + _spawn_actors(restored) _wire_props() _bind_story() call_deferred("_try_wake") @@ -38,23 +56,27 @@ func _wire_props() -> void: plate.activated.connect(door.open_door) var toxin := get_node_or_null("Props/ToxinPool") as ToxinPool if toxin != null: - toxin.configure(Vector2(112, 32)) + toxin.configure(Level01EastWing.PIT_WATER_SIZE) -func _spawn_actors() -> void: +func _spawn_actors(restored: bool = false) -> void: _player = PLAYER.instantiate() _player.position = DEFAULT_SPAWN add_child(_player) - if SaveData.has_save(): - SaveData.load_game() - if SaveData.pending_spawn != Vector2.INF: - _player.position = SaveData.consume_pending_spawn() - elif SaveData.data.has("player") and (SaveData.data["player"] as Dictionary).has("pos"): - _player.position = SaveData.data["player"]["pos"] + var from_checkpoint := SaveData.entering_from_checkpoint + if SaveData.has_pending_spawn(): + _player.position = SaveData.consume_pending_spawn() + elif restored and SaveData.data.has("player") and (SaveData.data["player"] as Dictionary).has("pos"): + _player.position = SaveData.data["player"]["pos"] + if restored: SaveData.apply_player(_player) SaveData.apply_world(self) SaveData.apply_consumed(self) + if from_checkpoint: + _player.health.heal_full() + _player.toxin.purify(1.0) + GameEvents.player_health_changed.emit(_player.health.current, _player.health.max_hp) var cam: GameCamera = CAMERA.instantiate() add_child(cam) @@ -62,7 +84,29 @@ func _spawn_actors() -> void: cam.global_position = _player.global_position + Vector2(0, -18) var hud: CanvasLayer = HUD.instantiate() - add_child(hud) + GameContext.ui_host(self).add_child(hud) + + +func _tune_play_layout() -> void: + var platforms := get_node_or_null("Platforms") as Node2D + place_teach_layout(platforms) + + +func place_teach_layout(platforms: Node2D) -> void: + if platforms == null: + return + if platforms.get_node_or_null("TeachTerrace") == null: + var terrace: SolidPlatform = PLATFORM.instantiate() + terrace.name = "TeachTerrace" + terrace.skin = "ground" + terrace.position = TEACH_TERRACE_POS + terrace.size = TEACH_TERRACE_SIZE + terrace.cap_left = true + terrace.cap_right = true + platforms.add_child(terrace) + var mid := platforms.get_node_or_null("Plat_268_248") as SolidPlatform + if mid != null: + mid.position.y = TEACH_MID_Y func _spawn_decor() -> void: @@ -113,6 +157,7 @@ func _bind_story() -> void: func _try_wake() -> void: if _beats != null: _beats.try_wake() + SaveData.entering_from_checkpoint = false static func should_spawn_executioner() -> bool: diff --git a/scripts/levels/test_arena.gd b/scripts/levels/test_arena.gd index 07f4cc0..1048ef9 100644 --- a/scripts/levels/test_arena.gd +++ b/scripts/levels/test_arena.gd @@ -17,6 +17,10 @@ const SOCKET := preload("res://scenes/interactables/SocketStation.tscn") const GATE := preload("res://scenes/interactables/RustyGate.tscn") +func _enter_tree() -> void: + add_to_group("game_world") + + func _ready() -> void: _build_backdrop() _build_collision() @@ -124,7 +128,7 @@ func _spawn_actors() -> void: cam.global_position = player.global_position + Vector2(0, -18) var hud: CanvasLayer = HUD.instantiate() - add_child(hud) + GameContext.ui_host(self).add_child(hud) GameEvents.announcement.emit("锈墓试验场 — 用斩击的判定帧把渣弹打回去") diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 35ddd60..0087383 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -86,9 +86,11 @@ func _physics_process(delta: float) -> void: var face := float(controller.facing) visual.scale.x = face melee.scale.x = face - # 落地扬尘:从明显下落转为触地时在脚边 puff 一团灰。 - if is_on_floor() and not _was_on_floor and fall_speed > 60.0: + # 落地扬尘:从明显下落转为触地时在脚边 puff;重落再给镜头一下。 + if is_on_floor() and not _was_on_floor and fall_speed > 48.0: Fx.dust_puff(global_position + Vector2(0.0, -2.0)) + if fall_speed > 130.0: + Juice.shake(2.2, 90) _was_on_floor = is_on_floor() _update_visual(delta) _poll_slash_arc(face) @@ -316,16 +318,13 @@ func _spawn_fire_trail() -> void: box_rect.size = Vector2(42, 10) box_shape.shape = box_rect box.add_child(box_shape) - var host: Node = get_tree().current_scene + var host: Node = GameContext.world_effects(self) if host == null: host = get_parent() host.add_child(box) box.global_position = origin box.monitoring = true - get_tree().create_timer(1.2).timeout.connect(func() -> void: - if is_instance_valid(box): - box.queue_free() - ) + GameContext.free_after(box, 1.2) func _spawn_blast() -> void: @@ -345,16 +344,16 @@ func _spawn_blast() -> void: box_rect.size = Vector2(48, 20) box_shape.shape = box_rect box.add_child(box_shape) - get_parent().add_child(box) + var host: Node = GameContext.world_effects(self) + if host == null: + host = get_parent() + host.add_child(box) box.global_position = origin box.monitoring = true for node in get_tree().get_nodes_in_group("pressure_plate"): if node is PressurePlate and (node as Node2D).global_position.distance_to(origin) < 56.0: (node as PressurePlate).slam() - get_tree().create_timer(0.18).timeout.connect(func() -> void: - if is_instance_valid(box): - box.queue_free() - ) + GameContext.free_after(box, 0.18) func _poll_interact() -> void: @@ -417,14 +416,19 @@ func _on_died() -> void: add_child(wait) wait.start() await wait.timeout - if not is_instance_valid(self) or get_tree() == null or get_tree().current_scene == null: + if not is_instance_valid(self) or get_tree() == null: return var nest := _resolve_lit_nest() + var reload := GameContext.world_scene_path(self) + if reload == "" and get_tree().current_scene != null: + reload = get_tree().current_scene.scene_file_path + if reload == "": + return if nest != null: GameEvents.player_respawned.emit() - SaveData.respawn(get_tree().current_scene.scene_file_path, nest.global_position) + SaveData.respawn(reload, nest.global_position) else: - Director.fade_to(get_tree().current_scene.scene_file_path) + Director.fade_to(reload) ## 存档里的巢路径是绝对 NodePath;换场景根名或测试宿主时可能对不上,再试叶子名。 @@ -432,16 +436,16 @@ func _resolve_lit_nest() -> Node2D: var nest_path := SaveData.last_lit_nest() if nest_path == "" or get_tree() == null: return null - var scene := get_tree().current_scene + var scene := GameContext.world_root(self) + if scene == null: + scene = get_tree().current_scene if scene == null: return null - var nest := scene.get_node_or_null(nest_path) + var nest := SaveData.resolve_saved_node(scene, nest_path) if nest == null: - nest = get_tree().root.get_node_or_null(nest_path) + nest = scene.get_node_or_null(nest_path) if nest == null: - var leaf := nest_path.get_file() - if leaf != "": - nest = scene.find_child(leaf, true, false) + nest = get_tree().root.get_node_or_null(nest_path) if nest is Node2D and is_instance_valid(nest): return nest as Node2D return null diff --git a/scripts/ui/display_fit.gd b/scripts/ui/display_fit.gd index b607851..5a78b6f 100644 --- a/scripts/ui/display_fit.gd +++ b/scripts/ui/display_fit.gd @@ -1,16 +1,37 @@ class_name DisplayFit extends Object -## Integer-scale 1280x720 onto 1440p (2x) and 4K (3x). Not an Autoload. +## Rustgrave display: 1280×720 design UI, 640×360 world image, integer content rect. +## The root stays in canvas-items mode. GamePresentation owns the 640x360 +## world image and PresentationMetrics owns its centered integer-sized rect; +## keeping both layers in viewport mode would scale the world a second time. const VIEW_W := 1280 const VIEW_H := 720 static func apply() -> void: + apply_game() + + +static func apply_menu() -> void: + _set_root_scale(Window.CONTENT_SCALE_ASPECT_KEEP) + + +static func apply_game() -> void: + # GamePresentation letterboxes the integer world rect itself. KEEP would + # add a second set of bars around the 1280×720 canvas. + _set_root_scale(Window.CONTENT_SCALE_ASPECT_IGNORE) + + +static func _set_root_scale(aspect: int) -> void: if DisplayServer.get_name() == "headless": return - if DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_WINDOWED: - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MAXIMIZED) + var loop := Engine.get_main_loop() + if loop is SceneTree: + var root := (loop as SceneTree).root + root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS + root.content_scale_aspect = aspect + root.content_scale_stretch = Window.CONTENT_SCALE_STRETCH_FRACTIONAL static func toggle_fullscreen() -> void: diff --git a/scripts/ui/game_presentation.gd b/scripts/ui/game_presentation.gd new file mode 100644 index 0000000..7a90f21 --- /dev/null +++ b/scripts/ui/game_presentation.gd @@ -0,0 +1,83 @@ +class_name GamePresentation +extends Control +## Only the world is rasterized at 640x360. Root-window UI is drawn at native size. + +var world_viewport: SubViewport +var world_image: TextureRect + + +func _enter_tree() -> void: + add_to_group("game_presentation") + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + DisplayFit.apply_game() + var background := ColorRect.new() + background.name = "LetterboxBackground" + background.set_anchors_preset(Control.PRESET_FULL_RECT) + background.color = Color.BLACK + background.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(background) + var ui := Node.new() + ui.name = "UiHost" + add_child(ui) + ui.child_entered_tree.connect(func(node: Node) -> void: + PresentationMetrics.bind_descendants(node)) + world_viewport = SubViewport.new() + world_viewport.name = "WorldViewport" + world_viewport.size = PresentationMetrics.WORLD_SIZE + world_viewport.world_2d = World2D.new() + world_viewport.disable_3d = true + world_viewport.snap_2d_transforms_to_pixel = true + world_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + world_viewport.handle_input_locally = true + world_viewport.audio_listener_enable_2d = true + world_viewport.canvas_item_default_texture_filter = Viewport.DEFAULT_CANVAS_ITEM_TEXTURE_FILTER_NEAREST + add_child(world_viewport) + world_image = TextureRect.new() + world_image.name = "WorldImage" + world_image.texture = world_viewport.get_texture() + world_image.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + world_image.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + world_image.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(world_image) + var packed := load(GameContext.WORLD_PATH) as PackedScene + world_viewport.add_child(packed.instantiate()) + get_tree().root.size_changed.connect(_layout) + _layout() + GameContext.suppress_gameplay_input() + + +func _exit_tree() -> void: + var scene_tree := get_tree() + if scene_tree != null and scene_tree.root.size_changed.is_connected(_layout): + scene_tree.root.size_changed.disconnect(_layout) + PresentationMetrics.unbind_runtime_overlays(scene_tree) + DisplayFit.apply_menu() + + +func _layout() -> void: + var rect: Rect2 = PresentationMetrics.for_window(get_tree().root)["canvas_rect"] + world_image.position = rect.position + world_image.size = rect.size + PresentationMetrics.bind_runtime_overlays(get_tree()) + + +func _unhandled_input(event: InputEvent) -> void: + if world_viewport == null or not GameContext.gameplay_input_enabled(): + return + var forwarded := event + if event is InputEventMouse: + var mouse := event as InputEventMouse + var rect := Rect2(world_image.position, world_image.size) + if not rect.has_point(mouse.position): + GameContext.suppress_gameplay_input() + return + var transform := Transform2D.IDENTITY + transform = transform.scaled(Vector2(PresentationMetrics.WORLD_SIZE) / rect.size) + transform.origin = -rect.position * transform.get_scale() + forwarded = event.xformed_by(transform) + world_viewport.push_input(forwarded, true) + if world_viewport.is_input_handled(): + get_viewport().set_input_as_handled() diff --git a/scripts/ui/game_presentation.gd.uid b/scripts/ui/game_presentation.gd.uid new file mode 100644 index 0000000..29f8ae1 --- /dev/null +++ b/scripts/ui/game_presentation.gd.uid @@ -0,0 +1 @@ +uid://ckdq3qc0sqceq diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 7e389ee..6ef10d0 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -107,6 +107,7 @@ func _build() -> void: _pause = PAUSE_MENU.instantiate() add_child(_pause) WorldClock.isolate_ui_layer(_pause) + PresentationMetrics.bind_descendants(self) func _build_stat_panel(root: Control) -> void: @@ -171,8 +172,8 @@ func _build_prompt(root: Control) -> void: _prompt_panel.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) _prompt_panel.grow_horizontal = Control.GROW_DIRECTION_BOTH _prompt_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN - _prompt_panel.offset_top = -74.0 - _prompt_panel.offset_bottom = -74.0 + _prompt_panel.offset_top = -22.0 + _prompt_panel.offset_bottom = -22.0 _prompt_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _prompt_panel.visible = false root.add_child(_prompt_panel) diff --git a/scripts/ui/presentation_metrics.gd b/scripts/ui/presentation_metrics.gd new file mode 100644 index 0000000..e02cfe2 --- /dev/null +++ b/scripts/ui/presentation_metrics.gd @@ -0,0 +1,84 @@ +class_name PresentationMetrics +extends Object +## Integer content rectangle for the 640×360 world image inside a 1280×720 UI. + +const WORLD_SIZE := Vector2i(640, 360) +const UI_SIZE := Vector2i(1280, 720) + + +static func calculate(window_size: Vector2i) -> Dictionary: + var scale := maxi(1, mini(window_size.x / WORLD_SIZE.x, window_size.y / WORLD_SIZE.y)) + var content := Vector2i(WORLD_SIZE.x * scale, WORLD_SIZE.y * scale) + var origin := Vector2i( + int((window_size.x - content.x) / 2.0), + int((window_size.y - content.y) / 2.0)) + var physical := Rect2(Vector2(origin), Vector2(content)) + var to_canvas := Vector2(UI_SIZE) / Vector2(window_size) + var canvas_rect := Rect2(physical.position * to_canvas, physical.size * to_canvas) + var ui_transform := Transform2D( + Vector2(canvas_rect.size.x / float(UI_SIZE.x), 0.0), + Vector2(0.0, canvas_rect.size.y / float(UI_SIZE.y)), + canvas_rect.position) + return { + "scale": scale, + "physical_rect": physical, + "canvas_rect": canvas_rect, + "ui_transform": ui_transform, + } + + +static func for_window(win: Window) -> Dictionary: + return calculate(win.size) + + +static func bind_layer(layer: CanvasLayer) -> void: + if layer == null: + return + WorldClock.isolate_ui_layer(layer) + if not layer.is_inside_tree(): + return + var metrics := for_window(layer.get_tree().root) + layer.transform = metrics["ui_transform"] + + +static func unbind_layer(layer: CanvasLayer) -> void: + if layer == null: + return + layer.transform = Transform2D.IDENTITY + + +static func bind_descendants(root: Node) -> void: + if root == null: + return + if root is CanvasLayer: + bind_layer(root) + for child in root.get_children(): + bind_descendants(child) + + +static func bind_runtime_overlays(scene_tree: SceneTree = null) -> void: + if scene_tree == null: + scene_tree = Engine.get_main_loop() as SceneTree + if scene_tree == null: + return + for autoload_name in ["Director", "Juice"]: + var node := scene_tree.root.get_node_or_null(autoload_name) + if node != null: + bind_descendants(node) + var presentation := scene_tree.get_first_node_in_group("game_presentation") + if presentation != null: + bind_descendants(presentation.get_node_or_null("UiHost")) + + +static func unbind_runtime_overlays(scene_tree: SceneTree = null) -> void: + if scene_tree == null: + scene_tree = Engine.get_main_loop() as SceneTree + if scene_tree == null: + return + for autoload_name in ["Director", "Juice"]: + var node := scene_tree.root.get_node_or_null(autoload_name) + if node == null: + continue + for child in node.get_children(): + if child is CanvasLayer: + unbind_layer(child) diff --git a/scripts/ui/presentation_metrics.gd.uid b/scripts/ui/presentation_metrics.gd.uid new file mode 100644 index 0000000..edcbdb1 --- /dev/null +++ b/scripts/ui/presentation_metrics.gd.uid @@ -0,0 +1 @@ +uid://cy76py63gk4sv diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index 6b53cdd..e4d487a 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -35,7 +35,7 @@ func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) mouse_filter = Control.MOUSE_FILTER_STOP DisplayServer.window_set_title("Rustgrave") - DisplayFit.apply() + DisplayFit.apply_menu() WorldClock.hold_for_menu() _build_backdrop() _build_masthead() diff --git a/scripts/world/cine_fx.gd b/scripts/world/cine_fx.gd index 627c99e..53c075e 100644 --- a/scripts/world/cine_fx.gd +++ b/scripts/world/cine_fx.gd @@ -1,6 +1,7 @@ class_name CineFx extends Node2D ## World-space depth dust / scrap plus a faint moon shaft. +## Sits behind the knight (z=-1) so near motes never grey the silhouette. ## Presentation only — wind and zone come from WorldClock. const FAR_COUNT := 22 @@ -17,7 +18,7 @@ var _headless := false func _ready() -> void: - z_index = 3 + z_index = -1 _headless = DisplayServer.get_name() == "headless" _mote_tex = _load_motes() _shaft = Sprite2D.new() @@ -86,7 +87,8 @@ func _spawn_layer(bucket: Array[Sprite2D], count: int, scale: float, col: Color) spr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST spr.scale = Vector2(scale, scale) spr.modulate = col - spr.position = Vector2(randf() * 1280.0, randf() * 360.0) + spr.position = Vector2(randf() * float(PresentationMetrics.WORLD_SIZE.x), + randf() * float(PresentationMetrics.WORLD_SIZE.y)) add_child(spr) bucket.append(spr) diff --git a/scripts/world/weather_fx.gd b/scripts/world/weather_fx.gd index e7a8a04..57b58d8 100644 --- a/scripts/world/weather_fx.gd +++ b/scripts/world/weather_fx.gd @@ -1,37 +1,51 @@ class_name WeatherFx extends CanvasLayer -## Screen-space weather: rain / rust rain streaks + horizontal ember motes. -## Alpha follows WorldClock; indoor / title / menu hide particles. +## Rain is drawn on the 640×360 canvas so MoodTint cannot crush it, but every +## drop is simulated in world space: the streak tip is the collision point. +## Hits (ground layer 1, toxin volume, Ember Nest bowl) splash and recycle +## above the camera. Embers stay decorative screen-space motes. -const DROP_COUNT := 52 +const DROP_COUNT := 64 +const DROP_H := 6.0 const FALL_SPEED := 240.0 const EMBER_COUNT := 20 const EMBER_PATH := "res://assets/ui/ember_motes.png" const EMBER_FRAMES := 8 const EMBER_DRIFT := 78.0 +const GROUND_MASK := 1 +const SPLASH_RATE := 56.0 +const SPLASH_BURST := 18.0 var _drops: Array[Sprite2D] = [] +var _world: Array[Vector2] = [] var _embers: Array[Sprite2D] = [] var _alpha := 0.0 var _ember_alpha := 0.0 var _tex: Texture2D var _ember_tex: Texture2D +var _splash_budget := 0.0 +var _warm := 0.12 func _ready() -> void: layer = 4 follow_viewport_enabled = false + add_to_group("weather_fx") if DisplayServer.get_name() == "headless": set_process(false) return + var bounds := _bounds() _tex = _make_drop_tex() + var add := CanvasItemMaterial.new() + add.blend_mode = CanvasItemMaterial.BLEND_MODE_ADD for i in DROP_COUNT: var spr := Sprite2D.new() spr.texture = _tex spr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST - spr.centered = true - spr.position = Vector2(randf() * 1280.0, randf() * 720.0) - spr.modulate = Color(0.78, 0.84, 0.92, 0.0) + spr.centered = false + spr.material = add + spr.position = Vector2(randf() * bounds.x, randf() * bounds.y) + spr.modulate = Color(0.86, 0.90, 0.96, 0.0) add_child(spr) _drops.append(spr) _ember_tex = _load_ember_tex() @@ -43,35 +57,48 @@ func _ready() -> void: spr.frame = randi() % EMBER_FRAMES spr.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST spr.centered = true - spr.position = Vector2(randf() * 1280.0, randf() * 720.0) + spr.position = Vector2(randf() * bounds.x, randf() * bounds.y) spr.modulate = Color(1.0, 0.62, 0.38, 0.0) spr.scale = Vector2(0.85, 0.85) add_child(spr) _embers.append(spr) + var rain0 := WorldClock.rain_opacity() + if rain0 > 0.0: + _alpha = rain0 func _process(delta: float) -> void: var target := WorldClock.rain_opacity() - _alpha = move_toward(_alpha, target, delta / 4.5) - var rust := WorldClock.rust_rain_mix() if WorldClock.weather_fx_allowed() else 0.0 - var rain_col := Color(0.78, 0.84, 0.92).lerp(Color(0.80, 0.48, 0.34), clampf(rust, 0.0, 1.0)) + _alpha = move_toward(_alpha, target, delta / 2.2) + # 锈色只跟锈雨天气走。夜晚普通雨的 soak 仍由 rust_rain_mix 处理, + # 但银白雨丝被lerp成锈橙后再叠 MoodTint 会发脏、盖住骑士。 + var rust := WorldClock.weather_weight(WorldClock.Weather.RUST_RAIN) if WorldClock.weather_fx_allowed() else 0.0 + var rain_col := Color(0.90, 0.94, 1.0).lerp(Color(0.86, 0.52, 0.36), clampf(rust, 0.0, 1.0)) if _alpha <= 0.001 and target <= 0.0: for spr in _drops: if spr.modulate.a > 0.0: spr.modulate.a = 0.0 else: - var vis := _alpha * 0.58 - for spr in _drops: - var p := spr.position - p.y += FALL_SPEED * delta - p.x += rain_wind_x() * delta - if p.y > 728.0: - p.y = -8.0 - p.x = randf() * 1280.0 - elif p.x < -8.0: - p.x = 1288.0 - spr.position = Vector2(roundf(p.x), roundf(p.y)) - spr.modulate = Color(rain_col.r, rain_col.g, rain_col.b, vis) + _ensure_world_positions() + var vis := _alpha * 0.95 + var view := _camera_world_rect() + _splash_budget = minf(_splash_budget + SPLASH_RATE * delta, SPLASH_BURST) + _warm = maxf(0.0, _warm - delta) + var wind := rain_wind_x() * delta + var fall := Vector2(wind, FALL_SPEED * delta) + for i in _drops.size(): + var from := _world[i] + var to := from + fall + if to.x < view.position.x - 8.0: + to.x = view.end.x + 8.0 + elif to.x > view.end.x + 8.0: + to.x = view.position.x - 8.0 + var hit := resolve_fall(from, to, view) + _world[i] = hit["world"] + if _warm <= 0.0 and hit["kind"] != &"": + _try_splash(hit["kind"], hit["point"]) + _drops[i].position = _tip_to_canvas(_world[i]) + _drops[i].modulate = Color(rain_col.r, rain_col.g, rain_col.b, vis) _tick_embers(delta) @@ -84,16 +111,20 @@ func _tick_embers(delta: float) -> void: spr.modulate.a = 0.0 return var vis := _ember_alpha * 0.28 + var bounds := _bounds() for spr in _embers: var p := spr.position p.x += rain_wind_x() * (EMBER_DRIFT / 36.0) * delta p.y += sin(p.x * 0.04 + p.y * 0.01) * 10.0 * delta if p.x < -10.0: - p.x = 1290.0 - p.y = randf() * 720.0 + p.x = bounds.x + 10.0 + p.y = randf() * bounds.y + elif p.x > bounds.x + 10.0: + p.x = -10.0 + p.y = randf() * bounds.y elif p.y < -10.0: - p.y = 728.0 - elif p.y > 730.0: + p.y = bounds.y + 8.0 + elif p.y > bounds.y + 10.0: p.y = -8.0 spr.position = Vector2(roundf(p.x), roundf(p.y)) if spr.hframes > 1 and randf() < delta * 6.0: @@ -101,6 +132,22 @@ func _tick_embers(delta: float) -> void: spr.modulate = Color(1.0, 0.62, 0.38, vis) +func _bounds() -> Vector2: + var host := get_parent() + if host != null: + var host_vp := host.get_viewport() + if host_vp != null: + var host_size := host_vp.get_visible_rect().size + if host_size.x > 1.0 and host_size.y > 1.0: + return host_size + var vp := get_viewport() + if vp != null: + var size := vp.get_visible_rect().size + if size.x > 1.0 and size.y > 1.0: + return size + return Vector2(PresentationMetrics.WORLD_SIZE) + + func rain_wind_x() -> float: var v := WorldClock.wind_vector().x if absf(v) < 0.02: @@ -108,6 +155,236 @@ func rain_wind_x() -> float: return v * 150.0 +func _ensure_world_positions() -> void: + if _world.size() == _drops.size() and not _world.is_empty(): + return + _world.clear() + var view := _camera_world_rect() + for spr in _drops: + var p := Vector2( + view.position.x + randf() * maxf(view.size.x, 1.0), + view.position.y + randf() * maxf(view.size.y, 1.0) + ) + _world.append(p) + spr.centered = false + spr.position = _tip_to_canvas(p) + + +func _camera_world_rect() -> Rect2: + var size := _bounds() + var cam := _active_camera() + if cam == null: + return Rect2(Vector2.ZERO, size) + var center := cam.get_screen_center_position() + return Rect2(center - size * 0.5, size) + + +func _as_view(view: Variant) -> Rect2: + if view is Rect2: + return view + if view is Vector2: + return Rect2(Vector2.ZERO, view) + return _camera_world_rect() + + +func _recycle_world(view: Rect2) -> Vector2: + return Vector2( + view.position.x + randf() * maxf(view.size.x, 1.0), + view.position.y - randf_range(4.0, 18.0) + ) + + +func _tip_to_canvas(tip: Vector2) -> Vector2: + var canvas := world_to_canvas(tip) + return Vector2(roundf(canvas.x - 1.0), roundf(canvas.y - DROP_H)) + + +## Viewport (canvas) → world. CanvasLayer can miss Viewport.get_camera_2d(), +## so we also look at the parent world and the game_camera group. +func canvas_to_world(canvas: Vector2) -> Vector2: + var cam := _active_camera() + if cam == null: + return canvas + var size := _bounds() + var zoom := cam.zoom + if zoom.x == 0.0 or zoom.y == 0.0: + zoom = Vector2.ONE + return cam.get_screen_center_position() + (canvas - size * 0.5) / zoom + + +func world_to_canvas(world: Vector2) -> Vector2: + var cam := _active_camera() + if cam == null: + return world + var size := _bounds() + var zoom := cam.zoom + if zoom.x == 0.0 or zoom.y == 0.0: + zoom = Vector2.ONE + return size * 0.5 + (world - cam.get_screen_center_position()) * zoom + + +func _active_camera() -> Camera2D: + var host := get_parent() + if host != null: + var host_vp := host.get_viewport() + if host_vp != null: + var cam := host_vp.get_camera_2d() + if cam != null: + return cam + var tree := get_tree() + if tree != null: + var grouped := tree.get_first_node_in_group("game_camera") + if grouped is Camera2D: + return grouped as Camera2D + var own := get_viewport() + if own != null: + return own.get_camera_2d() + return null + + +## Move a drop tip from `from_world` toward `to_world`. On nest / pool / ground +## the streak recycles above the view. Kind is empty when it only wraps. +func resolve_fall(from_world: Vector2, to_world: Vector2, view: Variant) -> Dictionary: + var rect := _as_view(view) + var hit := probe_segment(from_world, to_world) + if hit["kind"] != &"": + var recycled := _recycle_world(rect) + return { + "canvas": world_to_canvas(recycled), + "world": recycled, + "kind": hit["kind"], + "point": hit["point"], + "recycled": true, + } + if to_world.y > rect.end.y + 8.0: + var wrapped := _recycle_world(rect) + return { + "canvas": world_to_canvas(wrapped), + "world": wrapped, + "kind": &"", + "world_miss": to_world, + "point": to_world, + "recycled": true, + } + return { + "canvas": world_to_canvas(to_world), + "world": to_world, + "kind": &"", + "point": to_world, + "recycled": false, + } + + +func probe_segment(from_world: Vector2, to_world: Vector2) -> Dictionary: + var empty := {"kind": &"", "point": to_world} + var nest_hit := _probe_nests(from_world, to_world) + if nest_hit["kind"] != &"": + return nest_hit + var pool_hit := _probe_pools(from_world, to_world) + var ground_hit := _probe_ground(from_world, to_world, empty) + if pool_hit["kind"] != &"" and ground_hit["kind"] != &"": + var pool_y := (pool_hit["point"] as Vector2).y + var ground_y := (ground_hit["point"] as Vector2).y + # 坑唇草皮比水膜高:溅在石头上,不要绿水吻盖过踏步。 + if ground_y <= pool_y + 1.5: + return ground_hit + return pool_hit + if pool_hit["kind"] != &"": + return pool_hit + return ground_hit + + +func _probe_nests(from_world: Vector2, to_world: Vector2) -> Dictionary: + var tree := get_tree() + if tree == null: + return {"kind": &"", "point": to_world} + for node in tree.get_nodes_in_group("ember_nests"): + var nest := node as EmberNest + if nest == null or not is_instance_valid(nest): + continue + var rect: Rect2 = nest.rain_hit_rect() + if rect.has_point(to_world) or _segment_hits_rect(from_world, to_world, rect): + var kind := &"nest_lit" if nest.is_lit() else &"nest" + var at := Vector2(to_world.x, rect.position.y + 6.0) + return {"kind": kind, "point": at} + return {"kind": &"", "point": to_world} + + +func _probe_pools(from_world: Vector2, to_world: Vector2) -> Dictionary: + var tree := get_tree() + if tree == null: + return {"kind": &"", "point": to_world} + for node in tree.get_nodes_in_group("toxin_pools"): + var pool := node as ToxinPool + if pool == null or not is_instance_valid(pool): + continue + var volume: Rect2 = pool.volume_rect() + if volume.has_point(to_world) or _segment_hits_rect(from_world, to_world, volume): + return {"kind": &"toxin", "point": pool.splash_point(to_world.x)} + return {"kind": &"", "point": to_world} + + +func _probe_ground(from_world: Vector2, to_world: Vector2, empty: Dictionary) -> Dictionary: + var space := _space_state() + if space == null: + return empty + if from_world.distance_squared_to(to_world) > 0.01: + var ray := PhysicsRayQueryParameters2D.create(from_world, to_world, GROUND_MASK) + ray.collide_with_areas = false + ray.collide_with_bodies = true + var hit := space.intersect_ray(ray) + if not hit.is_empty(): + return {"kind": &"ground", "point": hit["position"]} + var pq := PhysicsPointQueryParameters2D.new() + pq.position = to_world + pq.collision_mask = GROUND_MASK + pq.collide_with_bodies = true + pq.collide_with_areas = false + var pts := space.intersect_point(pq, 1) + if not pts.is_empty(): + return {"kind": &"ground", "point": to_world} + return empty + + +func _space_state() -> PhysicsDirectSpaceState2D: + var host := get_parent() + if host is Node2D: + var world := (host as Node2D).get_world_2d() + if world != null: + return world.direct_space_state + var vp := get_viewport() + if vp == null or vp.world_2d == null: + return null + return vp.world_2d.direct_space_state + + +static func _segment_hits_rect(from_p: Vector2, to_p: Vector2, rect: Rect2) -> bool: + if rect.has_point(from_p) or rect.has_point(to_p): + return true + var corners := PackedVector2Array([ + rect.position, + Vector2(rect.end.x, rect.position.y), + rect.end, + Vector2(rect.position.x, rect.end.y), + ]) + for i in 4: + var a: Vector2 = corners[i] + var b: Vector2 = corners[(i + 1) % 4] + if Geometry2D.segment_intersects_segment(from_p, to_p, a, b) != null: + return true + return false + + +func _try_splash(kind: StringName, world_pos: Vector2) -> void: + if _splash_budget < 1.0: + return + _splash_budget -= 1.0 + var splash_kind := kind + if kind == &"ground" and WorldClock.is_rust_raining(): + splash_kind = &"rust" + Fx.rain_splash(world_pos, splash_kind) + + func _load_ember_tex() -> Texture2D: if ResourceLoader.exists(EMBER_PATH): var tex := load(EMBER_PATH) as Texture2D @@ -126,12 +403,12 @@ func _make_ember_tex() -> Texture2D: func _make_drop_tex() -> Texture2D: - var img := Image.create(2, 6, false, Image.FORMAT_RGBA8) + var img := Image.create(2, int(DROP_H), false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) - img.set_pixel(0, 0, Color(0.70, 0.78, 0.88, 0.25)) - img.set_pixel(1, 1, Color(0.82, 0.88, 0.94, 0.55)) - img.set_pixel(0, 2, Color(0.88, 0.92, 0.97, 0.70)) - img.set_pixel(1, 3, Color(0.85, 0.90, 0.96, 0.50)) - img.set_pixel(0, 4, Color(0.78, 0.84, 0.92, 0.28)) - img.set_pixel(1, 5, Color(0.72, 0.80, 0.90, 0.12)) + img.set_pixel(0, 0, Color(0.84, 0.90, 1.00, 0.28)) + img.set_pixel(1, 1, Color(0.92, 0.96, 1.00, 0.82)) + img.set_pixel(0, 2, Color(0.98, 1.00, 1.00, 1.00)) + img.set_pixel(1, 3, Color(0.94, 0.97, 1.00, 0.88)) + img.set_pixel(0, 4, Color(0.88, 0.93, 1.00, 0.55)) + img.set_pixel(1, 5, Color(0.82, 0.88, 0.98, 0.28)) return ImageTexture.create_from_image(img) diff --git a/tests/cine_fx_test.gd b/tests/cine_fx_test.gd index e3bdc9b..e60694f 100644 --- a/tests/cine_fx_test.gd +++ b/tests/cine_fx_test.gd @@ -13,6 +13,7 @@ func teardown() -> void: func test_indoor_hides_mid_motes() -> void: var fx := CineFx.new() add_child(fx) + eq(fx.z_index, -1, "depth dust stays behind the knight") WorldClock.set_zone(WorldClock.Zone.OUTDOORS) WorldClock.set_weather(WorldClock.Weather.HAZE, true) WorldClock._snap_wind_speed() diff --git a/tests/combo_resonance_test.gd b/tests/combo_resonance_test.gd index 7b9868b..d198078 100644 --- a/tests/combo_resonance_test.gd +++ b/tests/combo_resonance_test.gd @@ -11,6 +11,8 @@ func _equip_pair(player: Player) -> void: func _count_hitboxes(host: Node) -> int: var n := 0 + if host == null: + return 0 for child in host.get_children(): if child is Hitbox: n += 1 @@ -25,10 +27,11 @@ func test_blast_requires_resonance_window() -> void: _equip_pair(player) ok(player.inventory.has_pair(AbilityIds.HEAT_FORGE, AbilityIds.EMBER_STEP)) ok(not player.resonance.is_active()) - var before := _count_hitboxes(arena) + var effects := GameContext.world_effects(player) + var before := _count_hitboxes(effects) player.on_melee_active(2) - eq(_count_hitboxes(arena), before, "no blast without resonance") + eq(_count_hitboxes(effects), before, "no blast without resonance") player.resonance.pulse() ok(player.resonance.is_active()) player.on_melee_active(2) - ok(_count_hitboxes(arena) > before, "pair + resonance fires the blast") + ok(_count_hitboxes(effects) > before, "pair + resonance fires the blast") diff --git a/tests/display_fit_test.gd b/tests/display_fit_test.gd index 012b787..f9702d4 100644 --- a/tests/display_fit_test.gd +++ b/tests/display_fit_test.gd @@ -1,5 +1,4 @@ extends TestCase -## Logical viewport stays 1280x720; stretch is integer for 2x / 3x desktops. func test_logical_viewport_stays_1280() -> void: @@ -9,13 +8,13 @@ func test_logical_viewport_stays_1280() -> void: eq(int(ProjectSettings.get_setting("display/window/size/viewport_height")), 720) -func test_stretch_is_integer_keep() -> void: - eq(String(ProjectSettings.get_setting("display/window/stretch/mode")), "viewport") +func test_stretch_mode_and_aspect() -> void: + eq(String(ProjectSettings.get_setting("display/window/stretch/mode")), "canvas_items") eq(String(ProjectSettings.get_setting("display/window/stretch/aspect")), "keep") - var scale_mode: Variant = ProjectSettings.get_setting("display/window/stretch/scale_mode") - ok(String(scale_mode) == "integer" or int(scale_mode) == 1, "integer scale for 1440p / 4K") func test_apply_is_safe_headless() -> void: DisplayFit.apply() + DisplayFit.apply_menu() + DisplayFit.apply_game() ok(true, "headless apply is a no-op") diff --git a/tests/ember_nest_test.gd b/tests/ember_nest_test.gd index 0527ef7..8b03554 100644 --- a/tests/ember_nest_test.gd +++ b/tests/ember_nest_test.gd @@ -52,6 +52,10 @@ func test_unlit_hides_flame() -> void: if sparks != null: ok(not sparks.visible, "unlit nest shows no sparks") eq(sparks.get_child_count(), 0, "unlit nest has no spark children") + var coals := nest.get_node_or_null("Coals") as Sprite2D + ok(coals != null, "Coals overlay exists") + if coals != null: + ok(not coals.visible, "unlit nest shows no live coals") var f0 := flame.frame nest._process(1.0) eq(flame.frame, f0, "unlit flame does not animate") @@ -77,6 +81,8 @@ func test_lit_shows_warm_flame() -> void: ok(sparks != null, "Sparks node exists") if sparks != null: ok(sparks.visible, "lit nest can emit sparks") + var coals := nest.get_node_or_null("Coals") as Sprite2D + ok(coals != null and coals.visible, "lit nest shows the coal bed") nest.apply_persistent_state({"lit": false}) ok(not flame.visible, "relit-off hides the flame again") @@ -96,10 +102,17 @@ func test_collision_unchanged_by_flame() -> void: var shape := col.shape as RectangleShape2D ok(shape != null) if shape != null: - eq(shape.size, Vector2(18, 22)) + eq(shape.size, Vector2(48, 36)) eq(nest.collision_layer, 64) eq(nest.collision_mask, 2) - ok(nest.get_node_or_null("Fill") != null, "tombstone sprite still named Fill") + ok(nest.get_node_or_null("Fill") != null, "brazier sprite still named Fill") + var fill := nest.get_node_or_null("Fill") as Sprite2D + ok(fill != null and fill.texture != null) + if fill != null and fill.texture != null: + eq(String(fill.texture.resource_path), "res://assets/env/ember_nest.png") + ok(fill.texture.get_width() >= 44, "nest is a readable brazier, not 18px rubble") + eq(nest.rain_hit_rect().size, Vector2(48, 36), "rain uses the planted 48×36 iron, not the interact Area") + eq(nest.is_lit(), false) func test_unlit_has_no_nest_light() -> void: diff --git a/tests/fire_trail_test.gd b/tests/fire_trail_test.gd index 2565507..9cf6214 100644 --- a/tests/fire_trail_test.gd +++ b/tests/fire_trail_test.gd @@ -7,16 +7,20 @@ func test_fire_trail_is_not_parented_to_player() -> void: add_child(arena) build_floor(arena) var player := await spawn_player(arena) + var effects := GameContext.world_effects(player) var before: Array = [] - for child in get_tree().current_scene.get_children(): + for child in effects.get_children(): before.append(child) player._spawn_fire_trail() var trail: Hitbox = null - for child in get_tree().current_scene.get_children(): + for child in effects.get_children(): if child is Hitbox and not before.has(child): trail = child break ok(trail != null, "trail spawned in the world") + if trail == null: + return + ok(trail.get_world_2d() == player.get_world_2d(), "trail collides in the player's world") ok(trail.get_parent() != player, "trail parent is not Player") ok(not player.get_children().has(trail), "trail is not a player child") var parked := trail.global_position diff --git a/tests/forge_heart_test.gd b/tests/forge_heart_test.gd index e089dda..c4f1c01 100644 --- a/tests/forge_heart_test.gd +++ b/tests/forge_heart_test.gd @@ -24,6 +24,7 @@ func test_heart_locked_until_boss_dead() -> void: ok(not heart.can_interact(heart), "flag alone does not unlock a locked heart") ok(not heart.monitoring, "flag does not turn monitoring back on") heart.unlock() + await flush(1) ok(heart.can_interact(heart), "unlock → can E") ok(heart.monitoring) @@ -66,5 +67,6 @@ func test_locked_heart_is_not_sensor_focus() -> void: ok(not heart.can_interact(player)) ok(player.sensor.get_focus() != heart, "locked heart is not an E target") heart.unlock() + await flush(1) player.sensor._on_area_entered(heart) eq(player.sensor.get_focus(), heart, "unlocked heart is focusable") diff --git a/tests/level01_layout_test.gd b/tests/level01_layout_test.gd index d8edf8f..c797fab 100644 --- a/tests/level01_layout_test.gd +++ b/tests/level01_layout_test.gd @@ -20,6 +20,45 @@ func teardown() -> void: get_tree().paused = false +func test_teach_terrace_is_a_single_jump() -> void: + almost(Level01Static.GROUND_TOP - Level01Static.TEACH_TERRACE_POS.y, Level01Static.STEP_RISE, 0.01) + ok(Level01Static.STEP_RISE < Level01Static.JUMP_REACH, "32px step stays under the 37px jump") + var platforms := Node2D.new() + platforms.name = "Platforms" + add_child(platforms) + var level := Level01Static.new() + level.place_teach_layout(platforms) + var terrace := platforms.get_node_or_null("TeachTerrace") as SolidPlatform + ok(terrace != null, "teach terrace is placed") + if terrace != null: + eq(terrace.position, Level01Static.TEACH_TERRACE_POS) + eq(terrace.size, Level01Static.TEACH_TERRACE_SIZE) + level.free() + + +func test_pit_floor_is_within_jump_reach() -> void: + ok(Level01EastWing.PIT_FLOOR_Y - Level01EastWing.PIT_BANK_Y <= Level01Static.JUMP_REACH) + almost(Level01EastWing.PIT_WATER_Y, Level01EastWing.PIT_CLIMB_LEFT_POS.y, 0.01) + eq(Level01EastWing.PIT_WATER_SIZE, Vector2(112, 32)) + var host := _make_host() + var pit: SolidPlatform = preload("res://scenes/world/Platform.tscn").instantiate() + pit.name = "ToxinPit" + pit.skin = "ground" + pit.position = Vector2(400, 368) + pit.size = Vector2(112, 32) + host.get_node("Platforms").add_child(pit) + var wing := Level01EastWing.new() + add_child(wing) + wing.build(host) + var tuned := host.get_node_or_null("Platforms/ToxinPit") as SolidPlatform + ok(tuned != null) + if tuned != null: + almost(tuned.position.y, Level01EastWing.PIT_FLOOR_Y, 0.01) + eq(tuned.size, Level01EastWing.PIT_FLOOR_SIZE) + ok(host.get_node_or_null("Platforms/PitClimbLeft") != null) + ok(host.get_node_or_null("Platforms/PitClimbRight") != null) + + func test_east_constants_stay_aligned() -> void: eq(Level01Static.EAST_LIMIT, Level01EastWing.EAST_LIMIT) almost(Level01Static.EAST_FLOOR_X, Level01EastWing.EAST_FLOOR_X, 0.01) @@ -122,16 +161,74 @@ func test_boss_slain_unlocks_heart_and_plays() -> void: func test_parallax_adds_mood_tint() -> void: var host := Node2D.new() add_child(host) - var backdrop := ParallaxBackground.new() + var backdrop := CanvasLayer.new() backdrop.name = "ParallaxBackdrop" host.add_child(backdrop) - var far := ParallaxLayer.new() + var far := Parallax2D.new() far.name = "Far" + far.follow_viewport = false backdrop.add_child(far) var extras := Level01Parallax.new() add_child(extras) extras.build(host) ok(host.get_node_or_null("MoodTint") != null, "mood tint lands on the host") + var sil := backdrop.get_node_or_null("NearSilhouette") + ok(sil != null, "silhouette strip is planted") + if sil != null: + for child in sil.get_children(): + var path := "" + if child is Sprite2D and (child as Sprite2D).texture != null: + path = (child as Sprite2D).texture.resource_path + elif child.get_child_count() > 0: + var spr := child.get_child(0) + if spr is Sprite2D and (spr as Sprite2D).texture != null: + path = (spr as Sprite2D).texture.resource_path + if path != "": + ok(Level01Env.is_foliage(path), "silhouette stays foliage, not stone") + + +func test_mountain_tile_is_narrower_than_the_640_view() -> void: + var tile := 192.0 * 1.7 + almost(tile, 326.4, 0.01) + ok(tile * 2.0 < float(PresentationMetrics.WORLD_SIZE.x) + Level01Parallax.COVER_PAD, + "one Godot mirror of 326px cannot cover 640 plus look-ahead") + ok(Level01Parallax.copies_for_view(tile) >= Level01Parallax.MIN_REPEAT_TIMES) + ok(float(Level01Parallax.copies_for_view(tile)) * floorf(tile) + >= float(PresentationMetrics.WORLD_SIZE.x) + Level01Parallax.COVER_PAD) + + +func test_cover_layer_repeats_mountains_across_the_view() -> void: + var layer := Parallax2D.new() + add_child(layer) + var spr := Sprite2D.new() + spr.name = "Sprite" + spr.centered = false + spr.texture = load("res://assets/env/parallax_mountains.png") as Texture2D + spr.scale = Vector2(1.7, 1.7) + layer.add_child(spr) + var extra := spr.duplicate() as Sprite2D + extra.name = "Sprite_1" + extra.position.x = 326.4 + layer.add_child(extra) + layer.repeat_size = Vector2(326.4, 0) + layer.repeat_times = 1 + Level01Parallax.cover_layer(layer) + var n := 0 + for child in layer.get_children(): + if child is Sprite2D: + n += 1 + eq(n, 1, "Parallax2D keeps one plate and repeats it") + ok(not layer.follow_viewport, "plates live on a screen-space CanvasLayer") + eq(layer.repeat_size.x, 326.0) + ok(layer.repeat_times >= Level01Parallax.MIN_REPEAT_TIMES) + ok(layer.repeat_size.x * float(layer.repeat_times) + >= float(PresentationMetrics.WORLD_SIZE.x) + Level01Parallax.COVER_PAD) + Level01Parallax.cover_layer(layer) + var n2 := 0 + for child in layer.get_children(): + if child is Sprite2D: + n2 += 1 + eq(n2, 1, "covering twice does not keep cloning") func _bind_beats() -> Level01StoryBeats: diff --git a/tests/presentation_metrics_test.gd b/tests/presentation_metrics_test.gd new file mode 100644 index 0000000..5821103 --- /dev/null +++ b/tests/presentation_metrics_test.gd @@ -0,0 +1,75 @@ +extends TestCase + + +func test_standard_desktops_fill_with_world_texels() -> void: + for item in [Vector3i(1280, 720, 2), Vector3i(1920, 1080, 3), Vector3i(2560, 1440, 4), Vector3i(3840, 2160, 6)]: + var metrics := PresentationMetrics.calculate(Vector2i(item.x, item.y)) + eq(metrics["scale"], item.z) + eq(metrics["physical_rect"], Rect2(0, 0, item.x, item.y)) + + +func test_unusual_windows_have_integer_centered_content() -> void: + var laptop := PresentationMetrics.calculate(Vector2i(1366, 768)) + eq(laptop["physical_rect"], Rect2(43, 24, 1280, 720)) + var deck := PresentationMetrics.calculate(Vector2i(1280, 800)) + eq(deck["physical_rect"], Rect2(0, 40, 1280, 720)) + var ultra := PresentationMetrics.calculate(Vector2i(3440, 1440)) + eq(ultra["physical_rect"], Rect2(440, 0, 2560, 1440)) + var small := PresentationMetrics.calculate(Vector2i(640, 360)) + eq(small["scale"], 1) + + +func test_ui_and_world_share_the_same_final_physical_rect() -> void: + for size in [Vector2i(1366, 768), Vector2i(1920, 1080), Vector2i(1280, 800), Vector2i(3440, 1440), Vector2i(3841, 2161)]: + var metrics := PresentationMetrics.calculate(size) + var ui: Transform2D = metrics["ui_transform"] + var canvas_rect: Rect2 = metrics["canvas_rect"] + ok(ui.origin.is_equal_approx(canvas_rect.position), "UI starts at the world image") + ok((ui * Vector2(PresentationMetrics.UI_SIZE)).is_equal_approx(canvas_rect.end), "UI covers the exact world image") + var physical_scale := Vector2(size) / Vector2(PresentationMetrics.UI_SIZE) + var physical: Rect2 = metrics["physical_rect"] + ok((canvas_rect.position * physical_scale).is_equal_approx(physical.position)) + ok((canvas_rect.size * physical_scale).is_equal_approx(physical.size)) + + +func test_scene_routing_keeps_the_authored_save_identifier() -> void: + eq(GameContext.route_scene(GameContext.WORLD_PATH), "res://scenes/ui/GamePresentation.tscn") + eq(GameContext.route_scene("res://scenes/ui/TitleScreen.tscn"), "res://scenes/ui/TitleScreen.tscn") + eq(GameContext.route_scene("res://scenes/levels/Level01_Static.tscn"), + "res://scenes/ui/GamePresentation.tscn") + eq(GameCamera.ZOOM, 1.0) + + +func test_weather_particles_use_the_host_viewport() -> void: + eq(PresentationMetrics.WORLD_SIZE, Vector2i(640, 360)) + var vp := SubViewport.new() + vp.size = PresentationMetrics.WORLD_SIZE + add_child(vp) + var fx := WeatherFx.new() + vp.add_child(fx) + eq(fx._bounds(), Vector2(PresentationMetrics.WORLD_SIZE), + "rain wraps inside the 640×360 world, not the 1280 UI window") + + +func test_level_registers_as_the_game_world() -> void: + var presentation := Node.new() + presentation.name = "GamePresentation" + add_child(presentation) + var viewport := Node.new() + viewport.name = "WorldViewport" + presentation.add_child(viewport) + var level := Level01Static.new() + level.name = "Level01_Static" + level.run_slice = false + viewport.add_child(level) + ok(level.is_in_group("game_world"), "Level01 joins game_world before children ready") + eq(GameContext.world_root(level), level) + eq(GameContext.world_scene_path(level), GameContext.WORLD_PATH) + var props := Node2D.new() + props.name = "Props" + level.add_child(props) + var nest := Node2D.new() + nest.name = "EmberNest" + props.add_child(nest) + eq(SaveData.persist_path(nest), "Props/EmberNest", + "save keys stay scene-relative, not WorldViewport-prefixed") diff --git a/tests/presentation_metrics_test.gd.uid b/tests/presentation_metrics_test.gd.uid new file mode 100644 index 0000000..e50348d --- /dev/null +++ b/tests/presentation_metrics_test.gd.uid @@ -0,0 +1 @@ +uid://cmxsewubbv5q0 diff --git a/tests/weather_fx_test.gd b/tests/weather_fx_test.gd new file mode 100644 index 0000000..1d94d62 --- /dev/null +++ b/tests/weather_fx_test.gd @@ -0,0 +1,183 @@ +extends TestCase +## Rain streaks collide in world space: ground, toxin water, Ember Nest. + + +func setup() -> void: + WorldClock.reset() + WorldClock.menu_hold = false + Director.abort() + Director.choice_hold = false + + +func teardown() -> void: + WorldClock.reset() + WorldClock.menu_hold = false + Director.abort() + Director.choice_hold = false + + +func test_canvas_to_world_is_identity_without_camera() -> void: + var fx := WeatherFx.new() + add_child(fx) + await flush(1) + eq(fx.canvas_to_world(Vector2(40, 80)), Vector2(40, 80)) + eq(fx.world_to_canvas(Vector2(40, 80)), Vector2(40, 80)) + + +func test_drop_hits_ground_platform() -> void: + var arena := Node2D.new() + add_child(arena) + var plat := SolidPlatform.new() + plat.size = Vector2(80, 16) + plat.position = Vector2(100, 200) + arena.add_child(plat) + var fx := WeatherFx.new() + arena.add_child(fx) + await flush(2) + var hit: Dictionary = fx.probe_segment(Vector2(140, 188), Vector2(140, 210)) + eq(hit["kind"], &"ground", "rain stops on the grass, not the viewport floor") + ok((hit["point"] as Vector2).y <= 201.0, "contact is the platform top") + eq(fx.probe_segment(Vector2(140, 40), Vector2(140, 52))["kind"], &"", "open air is a miss") + + +func test_drop_hits_pool_water_before_pit_floor() -> void: + var arena := Node2D.new() + add_child(arena) + var floor := SolidPlatform.new() + floor.size = Vector2(112, 48) + floor.position = Vector2(400, 352) + arena.add_child(floor) + var pool := ToxinPool.new() + pool.position = Vector2(400, 336) + arena.add_child(pool) + pool.configure(Vector2(112, 32)) + var fx := WeatherFx.new() + arena.add_child(fx) + await flush(2) + ok(pool.is_in_group("toxin_pools")) + var surface: Dictionary = fx.probe_segment(Vector2(450, 328), Vector2(450, 342)) + eq(surface["kind"], &"toxin", "rain kisses the scum, not the pit floor") + almost((surface["point"] as Vector2).y, 337.0, 1.5, "splash sits on the waterline") + eq(pool.splash_point(450.0), surface["point"]) + var submerged: Dictionary = fx.probe_segment(Vector2(450, 350), Vector2(450, 358)) + eq(submerged["kind"], &"toxin", "a drop already in the volume still counts as water") + + +func test_drop_hits_pit_lip_as_ground() -> void: + var arena := Node2D.new() + add_child(arena) + var lip := SolidPlatform.new() + lip.size = Vector2(32, 16) + lip.position = Vector2(400, 336) + arena.add_child(lip) + var floor := SolidPlatform.new() + floor.size = Vector2(112, 48) + floor.position = Vector2(400, 352) + arena.add_child(floor) + var pool := ToxinPool.new() + pool.position = Vector2(400, 336) + arena.add_child(pool) + pool.configure(Vector2(112, 32)) + var fx := WeatherFx.new() + arena.add_child(fx) + await flush(2) + var lip_hit: Dictionary = fx.probe_segment(Vector2(416, 328), Vector2(416, 342)) + eq(lip_hit["kind"], &"ground", "rain on the pit step is turf, not a green kiss") + var water: Dictionary = fx.probe_segment(Vector2(456, 328), Vector2(456, 342)) + eq(water["kind"], &"toxin", "open water between the lips still kisses") + + +func test_pool_volume_reads_and_scum_snaps() -> void: + var pool := ToxinPool.new() + add_child(pool) + pool.configure(Vector2(112, 32)) + await flush(1) + eq(pool.z_index, -1, "water sits under pit lips") + var volume := pool.get_node_or_null("Volume") as ColorRect + ok(volume != null, "pool has a liquid body, not only a 5px film") + if volume != null: + eq(volume.size, Vector2(112, 32)) + ok(volume.color.g > volume.color.r, "volume stays teal") + ok(not pool._scum_sprites.is_empty(), "scum film still tiles the surface") + pool._process(0.35) + for spr in pool._scum_sprites: + eq(spr.position.y, roundf(spr.position.y), "scum stays on the pixel grid") + ok(spr.modulate.g > 1.0, "scum is pre-boosted against MoodTint") + + +func test_drop_hits_ember_nest_bowl() -> void: + var arena := Node2D.new() + add_child(arena) + var plat := SolidPlatform.new() + plat.size = Vector2(200, 32) + plat.position = Vector2(80, 320) + arena.add_child(plat) + var nest := EmberNest.new() + nest.position = Vector2(150, 306) + arena.add_child(nest) + var fx := WeatherFx.new() + arena.add_child(fx) + await flush(2) + ok(nest.is_in_group("ember_nests")) + var hit: Dictionary = fx.probe_segment(Vector2(150, 278), Vector2(150, 298)) + eq(hit["kind"], &"nest", "rain hits the iron bowl before the turf") + nest.apply_persistent_state({"lit": true}) + var lit: Dictionary = fx.probe_segment(Vector2(150, 278), Vector2(150, 298)) + eq(lit["kind"], &"nest_lit", "lit nest steams instead of a cold iron kiss") + + +func test_resolve_fall_recycles_to_the_sky() -> void: + var arena := Node2D.new() + add_child(arena) + var plat := SolidPlatform.new() + plat.size = Vector2(80, 16) + plat.position = Vector2(0, 100) + arena.add_child(plat) + var fx := WeatherFx.new() + arena.add_child(fx) + await flush(2) + var resolved: Dictionary = fx.resolve_fall(Vector2(40, 90), Vector2(40, 120), Vector2(640, 360)) + eq(resolved["kind"], &"ground") + ok(resolved["recycled"]) + ok((resolved["world"] as Vector2).y < 0.0, "streak restarts above the view") + var wrap: Dictionary = fx.resolve_fall(Vector2(20, 350), Vector2(20, 372), Vector2(640, 360)) + ok(wrap["recycled"], "empty sky still wraps at the viewport") + eq(wrap["kind"], &"") + + +func test_canvas_to_world_follows_the_level_camera() -> void: + var vp := SubViewport.new() + vp.size = PresentationMetrics.WORLD_SIZE + vp.world_2d = World2D.new() + vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(vp) + var world := Node2D.new() + vp.add_child(world) + var cam := Camera2D.new() + cam.position = Vector2(320, 200) + world.add_child(cam) + cam.make_current() + var plat := SolidPlatform.new() + plat.size = Vector2(640, 32) + plat.position = Vector2(0, 280) + world.add_child(plat) + var fx := WeatherFx.new() + world.add_child(fx) + await flush(3) + var mid := Vector2(PresentationMetrics.WORLD_SIZE) * 0.5 + var mapped := fx.canvas_to_world(mid) + ok(absf(mapped.x - cam.get_screen_center_position().x) < 2.0, "view center maps to camera") + ok(absf(mapped.y - cam.get_screen_center_position().y) < 2.0) + # Grass at world y=280 sits near canvas y=260 when the camera is at y=200. + var from_w := fx.canvas_to_world(Vector2(320, 248)) + var to_w := fx.canvas_to_world(Vector2(320, 268)) + var hit: Dictionary = fx.probe_segment(from_w, to_w) + eq(hit["kind"], &"ground", "mapped rain still finds the platform under the camera") + var world_pt := Vector2(400, 280) + var back: Vector2 = fx.canvas_to_world(fx.world_to_canvas(world_pt)) + almost(back.x, world_pt.x, 0.51, "world→canvas→world roundtrips") + almost(back.y, world_pt.y, 0.51) + var view := Rect2(cam.get_screen_center_position() - Vector2(PresentationMetrics.WORLD_SIZE) * 0.5, Vector2(PresentationMetrics.WORLD_SIZE)) + var resolved: Dictionary = fx.resolve_fall(Vector2(320, 268), Vector2(320, 292), view) + eq(resolved["kind"], &"ground", "a world-space drop stops on the turf") + ok((resolved["world"] as Vector2).y < view.position.y + 1.0, "recycle is above the lens, not inside the dirt") diff --git a/tests/world_clock_test.gd b/tests/world_clock_test.gd index 817c6bd..4a90865 100644 --- a/tests/world_clock_test.gd +++ b/tests/world_clock_test.gd @@ -151,11 +151,12 @@ func test_parallax_follows_clock_tint() -> void: WorldClock.set_weather(WorldClock.Weather.FOG, true) var host := Node2D.new() add_child(host) - var backdrop := ParallaxBackground.new() + var backdrop := CanvasLayer.new() backdrop.name = "ParallaxBackdrop" host.add_child(backdrop) - var far := ParallaxLayer.new() + var far := Parallax2D.new() far.name = "Far" + far.follow_viewport = false backdrop.add_child(far) var extras := Level01Parallax.new() add_child(extras) @@ -291,7 +292,7 @@ func test_hud_stays_readable_at_night() -> void: WorldClock.set_zone(WorldClock.Zone.OUTDOORS) var host := Node2D.new() add_child(host) - var backdrop := ParallaxBackground.new() + var backdrop := CanvasLayer.new() backdrop.name = "ParallaxBackdrop" host.add_child(backdrop) var extras := Level01Parallax.new() diff --git a/tests/world_light_test.gd b/tests/world_light_test.gd index d14fa24..db5ba3c 100644 --- a/tests/world_light_test.gd +++ b/tests/world_light_test.gd @@ -122,11 +122,12 @@ func test_parallax_plants_moon_fill() -> void: WorldClock.set_weather(WorldClock.Weather.HAZE, true) var host := Node2D.new() add_child(host) - var backdrop := ParallaxBackground.new() + var backdrop := CanvasLayer.new() backdrop.name = "ParallaxBackdrop" host.add_child(backdrop) - var far := ParallaxLayer.new() + var far := Parallax2D.new() far.name = "Far" + far.follow_viewport = false backdrop.add_child(far) var extras := Level01Parallax.new() add_child(extras) diff --git a/tools/gen_ember_nest.py b/tools/gen_ember_nest.py new file mode 100644 index 0000000..a689229 --- /dev/null +++ b/tools/gen_ember_nest.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Author the Ember Nest brazier at world 1x (NEAREST, no UI 2x). + +The checkpoint used to be rubble_c squeezed to 18×22 — a pebble next to the +80px knight. This is a rusted iron grave-brazier: flared bowl, riveted hoop, +splayed tripod, ash well with bone chips. Lit coals are a second sheet so +the basin can sit cold until the player lights it. + +Run: python3 tools/gen_ember_nest.py +""" +from __future__ import annotations + +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, HERE) + +from gen_ui_kit import Grid, outline # noqa: E402 + +OUT = os.path.join(ROOT, "assets", "env") +W, H = 48, 36 +# Bowl well in sprite space — keep in sync with EmberNest.BOWL via +# BASE_OFFSET + (WELL_CX, WELL_CY). +WELL_CX, WELL_CY = 22, 8 + + +def _ellipse(g: Grid, cx: float, cy: float, rx: float, ry: float, ch: str) -> None: + rx = max(rx, 0.5) + ry = max(ry, 0.5) + for y in range(g.h): + for x in range(g.w): + if ((x + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - cy) / ry) ** 2 <= 1.0: + g[x, y] = ch + + +def _rect(g: Grid, x0: int, y0: int, x1: int, y1: int, ch: str) -> None: + for y in range(y0, y1 + 1): + for x in range(x0, x1 + 1): + g[x, y] = ch + + +def _span(g: Grid, y: int, x0: int, x1: int, ch: str) -> None: + _rect(g, x0, y, x1, y, ch) + + +def _put(g: Grid, cells: list[tuple[int, int, str]]) -> None: + for x, y, ch in cells: + g[x, y] = ch + + +def _splay_leg(g: Grid, x_top: float, x_bot: float, y0: int, y1: int, + width: int = 4) -> None: + """Iron post that kicks outward toward the grass.""" + span = max(y1 - y0, 1) + for y in range(y0, y1 + 1): + t = (y - y0) / span + x = int(round(x_top + (x_bot - x_top) * t)) + for dx in range(width): + g[x + dx, y] = "1" + g[x, y] = "2" + g[x + width - 1, y] = "7" if y > y0 + 3 else "1" + if y > y1 - 4: + g[x, y] = "8" + g[x + 1, y] = "8" + g[x + width - 1, y] = "7" + foot_x = int(round(x_bot)) + _rect(g, foot_x - 1, y1, foot_x + width, y1, "0") + _rect(g, foot_x, y1 - 1, foot_x + width - 1, y1 - 1, "8") + g[foot_x, y1] = "8" + g[foot_x + width - 1, y1] = "7" + g[foot_x - 1, y1 - 1] = "7" + g[foot_x + width, y1 - 1] = "1" + + +def _legs(g: Grid) -> None: + _splay_leg(g, 10.0, 5.0, 17, 34, 4) + _splay_leg(g, 31.0, 36.0, 17, 34, 4) + _splay_leg(g, 20.0, 21.0, 16, 28, 4) + # Upper stretcher under the bowl. + _span(g, 17, 12, 34, "1") + _span(g, 18, 12, 34, "1") + _span(g, 17, 13, 33, "2") + _span(g, 18, 13, 33, "8") + _span(g, 19, 14, 32, "7") + # Lower cross-brace so the tripod reads as a stand. + _span(g, 24, 16, 30, "1") + _span(g, 25, 16, 30, "1") + _span(g, 24, 17, 29, "2") + g[17, 25] = "8" + g[29, 25] = "8" + + +def _bowl(g: Grid) -> None: + """Side-on flared basin, drawn as scanlines so the lip and well read.""" + # Rim is the widest slice; the body tapers so it is a vessel, not a can. + shell = [ + (1, 14, 31), + (2, 10, 35), + (3, 8, 37), + (4, 7, 38), + (5, 6, 39), + (6, 6, 39), + (7, 7, 38), + (8, 7, 38), + (9, 8, 37), + (10, 8, 37), + (11, 9, 36), + (12, 10, 35), + (13, 11, 34), + (14, 12, 33), + (15, 13, 32), + (16, 15, 30), + ] + for y, x0, x1 in shell: + _span(g, y, x0, x1, "8") + g[x0, y] = "9" + g[x0 + 1, y] = "9" + g[x1, y] = "7" + g[x1 - 1, y] = "7" + if 4 <= y <= 13: + _span(g, y, x0 + 2, x1 - 2, "9") + # Inner well — cold ash, inset so the rim is a 2px lip. + well = [ + (3, 16, 29, "1"), + (4, 14, 31, "7"), + (5, 13, 32, "v"), + (6, 13, 32, "v"), + (7, 13, 32, "v"), + (8, 14, 31, "v"), + (9, 14, 31, "v"), + (10, 15, 30, "7"), + (11, 17, 28, "7"), + ] + for y, x0, x1, ch in well: + _span(g, y, x0, x1, ch) + g[x0, y] = "1" + g[x1, y] = "1" + # Sparse ash, not a regular grate. + _put(g, [ + (16, 6, "7"), (19, 7, "7"), (24, 6, "7"), (27, 8, "7"), + (18, 8, "7"), (29, 7, "7"), (15, 8, "7"), (22, 9, "1"), + (26, 5, "7"), (20, 5, "7"), + ]) + # Far rim highlight (iron, not pink rust-light) and near-lip shade. + _span(g, 1, 16, 29, "3") + _span(g, 2, 12, 33, "3") + _span(g, 2, 16, 29, "2") + _span(g, 3, 9, 14, "3") + _span(g, 3, 31, 36, "2") + g[7, 5] = "3" + g[8, 4] = "3" + # Rivets on the rim. + for x, y in ((12, 2), (18, 1), (22, 1), (27, 1), (33, 2)): + g[x, y] = "3" + g[x, y + 1] = "1" + # Rust grit on the front wall. + for x, y in ( + (12, 12), (14, 13), (31, 12), (29, 14), (13, 14), + (32, 11), (11, 11), (30, 15), (15, 15), (27, 16), + (10, 10), (33, 10), + ): + if g[x, y] in ("8", "9"): + g[x, y] = "7" if g[x, y] == "8" else "8" + # Bone chips + a small jaw in the ash (cemetery nest, not a kitchen stove). + _put(g, [ + (16, 7, "e"), (17, 7, "e"), (16, 8, "7"), + (27, 8, "e"), (28, 8, "7"), + (21, 6, "e"), (22, 6, "e"), (23, 6, "e"), + (21, 7, "e"), (23, 7, "e"), + (22, 8, "7"), + (19, 9, "7"), (25, 9, "v"), + ]) + + +def _hoop(g: Grid) -> None: + """Worn iron band around the belly — reads as a vessel, not a blob.""" + _span(g, 13, 12, 33, "1") + _span(g, 14, 11, 34, "1") + _span(g, 15, 13, 32, "1") + _span(g, 13, 13, 32, "2") + _span(g, 14, 13, 32, "8") + _span(g, 15, 14, 31, "7") + for x in (14, 22, 31): + g[x, 13] = "3" + g[x, 14] = "1" + g[22, 14] = "9" + + +def _chain_and_charm(g: Grid) -> None: + """Rusty chain off the right rim with a hanging bone — welded to the lip.""" + _put(g, [ + (39, 5, "2"), (39, 6, "1"), + (40, 7, "2"), (40, 8, "1"), + (39, 9, "2"), (39, 10, "1"), + (40, 11, "8"), (40, 12, "1"), + (39, 13, "2"), (39, 14, "1"), + (40, 15, "8"), (40, 16, "1"), + (39, 17, "e"), (40, 17, "e"), (41, 17, "e"), + (40, 18, "e"), + (39, 19, "e"), (41, 19, "e"), + (40, 20, "7"), + ]) + + +def build_nest() -> Grid: + g = Grid(W, H, ".") + _legs(g) + _bowl(g) + _hoop(g) + _chain_and_charm(g) + outline(g, "0") + return g + + +def build_coals() -> Grid: + g = Grid(W, H, ".") + cx, cy = float(WELL_CX), float(WELL_CY) - 1.2 + _ellipse(g, cx, cy, 11.0, 4.0, "9") + _ellipse(g, cx, cy - 0.3, 8.4, 2.8, "b") + _ellipse(g, cx, cy - 0.7, 5.2, 1.6, "c") + for x, y, ch in ( + (14, 10, "c"), (30, 11, "c"), (18, 12, "b"), + (26, 9, "c"), (22, 11, "d"), (20, 10, "c"), + ): + g[x, y] = ch + return g + + +def main() -> None: + os.makedirs(OUT, exist_ok=True) + build_nest().to_image(1).save(os.path.join(OUT, "ember_nest.png")) + build_coals().to_image(1).save(os.path.join(OUT, "ember_nest_coals.png")) + print(f" ember_nest.png {W}x{H}") + print(f" ember_nest_coals.png {W}x{H}") + + +if __name__ == "__main__": + main()