diff --git a/content/stages/starters/living_room.scene b/content/stages/starters/living_room.scene index 2097f96..9575299 100644 --- a/content/stages/starters/living_room.scene +++ b/content/stages/starters/living_room.scene @@ -1,29 +1,49 @@ { - "version": "1.5", + "version": "1.6", "scene_name": "Living Room", "sky_id": "sky_clear_day", - "camera": {"position": {"x": 0.0, "y": -420.0}, "zoom": 0.5}, + "camera": {"position": {"x": 0.0, "y": -220.0}, "zoom": 0.5}, "objects": [ - {"file_id":"wall_top","type":"wall_panel","position":{"x":0.0,"y":-820.0},"rotation":0.0,"size":{"x":1120.0,"y":170.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, - {"file_id":"wall_left","type":"wall_panel","position":{"x":-360.0,"y":-535.0},"rotation":0.0,"size":{"x":440.0,"y":400.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, - {"file_id":"wall_right","type":"wall_panel","position":{"x":360.0,"y":-535.0},"rotation":0.0,"size":{"x":440.0,"y":400.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, + {"file_id":"wall_top","type":"wall_panel","position":{"x":0.0,"y":-941.0},"rotation":0.0,"size":{"x":4760.0,"y":518.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, + {"file_id":"wall_left","type":"wall_panel","position":{"x":-1262.5,"y":-256.0},"rotation":0.0,"size":{"x":2235.0,"y":852.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, + {"file_id":"wall_right","type":"wall_panel","position":{"x":1262.5,"y":-256.0},"rotation":0.0,"size":{"x":2235.0,"y":852.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, + {"file_id":"wall_under_window","type":"wall_panel","position":{"x":0.0,"y":-116.0},"rotation":0.0,"size":{"x":290.0,"y":572.0},"finish_id":"finish_wall_paint","color":"#5A9D70"}, + {"file_id":"wall_baseboard","type":"wall_panel","position":{"x":0.0,"y":177.0},"rotation":0.0,"size":{"x":4760.0,"y":14.0},"finish_id":"finish_wall_paint","color":"#E4C496"}, {"file_id":"window_frame","type":"scenery_artwork","asset_id":"res://content/props/art_window_frame-v1.prop","display_name":"Window frame","depth":"near","position":{"x":0.0,"y":-530.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}}, - {"file_id":"wall_clock","type":"scenery_artwork","asset_id":"res://content/props/art_wall_clock-v1.prop","display_name":"Wall clock","depth":"near","position":{"x":-430.0,"y":-570.0},"rotation":0.0,"scale":{"x":0.8,"y":0.8}}, - {"file_id":"fireplace","type":"scenery_artwork","asset_id":"res://content/props/art_fireplace_mantel-v2.prop","display_name":"Fireplace","depth":"near","position":{"x":365.0,"y":-460.0},"rotation":0.0,"scale":{"x":0.82,"y":0.82}}, - {"file_id":"bookshelf","type":"scenery_artwork","asset_id":"res://content/props/art_bookshelf-v1.prop","display_name":"Bookshelf","depth":"near","position":{"x":-475.0,"y":-310.0},"rotation":0.0,"scale":{"x":0.82,"y":0.82}}, - {"file_id":"picture","type":"scenery_artwork","asset_id":"res://content/props/art_framed_picture-v1.prop","display_name":"Framed picture","depth":"near","position":{"x":185.0,"y":-575.0},"rotation":0.0,"scale":{"x":0.75,"y":0.75}}, - {"file_id":"ground_wood_1","type":"terrain","asset_id":"ground","position":{"x":-600.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, - {"file_id":"ground_wood_2","type":"terrain","asset_id":"ground","position":{"x":-400.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, - {"file_id":"ground_wood_3","type":"terrain","asset_id":"ground","position":{"x":-200.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, - {"file_id":"ground_wood_4","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, - {"file_id":"ground_wood_5","type":"terrain","position":{"x":200.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood","asset_id":"ground"}, - {"file_id":"ground_wood_6","type":"terrain","asset_id":"ground","position":{"x":400.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, - {"file_id":"ground_wood_7","type":"terrain","asset_id":"ground","position":{"x":600.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, - {"file_id":"rug","type":"scenery_artwork","asset_id":"res://content/props/art_rug-v1.prop","display_name":"Floor rug","depth":"floor","position":{"x":0.0,"y":112.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}}, - {"file_id":"plant","type":"scenery_artwork","asset_id":"res://content/props/art_potted_plant-v1.prop","display_name":"Potted plant","depth":"floor","position":{"x":500.0,"y":84.0},"rotation":0.0,"scale":{"x":0.8,"y":0.8}}, - {"file_id":"couch","type":"prop","asset_id":"res://content/props/prop_couch-v1.prop","position":{"x":-190.0,"y":48.0},"rotation":0.0,"scale":{"x":0.82,"y":0.82},"stage_object_id":"starter_couch","roles":{"depth":"behind","start_motion":"still","holds_other_props":false,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"}, - {"file_id":"armchair","type":"prop","asset_id":"res://content/props/prop_armchair-v1.prop","position":{"x":210.0,"y":52.0},"rotation":0.0,"scale":{"x":0.78,"y":0.78},"stage_object_id":"starter_armchair","roles":{"depth":"behind","start_motion":"still","holds_other_props":false,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"}, - {"file_id":"coffee_table","type":"prop","asset_id":"res://content/props/prop_coffee_table-v1.prop","position":{"x":15.0,"y":116.0},"rotation":0.0,"scale":{"x":0.75,"y":0.75},"stage_object_id":"starter_coffee_table","roles":{"depth":"front","start_motion":"still","holds_other_props":true,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"} + {"file_id":"wall_clock","type":"scenery_artwork","asset_id":"res://content/props/art_wall_clock-v1.prop","display_name":"Wall clock","depth":"near","position":{"x":-600.0,"y":-540.0},"rotation":0.0,"scale":{"x":0.8,"y":0.8}}, + {"file_id":"fireplace","type":"scenery_artwork","asset_id":"res://content/props/art_fireplace_mantel-v2.prop","display_name":"Fireplace","depth":"near","position":{"x":650.0,"y":55.7},"rotation":0.0,"scale":{"x":0.42,"y":0.42}}, + {"file_id":"bookshelf","type":"scenery_artwork","asset_id":"res://content/props/art_bookshelf-v1.prop","display_name":"Bookshelf","depth":"near","position":{"x":-950.0,"y":102.75},"rotation":0.0,"scale":{"x":0.65,"y":0.65}}, + {"file_id":"picture","type":"scenery_artwork","asset_id":"res://content/props/art_framed_picture-v1.prop","display_name":"Framed picture","depth":"near","position":{"x":1000.0,"y":-670.0},"rotation":0.0,"scale":{"x":0.6,"y":0.6}}, + {"file_id":"ground_wood_left_12","type":"terrain","asset_id":"ground","position":{"x":-2304.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_11","type":"terrain","asset_id":"ground","position":{"x":-2112.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_10","type":"terrain","asset_id":"ground","position":{"x":-1920.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_09","type":"terrain","asset_id":"ground","position":{"x":-1728.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_08","type":"terrain","asset_id":"ground","position":{"x":-1536.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_07","type":"terrain","asset_id":"ground","position":{"x":-1344.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_06","type":"terrain","asset_id":"ground","position":{"x":-1152.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_05","type":"terrain","asset_id":"ground","position":{"x":-960.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_04","type":"terrain","asset_id":"ground","position":{"x":-768.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_03","type":"terrain","asset_id":"ground","position":{"x":-576.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_02","type":"terrain","asset_id":"ground","position":{"x":-384.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_left_01","type":"terrain","asset_id":"ground","position":{"x":-192.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_01","type":"terrain","asset_id":"ground","position":{"x":192.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_02","type":"terrain","asset_id":"ground","position":{"x":384.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_03","type":"terrain","asset_id":"ground","position":{"x":576.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_04","type":"terrain","asset_id":"ground","position":{"x":768.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_05","type":"terrain","asset_id":"ground","position":{"x":960.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_06","type":"terrain","asset_id":"ground","position":{"x":1152.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_07","type":"terrain","asset_id":"ground","position":{"x":1344.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_08","type":"terrain","asset_id":"ground","position":{"x":1536.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_09","type":"terrain","asset_id":"ground","position":{"x":1728.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_10","type":"terrain","asset_id":"ground","position":{"x":1920.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_11","type":"terrain","asset_id":"ground","position":{"x":2112.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"ground_wood_right_12","type":"terrain","asset_id":"ground","position":{"x":2304.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"}, + {"file_id":"rug","type":"scenery_artwork","asset_id":"res://content/props/art_rug-v1.prop","display_name":"Floor rug","depth":"floor","position":{"x":0.0,"y":105.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}}, + {"file_id":"plant","type":"scenery_artwork","asset_id":"res://content/props/art_potted_plant-v1.prop","display_name":"Potted plant","depth":"floor","position":{"x":1000.0,"y":100.4},"rotation":0.0,"scale":{"x":0.8,"y":0.8}}, + {"file_id":"couch","type":"prop","asset_id":"res://content/props/prop_couch-v1.prop","position":{"x":-690.0,"y":123.0},"rotation":0.0,"scale":{"x":0.55,"y":0.55},"stage_object_id":"starter_couch","roles":{"depth":"behind","start_motion":"still","holds_other_props":false,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"}, + {"file_id":"armchair","type":"prop","asset_id":"res://content/props/prop_armchair-v1.prop","position":{"x":620.0,"y":114.0},"rotation":0.0,"scale":{"x":0.65,"y":0.65},"stage_object_id":"starter_armchair","roles":{"depth":"behind","start_motion":"still","holds_other_props":false,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"}, + {"file_id":"coffee_table","type":"prop","asset_id":"res://content/props/prop_coffee_table-v1.prop","position":{"x":-515.0,"y":132.0},"rotation":0.0,"scale":{"x":0.55,"y":0.55},"stage_object_id":"starter_coffee_table","roles":{"depth":"front","start_motion":"still","holds_other_props":true,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"} ], "event_rules": [], "metadata": {"created_unix": 1792022400, "modified_unix": 1792022400, "autosave": false, "source_path": ""} diff --git a/content/stages/starters/town_street.scene b/content/stages/starters/town_street.scene index 7dea243..380a6dd 100644 --- a/content/stages/starters/town_street.scene +++ b/content/stages/starters/town_street.scene @@ -1,16 +1,69 @@ { - "version": "1.5", + "version": "1.6", "scene_name": "Town Street", "sky_id": "sky_cloudy_day", "camera": {"position": {"x": 0.0, "y": -420.0}, "zoom": 0.5}, "objects": [ - {"file_id":"scenery_skyline","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":0.0,"y":-600.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}}, - {"file_id":"scenery_tree_left","type":"scenery_artwork","asset_id":"res://content/props/art_tree-v2.prop","display_name":"Tree","depth":"near","position":{"x":-520.0,"y":-415.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}}, - {"file_id":"scenery_town_house","type":"scenery_artwork","asset_id":"res://content/props/art_town_house-v1.prop","display_name":"Town house","depth":"near","position":{"x":-255.0,"y":-390.0},"rotation":0.0,"scale":{"x":0.88,"y":0.88}}, - {"file_id":"scenery_storefront","type":"scenery_artwork","asset_id":"res://content/props/art_storefront-v1.prop","display_name":"Storefront","depth":"near","position":{"x":130.0,"y":-390.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}}, - {"file_id":"scenery_tree_right","type":"scenery_artwork","asset_id":"res://content/props/art_tree-v2.prop","display_name":"Tree","depth":"near","position":{"x":500.0,"y":-405.0},"rotation":0.0,"scale":{"x":0.88,"y":0.88}}, - {"file_id":"ground_sidewalk","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":0.0},"rotation":0.0,"scale":{"x":7.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, - {"file_id":"ground_asphalt","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":32.0},"rotation":0.0,"scale":{"x":7.0,"y":1.0},"finish_id":"finish_ground_asphalt"} + {"file_id":"town_landscape_ground","type":"background_ground","position":{"x":0.0,"y":660.0},"size":{"x":4800.0,"y":2000.0},"finish_id":"finish_background_grass","color":"#72A84C"}, + {"file_id":"scenery_skyline_left","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":-1600.0,"y":-390.0},"rotation":0.0,"scale":{"x":2.5,"y":1.0}}, + {"file_id":"scenery_skyline","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":0.0,"y":-390.0},"rotation":0.0,"scale":{"x":2.5,"y":1.0}}, + {"file_id":"scenery_skyline_right","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":1600.0,"y":-390.0},"rotation":0.0,"scale":{"x":2.5,"y":1.0}}, + {"file_id":"scenery_tree_left","type":"scenery_artwork","asset_id":"res://content/props/art_tree-v2.prop","display_name":"Tree","depth":"near","position":{"x":-720.0,"y":-70.0},"rotation":0.0,"scale":{"x":0.45,"y":0.45}}, + {"file_id":"scenery_town_house","type":"scenery_artwork","asset_id":"res://content/props/art_town_house-v1.prop","display_name":"Town house","depth":"near","position":{"x":-560.0,"y":-120.0},"rotation":0.0,"scale":{"x":0.8,"y":0.8}}, + {"file_id":"scenery_storefront","type":"scenery_artwork","asset_id":"res://content/props/art_storefront-v1.prop","display_name":"Storefront","depth":"near","position":{"x":560.0,"y":-107.125},"rotation":0.0,"scale":{"x":0.75,"y":0.75}}, + {"file_id":"scenery_tree_right","type":"scenery_artwork","asset_id":"res://content/props/art_tree-v2.prop","display_name":"Tree","depth":"near","position":{"x":720.0,"y":-70.0},"rotation":0.0,"scale":{"x":0.45,"y":0.45}}, + {"file_id":"ground_sidewalk_left_12","type":"terrain","asset_id":"ground","position":{"x":-2304.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_11","type":"terrain","asset_id":"ground","position":{"x":-2112.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_10","type":"terrain","asset_id":"ground","position":{"x":-1920.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_09","type":"terrain","asset_id":"ground","position":{"x":-1728.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_08","type":"terrain","asset_id":"ground","position":{"x":-1536.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_07","type":"terrain","asset_id":"ground","position":{"x":-1344.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_06","type":"terrain","asset_id":"ground","position":{"x":-1152.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_05","type":"terrain","asset_id":"ground","position":{"x":-960.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_04","type":"terrain","asset_id":"ground","position":{"x":-768.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_03","type":"terrain","asset_id":"ground","position":{"x":-576.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_02","type":"terrain","asset_id":"ground","position":{"x":-384.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_left_01","type":"terrain","asset_id":"ground","position":{"x":-192.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_01","type":"terrain","asset_id":"ground","position":{"x":192.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_02","type":"terrain","asset_id":"ground","position":{"x":384.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_03","type":"terrain","asset_id":"ground","position":{"x":576.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_04","type":"terrain","asset_id":"ground","position":{"x":768.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_05","type":"terrain","asset_id":"ground","position":{"x":960.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_06","type":"terrain","asset_id":"ground","position":{"x":1152.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_07","type":"terrain","asset_id":"ground","position":{"x":1344.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_08","type":"terrain","asset_id":"ground","position":{"x":1536.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_09","type":"terrain","asset_id":"ground","position":{"x":1728.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_10","type":"terrain","asset_id":"ground","position":{"x":1920.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_11","type":"terrain","asset_id":"ground","position":{"x":2112.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_sidewalk_right_12","type":"terrain","asset_id":"ground","position":{"x":2304.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"}, + {"file_id":"ground_asphalt_left_12","type":"terrain","asset_id":"ground","position":{"x":-2304.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_11","type":"terrain","asset_id":"ground","position":{"x":-2112.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_10","type":"terrain","asset_id":"ground","position":{"x":-1920.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_09","type":"terrain","asset_id":"ground","position":{"x":-1728.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_08","type":"terrain","asset_id":"ground","position":{"x":-1536.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_07","type":"terrain","asset_id":"ground","position":{"x":-1344.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_06","type":"terrain","asset_id":"ground","position":{"x":-1152.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_05","type":"terrain","asset_id":"ground","position":{"x":-960.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_04","type":"terrain","asset_id":"ground","position":{"x":-768.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_03","type":"terrain","asset_id":"ground","position":{"x":-576.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_02","type":"terrain","asset_id":"ground","position":{"x":-384.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_01","type":"terrain","asset_id":"ground","position":{"x":-192.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_01","type":"terrain","asset_id":"ground","position":{"x":192.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_02","type":"terrain","asset_id":"ground","position":{"x":384.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_03","type":"terrain","asset_id":"ground","position":{"x":576.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_04","type":"terrain","asset_id":"ground","position":{"x":768.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_05","type":"terrain","asset_id":"ground","position":{"x":960.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_06","type":"terrain","asset_id":"ground","position":{"x":1152.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_07","type":"terrain","asset_id":"ground","position":{"x":1344.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_08","type":"terrain","asset_id":"ground","position":{"x":1536.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_09","type":"terrain","asset_id":"ground","position":{"x":1728.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_10","type":"terrain","asset_id":"ground","position":{"x":1920.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_11","type":"terrain","asset_id":"ground","position":{"x":2112.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_12","type":"terrain","asset_id":"ground","position":{"x":2304.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_left_extension","type":"terrain","asset_id":"ground","position":{"x":-2496.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}, + {"file_id":"ground_asphalt_right_extension","type":"terrain","asset_id":"ground","position":{"x":2496.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"} ], "event_rules": [], "metadata": {"created_unix": 1792022400, "modified_unix": 1792022400, "autosave": false, "source_path": ""} diff --git a/docs/architecture/tech_debt_and_optimizations.md b/docs/architecture/tech_debt_and_optimizations.md index bebac43..380709f 100644 --- a/docs/architecture/tech_debt_and_optimizations.md +++ b/docs/architecture/tech_debt_and_optimizations.md @@ -85,6 +85,8 @@ This document tracks known technical debt, optimization opportunities, and minor | 73 | **Whole-set transform requires cross-system invariants** — Phase 19's temporary group transform covers only inert Scenery pieces: wall panels, artwork, and the planned Background ground. | Medium | Future feature | Moving/scaling walkable Ground, characters, interactive Props, Sky, camera composition, waypoints, and rules together needs separate physics, navigation, event-target, and save contracts. Do not expand the Scenery group tool into a whole-set transform without that design. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md` and `docs/specs/phase_19_background_ground_and_wall_color.md`. (2026-10-17) | | 74 | **Background surface motifs need a shared zoom-consistent draw budget** — a pattern detail scale derived from each panel's size can shift seams or texture density where adjacent pieces meet, while uncapped large rectangles can issue excessive draw calls. | Medium | Planned | Phase 19 follow-up uses world-cell coordinates, visible-region culling, and any level-of-detail choice derived from camera zoom for all pieces in the same view. Keep Grass/Concrete/Carpet detail deterministic and bounded; consider a texture cache only if measured scenes remain expensive. See `docs/specs/phase_19_background_ground_and_wall_color.md`. (2026-10-17) | | 75 | **Old Brick/Wood wall colors were saved but not rendered** — v1.5 walls can carry the old default green while their brick and wood palettes were hard-coded. | Low | Planned migration | Scene v1.6 migration replaces only the old green placeholder on pre-v1.6 Brick/Wood entries with natural finish defaults; Paint and non-placeholder colors remain. The exact placeholder could have been intentionally authored in an older file, which cannot be distinguished retrospectively. New v1.6 colors are authoritative, including green Brick/Wood. See `docs/specs/phase_19_background_ground_and_wall_color.md`. (2026-10-17) | +| 76 | **Camera-aware Sky redraws vector motifs while framing changes** — the former static Sky drew motifs only when the preset or viewport changed. | Low | Planned | The Phase 19 Sky addition keeps one screen-space `StageSkyView`, redraws only when a bound camera's effective framing changes, and caches reusable cloud/moon curve samples where practical. Measure sustained pan/zoom cost in a rendered Stage before adding a raster or shader path. The fixed reference frame and bounded response can saturate at distant camera positions; that is an accepted composition trade-off, not scene state to persist. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md#phase-19-addition-camera-aware-sky-details`. (2026-10-21) | +| 77 | **Starter surfaces guarantee a finite pan envelope, not arbitrary camera framing** — the two bundled sets use a fixed authored width to keep their scene data simple. | Low | Implemented; visual review pending | The authored envelope is a 1,280-logical-pixel viewport at zoom 0.5, ±1,000 world units of travel, and 100 units of margin per side. User review prompted an opaque Town Grass Background ground and separate scale-1 tile rows. Twenty-five 192-unit tiles cover the 4,760-unit minimum; Asphalt has 27 tiles (5,184 units) so the road extends one tile beyond the sidewalk at both ends. Navigation merges contiguous tile tops and routes the 32-unit curb step at the exposed ends without merging Ground objects or colliders. The focused suite passes 515 assertions, including the curb step and PLAY landing. The camera remains unrestricted, so wider viewports, farther pans, and zoomed-out shots can reveal finite ends. Rendered seam review remains pending. See docs/specs/phase_19_starter_scene_composition.md. (2026-10-21) --- @@ -181,5 +183,9 @@ This document tracks known technical debt, optimization opportunities, and minor | 2026-10-20 | Approved the scenery bitmap tile cache architecture (`docs/specs/scenery_bitmap_tile_cache.md`). Lightweight `StageSurfaceCache` handles share immutable bitmap tiles through one `StageSurfaceBakePool` under the current scene, with a 64 MiB resident bitmap budget and one reusable offscreen baker. Camera-visible tiles use four fixed zoom levels (0.125, 0.5, 1, 2); the coarsest tile bakes first and refined detail overlays it. At maximum 6× zoom, the 2× bitmap may look softer because it is magnified threefold. Erased fragments clip shared tiles only when their complete pattern sampling phase matches. The previous per-surface viewport cache retained vector commands and had no scene-wide resource limit, a likely contributor to `Element limit reached`; the exact RID pool remains unconfirmed. GPU readback during bitmap capture and regeneration after budget eviction are accepted costs to measure during implementation. | | 2026-10-20 | Replaced the positional scenery tile design after the user observed continuous buffering when panning after zoom. The accepted revision in `docs/specs/scenery_bitmap_tile_cache.md` bakes one seamless, 2× bitmap per finish/tint period and uses GPU repeat plus generated mipmaps for all camera positions and zooms. Erased fragments share the style texture and crop it using their existing sample coordinate. The scene-wide 64 MiB limit includes mipmaps; inactive style variants may be evicted, while recently used styles stay warm when space allows. This trades unique random detail across an unlimited wall for finite repeated variation and keeps the existing possible 3× bitmap enlargement at maximum zoom. A style change can bake once; repeated camera movement must not increment build or readback counts after styles are ready. | +| 2026-10-21 | Drafted the camera-aware Sky architecture addendum in `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md` for user sign-off. Accepted a fixed built-in camera reference, one bounded response profile per preset, and normalized motif anchors with uniform scaling. The gradient remains a full-screen `CanvasLayer` draw; motifs derive deterministic bounded pan/zoom response from absolute `Camera2D` framing. Chooser and starter previews stay unbound and still; existing Sky/camera scene fields suffice. The trade-offs are response saturation far from the reference and more frequent vector redraws during camera movement; logged #76 for measured performance follow-up. No runtime code or tests changed. | +| 2026-10-21 | Wrote the Phase 19 starter-scene composition UI/UX and architecture specifications (`docs/specs/phase_19_starter_scene_composition_ui_ux.md`, `docs/specs/phase_19_starter_scene_composition.md`) after approval of the Everyday Storybook brief and the 1,280-pixel / ±1,000-world-unit pan defaults. The edited starter snapshots use current scene v1.6 and existing Ground, wall, Background ground, Scenery, and Prop types, and keep the camera unrestricted; the starter card derives a central opening preview without new scene metadata. No code or art changed. Added #77 for the finite authored-envelope and broad-selection trade-off; the architecture spec is approved and implementation/agent verification are underway. +| 2026-10-21 | Implemented the approved starter-scene composition spec. Town Street and Living Room now use continuous 4,760-unit support surfaces, aligned bases, and camera-derived starter-card framing; both edited scene files use current v1.6. The focused production-load/save-reopen/PLAY composition test passed 177 assertions, with measured visible-contact geometry within 0.225 logical pixels. The broader scenery suite exits 1 on nine other background-card and wall-erasure/capture-bar assertions, while the new surface assertions pass. GUI capture exited 134 and headless capture produced no image, so rendered-pixel checks, pan-endpoint seam inspection, narrow viewport/zoom-in framing, and user/editor verification remain pending. Updated #77 from planned to implemented with the finite-camera limitation accepted. See `docs/specs/phase_19_starter_scene_composition.md`. +| 2026-10-21 | Revised the starters after the user's Town Street screenshot exposed Sky clouds below the skyline and the user rejected oversized scaled Ground blocks. Added an opaque Grass Background ground, replaced the sidewalk and both starter floors with scale-1 tile rows, and extended Asphalt by one tile at each end so the road is accessible. The focused production-path test passed 515 assertions, including navigation across tile seams, sidewalk-to-road routing, and a PLAY actor landing grounded on Asphalt. The broader scenery suite still exits 1 on the same nine Sky-preview/gallery and Scenery-edit UI assertions observed before this revision; its new surface assertions pass. A bounded GUI capture exited 134 without an image, so rendered pixels, pan endpoints, card previews, narrow viewport/zoom-in states, and user/editor review remain pending. See `docs/specs/phase_19_starter_scene_composition.md`. _End of Log_ diff --git a/docs/ideas/feature_backlog.md b/docs/ideas/feature_backlog.md index c343438..66a26bc 100644 --- a/docs/ideas/feature_backlog.md +++ b/docs/ideas/feature_backlog.md @@ -13,7 +13,7 @@ Priorities are recommendations for discussion, not a committed release schedule. 4. **Functional doorways and doors** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Let placed door artwork serve as scenery, an open passage, or a closed wall door that blocks characters until opened; an open passage may optionally link to another scene and arrival point. **Rationale:** Children can reuse door art for different stage roles. Door open/close actions and scene travel add state and depend on the basic prop blocking model and dynamic scene loading; keep this as later work. Whether a doorway activates automatically or through a directed action remains open. **Connections:** `scripts/stage/stage_scene_io.gd`, `scripts/stage/trigger_area.gd`, `scripts/stage/trigger_registry.gd`, `scripts/editors/stage/sandbox_stage.gd`. -5. **Scenery, backgrounds, and vector surface finishes** — **Status:** Planned · **Suggested priority:** Medium. **Plan:** [Phase 19 — Scenery, Backgrounds, and Surface Finishes](../plans/phase_19_scenery_backgrounds_surface_finishes.md). **Intent:** Give the Stage a Scenery category with three distinct uses: a scene-wide Sky background, finite wall panels, and individually placed artwork. One static, detailed Sky preset fills the camera view and remains fixed throughout PLAY; the initial designed presets are Clear Day, Cloudy Day, Sunset, and Starry Night. Skies are adaptive scene themes rather than `.prop` files: the `.scene` saves a stable preset ID, while each preset composes crisp vector gradients, varied clouds or stars, and sun or moon details without visible panel edges or obvious repetition. Wall panels are resizable, colorable rectangles with finishes such as paint, brick, and wood; `.prop` artwork can be placed as inert scenery. Scenery artwork supports simple Far and Near layers so a muted skyline strip can sit behind colorful storefronts, houses, and trees while all scenery remains behind the action. New Scene offers Blank and one polished Town Street starter built from a Cloudy Day Sky, Far skyline, Near storefront/tree scenery, and sidewalk/road Ground; the starter creates an unsaved scene whose ordinary parts remain editable. User-created scenery artwork reuses the existing vector artwork editor and `.prop` format; no separate Scenery Editor or `.scenery` format is needed initially. The Stage placement category, saved in the `.scene`, determines whether the same artwork is inert Scenery or an interactive Prop. Visual floor finishes are chosen through the existing Ground tool and remain walkable terrain; initial outdoor looks include concrete/sidewalk and asphalt/pavement alongside indoor finishes. Wall panels are inert Scenery. Shared finish names, previews, and color controls make the relationship clear without merging their behavior. Choosing a Ground finish affects new placement, while an existing piece changes only when directly edited. Ground alone gains finishes in this version; Ramp and Step retain their current appearance and remain unconstrained for a possible future terrain redesign. The artwork catalog uses optional multi-select tags from a built-in list: Nature, Buildings, Walls, Roads & Paths, and Objects. The same tags may filter both Props and Scenery because they describe the drawing, while the Stage entry point determines behavior. **Rationale:** Children can build outdoor and indoor sets without changing movement or interaction. A scene-wide Sky avoids accidental edges during camera movement, wall panels can build finite rooms over it, and cutout artwork dresses either setting. Curated long vector assets and the editable Town Street starter make a layered outdoor scene immediately achievable without adding a general template system. Reusing the established multi-shape vector authoring workflow avoids duplicate editors, split libraries, and file conversion. Its child-facing presentation may evolve from Prop Editor toward Artwork Editor so prop-only physics and attachment controls do not dominate scenery creation. Scenery objects and wall panels are explicitly inert: they cannot block, support, move, be picked up or used, or participate in events. Sky sits farthest back, wall panels and Far scenery render over it, Near scenery renders over those, and terrain, props, and actors remain in front. Ground patterns align across adjacent painted pieces so a floor or road reads as one continuous surface. A dedicated scenery-editing flow should keep broad or overlapping background art from blocking normal selection. Artwork may appear in several browse categories, while older untagged artwork remains available under All Artwork and Unsorted. Solid walls remain terrain or future static structures, while loose bricks and planks remain props. Individual clouds, suns, or moons may still be `.prop` scenery cutouts, but whole adaptive skies are not. Animated weather, parallax, directed sky changes, user-authored sky themes, custom repeatable finish authoring, a larger starter gallery, inserting starters into existing scenes, and user-authored set templates are future workflows and do not require a scenery asset type now. **Connections:** `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_file.gd`, `scripts/props/prop_library.gd`, `scripts/editors/stage/stage_asset_browser.gd`, `scripts/terrain/terrain_block.gd`, `scripts/editors/stage/stage_terrain_painter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, [Stage panel UI system](../specs/stage_panel_ui_system.md). +5. **Scenery, backgrounds, and vector surface finishes** — **Status:** Planned · **Suggested priority:** Medium. **Plan:** [Phase 19 — Scenery, Backgrounds, and Surface Finishes](../plans/phase_19_scenery_backgrounds_surface_finishes.md); [Starter Scene Composition brief](../plans/phase_19_starter_scene_composition.md); [composition UI/UX spec](../specs/phase_19_starter_scene_composition_ui_ux.md); [composition architecture spec](../specs/phase_19_starter_scene_composition.md). **Intent:** Give the Stage a Scenery category with three distinct uses: a scene-wide Sky background, finite wall panels, and individually placed artwork. One chosen Sky preset fills the camera view; its gradient stays fixed to the screen while details respond gently and automatically to camera pan and zoom in EDIT, DIRECT, and PLAY; the initial designed presets are Clear Day, Cloudy Day, Sunset, and Starry Night. Skies are adaptive scene themes rather than `.prop` files: the `.scene` saves a stable preset ID, while each preset composes crisp vector gradients, varied clouds or stars, and sun or moon details without visible panel edges or obvious repetition. Wall panels are resizable, colorable rectangles with finishes such as paint, brick, and wood; `.prop` artwork can be placed as inert scenery. Scenery artwork supports simple Far and Near layers so a muted skyline strip can sit behind colorful storefronts, houses, and trees while all scenery remains behind the action. New Scene offers Blank and one polished Town Street starter built from a Cloudy Day Sky, Far skyline, Near storefront/tree scenery, and sidewalk/road Ground; the starter creates an unsaved scene whose ordinary parts remain editable. The focused starter-composition work also aligns Town Street and Living Room artwork to continuous, pan-safe ground/floor runs while preserving separate editable pieces. User-created scenery artwork reuses the existing vector artwork editor and `.prop` format; no separate Scenery Editor or `.scenery` format is needed initially. The Stage placement category, saved in the `.scene`, determines whether the same artwork is inert Scenery or an interactive Prop. Visual floor finishes are chosen through the existing Ground tool and remain walkable terrain; initial outdoor looks include concrete/sidewalk and asphalt/pavement alongside indoor finishes. Wall panels are inert Scenery. Shared finish names, previews, and color controls make the relationship clear without merging their behavior. Choosing a Ground finish affects new placement, while an existing piece changes only when directly edited. Ground alone gains finishes in this version; Ramp and Step retain their current appearance and remain unconstrained for a possible future terrain redesign. The artwork catalog uses optional multi-select tags from a built-in list: Nature, Buildings, Walls, Roads & Paths, and Objects. The same tags may filter both Props and Scenery because they describe the drawing, while the Stage entry point determines behavior. **Rationale:** Children can build outdoor and indoor sets without changing movement or interaction. A scene-wide Sky avoids accidental edges during camera movement, wall panels can build finite rooms over it, and cutout artwork dresses either setting. Curated long vector assets and the editable Town Street starter make a layered outdoor scene immediately achievable without adding a general template system. Reusing the established multi-shape vector authoring workflow avoids duplicate editors, split libraries, and file conversion. Its child-facing presentation may evolve from Prop Editor toward Artwork Editor so prop-only physics and attachment controls do not dominate scenery creation. Scenery objects and wall panels are explicitly inert: they cannot block, support, move, be picked up or used, or participate in events. Sky sits farthest back, wall panels and Far scenery render over it, Near scenery renders over those, and terrain, props, and actors remain in front. Ground patterns align across adjacent painted pieces so a floor or road reads as one continuous surface. A dedicated scenery-editing flow should keep broad or overlapping background art from blocking normal selection. Artwork may appear in several browse categories, while older untagged artwork remains available under All Artwork and Unsorted. Solid walls remain terrain or future static structures, while loose bricks and planks remain props. Individual clouds, suns, or moons may still be `.prop` scenery cutouts, but whole adaptive skies are not. Autonomous weather animation, multi-layer parallax scenery, directed sky changes, user-authored sky themes, custom repeatable finish authoring, a larger starter gallery, inserting starters into existing scenes, and user-authored set templates are future workflows and do not require a scenery asset type now. **Connections:** `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_file.gd`, `scripts/props/prop_library.gd`, `scripts/editors/stage/stage_asset_browser.gd`, `scripts/terrain/terrain_block.gd`, `scripts/editors/stage/stage_terrain_painter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, [Stage panel UI system](../specs/stage_panel_ui_system.md). **Sky scope:** Choosing a Sky preset changes only the sky; it does not tint or recolor terrain, props, characters, or other scenery. @@ -42,12 +42,14 @@ Priorities are recommendations for discussion, not a committed release schedule. 12. **Size-aware artwork previews** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Show a stickman silhouette or simple relative-size cue in artwork catalog previews so children can judge an asset before placing it. **Rationale:** Once asset dimensions are authored consistently, a size cue could reduce trial-and-error in the Stage without forcing objects to a fixed size. This depends on a settled Stage scale convention and should remain a browse-time hint, not an automatic resize. **Connections:** Stage-relative prop and scenery scale (backlog item 11), `scripts/editors/stage/stage_asset_browser.gd`, `scripts/shared/ui/asset_card.gd`. -13. **Backdrop horizon and edge treatment** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Make the transitions between Sky, broad background surfaces, scenery, and walkable Ground easy to understand and visually intentional. **Rationale:** A grass backdrop can look like a floating rectangle if its edge does not meet the scene composition cleanly. A horizon line, soft terrain contour, or deliberate overlap could help establish depth without adding collision. **Connections:** Distant background surfaces (backlog item 2), Scenery backlog item 5. +13. **Backdrop horizon and edge treatment** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Make the transitions between Sky, broad background surfaces, scenery, and walkable Ground easy to understand and visually intentional. **Rationale:** A grass backdrop can look like a floating rectangle if its edge does not meet the scene composition cleanly. A horizon line, soft terrain contour, or deliberate overlap could help establish depth without adding collision. This remains separate from Phase 19's camera-aware Sky details. **Connections:** Distant background surfaces (backlog item 2), Scenery backlog item 5, [Phase 19 — Background Ground and Wall Color](../plans/phase_19_background_ground_and_wall_color.md). 14. **Shared scenery color palette** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Reuse a small set of accessible color swatches across wall finishes and recolorable background materials. **Rationale:** Familiar choices help children coordinate walls, carpet, and other set pieces without opening a full picker for every edit. This should complement a full color picker where requested and preserve material texture. **Connections:** Distant background surfaces (backlog item 2), Scenery backlog item 5. 15. **Duplicate background surface** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Let children duplicate a selected distant surface to extend a backdrop while keeping its material and color. **Rationale:** Repeating a configured panel may be faster than rebuilding a long landscape or room background from scratch. **Connections:** Distant background surfaces (backlog item 2), `scripts/stage/stage_selection.gd`. +16. **Gentle camera-aware Sky details** — **Status:** Planned · **Suggested priority:** Medium. **Plan:** [Phase 19 — Scenery, Backgrounds, and Surface Finishes, camera-aware Sky addendum](../plans/phase_19_scenery_backgrounds_surface_finishes.md#camera-aware-sky-details-addendum). **Intent:** Let clouds, sun, moon, and stars respond gently and automatically to Stage camera pan and zoom while the Sky gradient always fills the screen. Children choose a Sky and direct the scene without adjusting a motion control. **Rationale:** A small amount of camera response connects the Sky details to a dramatic shot while keeping them visually distant and avoiding a new child-facing setting. Each preset can tune its own response so details stay composed, clear of the main action, and inside the viewport. Background ground remains world-anchored and supplies the horizon. Keep the response bounded across EDIT, DIRECT, PLAY, viewport resize, and saved camera framing; do not add a slider or make Sky details establish a ground line. **Connections:** Scenery backlog item 5, Backdrop horizon and edge treatment (backlog item 13), `scripts/stage/scenery/stage_sky_view.gd`, `scripts/stage/scenery/stage_background_ground.gd`, `scripts/editors/stage/sandbox_stage.gd`. + ## Entry format For each feature, record: diff --git a/docs/plans/phase_19_scenery_backgrounds_surface_finishes.md b/docs/plans/phase_19_scenery_backgrounds_surface_finishes.md index dd2c00e..a869ddc 100644 --- a/docs/plans/phase_19_scenery_backgrounds_surface_finishes.md +++ b/docs/plans/phase_19_scenery_backgrounds_surface_finishes.md @@ -1,6 +1,6 @@ # Phase 19 — Scenery, Backgrounds, and Vector Surface Finishes -> **Status:** Phase 19 implementation, including Stage-relative asset sizing and the selected-artwork depth inspector, is integrated; automated and render-capable checks pass. Full user/editor verification remains pending. See the [Phase 19 handoff](phase_19_handoff.md) for continuation context and verification entry points. +> **Status:** The original Phase 19 implementation, including Stage-relative asset sizing and the selected-artwork depth inspector, is integrated; automated and render-capable checks pass. The camera-aware Sky details addendum below is accepted design work awaiting implementation and user/editor verification. See the [Phase 19 handoff](phase_19_handoff.md) for continuation context and verification entry points. > **Audience:** UI/UX, architecture, implementation, and art contributors. > **Purpose:** Define the child experience and the content required for indoor and outdoor sets. Rendering, file contracts, and GDScript APIs belong in later specifications. @@ -53,10 +53,24 @@ The existing vector Prop Editor and `.prop` assets supply user-created artwork. ### Choose a Sky -**Backgrounds → Sky** shows four designed thumbnail cards: **Clear Day**, **Cloudy Day**, **Sunset**, and **Starry Night**. One tap changes the scene's Sky, and choosing another card changes it again. A Sky fills every camera view without an exposed edge; its clouds, sun, moon, and stars remain visually stable and crisp during pan and zoom. Each preset should be a considered composition rather than a flat color with an obvious repeated icon. The Sky remains fixed throughout PLAY. +**Backgrounds → Sky** shows four designed thumbnail cards: **Clear Day**, **Cloudy Day**, **Sunset**, and **Starry Night**. One tap changes the scene's Sky, and choosing another card changes it again. A Sky fills every camera view without an exposed edge. Its gradient stays fixed to the screen while its clouds, sun, moon, and stars respond gently to camera pan and zoom as described in the [camera-aware Sky details addendum](#camera-aware-sky-details-addendum). Each preset should be a considered composition rather than a flat color with an obvious repeated icon. Changing Sky changes **only the sky**. It never tints or recolors Ground, wall panels, scenery artwork, characters, or Props. Sky is selected through its chooser, not by clicking a giant rectangle on the Stage. +### Camera-aware Sky details addendum + +**Goal and audience.** A child making an outdoor scene should see a Sky that feels distant without making their Ground and stickman appear to float among oversized clouds. This applies when a child chooses any of the four Phase 19 Sky presets, frames a shot in EDIT or DIRECT, and watches it in PLAY. + +**Child flow.** The child chooses a Sky as before, places walkable Ground and a stickman, then pans or zooms the Stage camera. The Sky gradient continues to fill the entire view. Clouds and celestial details shift gently with camera movement and change size modestly with camera zoom, so the composition feels connected to the shot while staying distant. The child does not set a motion mode, slider, or per-scene option. Switching presets applies that preset's tuned response automatically. The same view is reproduced after saving and reopening the scene at its saved camera framing. + +**Visual behavior.** Each preset keeps its distinctive color and motifs, with detail size and placement tuned to leave clear space around characters and Ground at ordinary framing. Camera response is bounded and smooth: no visible Sky edge, abrupt jump, distracting sweep, or cloud that grows to dominate the action during normal pan and zoom. The gradient remains screen-filling and fixed; only Sky details respond. EDIT, DIRECT, and PLAY use the same camera-relative composition. The chosen Sky remains decorative and never changes the color, collision, or movement of any world object. + +**Horizon and Ground.** The Sky does not draw a horizon or a ground line. A child who wants land behind the playable area uses the existing world-anchored **Background ground** scenery surface, placed behind walkable Ground. This keeps a visible landscape edge aligned with the scene as the camera moves. Softer contours, automatic edge blending, and easier horizon construction remain the separate [backdrop horizon and edge treatment idea](../ideas/feature_backlog.md); this addendum does not define or ship those tools. + +**Success criteria.** At ordinary and representative close/wide camera views, a character on Ground reads as being in front of the Sky rather than inside a cloud. Panning and zooming each preset gives a perceptible but subtle response without revealing an edge or obscuring the action. The same camera framing reproduces the same Sky composition after a mode change, PLAY/stop cycle, or save/reopen, and the composition adapts coherently to a resized viewport. A Background ground surface and walkable Ground stay aligned with each other while Sky details respond behind them. Sky chooser previews still identify the four looks and do not imply that the child must configure camera behavior. + +**Scope and handoff.** This is a Phase 19 visual-behavior addition for the four existing Sky presets. It preserves the existing Sky choice and screen-filling gradient. It excludes autonomous cloud/weather animation, a child-facing motion control, a Sky-ground horizon, multi-layer parallax scenery, and changes to terrain physics or Background ground placement. UI/UX and architecture should resolve how much pan/zoom response is comfortable for each preset and how the view stays composed at viewport extremes. Relevant current references are `scripts/stage/scenery/stage_sky_view.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/scenery/stage_background_ground.gd`, the [Phase 19 Sky UI/UX specification](../specs/phase_19_scenery_backgrounds_ui_ux.md), and the [Background Ground and Wall Color plan](phase_19_background_ground_and_wall_color.md). Those earlier specifications describe the original static behavior and need a focused follow-up before implementation. + ### Draw a wall panel **Backgrounds → Wall panel** offers **Paint**, **Brick**, and **Wood** finishes with a color choice. The child chooses a finish, drags from one corner to the other, sees a translucent preview of the intended size and pattern, and releases to place a finite rectangular panel. Snap may help edges meet. A selected panel can later be moved, resized, recolored, restyled, or erased; changing one panel does not silently alter others. @@ -89,13 +103,13 @@ The existing **Ground** palette entry gains a visual finish chooser. Picture swa Ground remains solid and walkable. This phase gives finishes only to **Ground**; it does not redesign or restyle Ramp and Step. Children can use Sidewalk and Asphalt Ground in Town Street and Wood Ground in Living Room. A Scenery wall panel or road marking never becomes a movement surface. -In **PLAY**, edit handles and placement ghosts disappear. Sky and Scenery stay still; characters move on Ground; Props follow their own saved behavior settings. Stopping PLAY restores the authored arrangement for another take. Story playback and camera movement should preserve the intended visual layer order and the child's view of the action. +In **PLAY**, edit handles and placement ghosts disappear. Scenery stays put; Sky details respond gently to camera pan and zoom under the same rules as EDIT and DIRECT. Characters move on Ground; Props follow their own saved behavior settings. Stopping PLAY restores the authored arrangement for another take. Story playback and camera movement should preserve the intended visual layer order and the child's view of the action. ## Included scope and exclusions -Included in Phase 19: one fixed scene-wide Sky preset; four initial Sky designs; finite editable wall panels with manually erasable rectangular openings; inert `.prop` Scenery placements with Far, Near, and On the floor depth; built-in multi-select browse tags; Ground-only visual finishes; editable Blank/Town Street/Living Room New Scene choices; Scenery-safe selection; scene save/reopen and legacy defaults; built-in vector content listed below; and a stage-relative size-authoring guide/workflow shared by Props and Scenery artwork, with single Scenery artwork placements resizable independently in Edit Scenery. The new authoring convention does not silently rescale existing assets or saved placements. +Included in Phase 19: one chosen scene-wide Sky with a screen-filling fixed gradient and gentle automatic camera-aware details; four initial Sky designs; finite editable wall panels with manually erasable rectangular openings; inert `.prop` Scenery placements with Far, Near, and On the floor depth; built-in multi-select browse tags; Ground-only visual finishes; editable Blank/Town Street/Living Room New Scene choices; Scenery-safe selection; scene save/reopen and legacy defaults; built-in vector content listed below; and a stage-relative size-authoring guide/workflow shared by Props and Scenery artwork, with single Scenery artwork placements resizable independently in Edit Scenery. The new authoring convention does not silently rescale existing assets or saved placements. -Excluded from Phase 19: a new Scenery Editor or `.scenery` file; child-authored repeatable finish patterns or reusable whole-Sky themes; animated weather, parallax, or directed Sky transitions; whole-scene filters such as sepia or black-and-white; functional doors or scene travel; automatic window cutouts or glass; Ramp/Step finishes or terrain redesign; inserting a starter into an existing scene; user-authored reusable set templates; and functional stairs or multi-level travel. [Multi-level walkable scenes](../ideas/feature_backlog.md) and [whole-scene visual filters](../ideas/feature_backlog.md) are separate future ideas, not Phase 19 work. +Excluded from Phase 19: a new Scenery Editor or `.scenery` file; child-authored repeatable finish patterns or reusable whole-Sky themes; autonomous weather animation, multi-layer parallax scenery, or directed Sky transitions; whole-scene filters such as sepia or black-and-white; functional doors or scene travel; automatic window cutouts or glass; Ramp/Step finishes or terrain redesign; inserting a starter into an existing scene; user-authored reusable set templates; and functional stairs or multi-level travel. [Multi-level walkable scenes](../ideas/feature_backlog.md) and [whole-scene visual filters](../ideas/feature_backlog.md) are separate future ideas, not Phase 19 work. ## Asset production manifest @@ -105,12 +119,14 @@ In the tables, **TBD** in Source/rights means the creator, reference provenance, ### Adaptive Sky themes +The **Integrated** statuses below describe the original Sky artwork and picker. Camera-aware detail behavior is accepted in the addendum above and remains to be implemented and verified. + | Stable ID | Class / final format | Tags / default layer | Starter use | Behavior / collision | Status | Source / rights | Visual acceptance | Final location / definition | Validation evidence | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `sky_clear_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Living Room; picker | Scene-wide, static; none | Integrated | Original vector concept | Appealing blue depth and varied light cloud/sun detail; window gap reads clearly; no edge on pan. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Living Room Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_living_room.png`; user pan/zoom check pending. | -| `sky_cloudy_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Town Street; picker | Scene-wide, static; none | Integrated | Original vector concept | Layered, varied clouds leave town silhouettes readable; no obvious tile or edge. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Town Street Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_town_street.png`; user pan/zoom check pending. | -| `sky_sunset` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide, static; none | Integrated | Original vector concept | Warm atmospheric color and sun/cloud composition remain distinct from Clear Day without tinting scene objects. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_finishes_and_repair.png`; user pan/zoom check pending. | -| `sky_starry_night` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide, static; none | Integrated | Original vector concept | Dark graduated sky, varied stars, and moon remain legible while foreground contrast survives. | `scripts/stage/scenery/stage_sky_view.gd` | Distinct production gallery preview inspected: `tests/phase19_visual_background_gallery.png`; full Stage user check pending. | +| `sky_clear_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Living Room; picker | Scene-wide; camera-aware details; none | Integrated | Original vector concept | Appealing blue depth and varied light cloud/sun detail; window gap reads clearly; no edge on pan. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Living Room Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_living_room.png`; user pan/zoom check pending. | +| `sky_cloudy_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Town Street; picker | Scene-wide; camera-aware details; none | Integrated | Original vector concept | Layered, varied clouds leave town silhouettes readable; no obvious tile or edge. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Town Street Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_town_street.png`; user pan/zoom check pending. | +| `sky_sunset` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide; camera-aware details; none | Integrated | Original vector concept | Warm atmospheric color and sun/cloud composition remain distinct from Clear Day without tinting scene objects. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_finishes_and_repair.png`; user pan/zoom check pending. | +| `sky_starry_night` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide; camera-aware details; none | Integrated | Original vector concept | Dark graduated sky, varied stars, and moon remain legible while foreground contrast survives. | `scripts/stage/scenery/stage_sky_view.gd` | Distinct production gallery preview inspected: `tests/phase19_visual_background_gallery.png`; full Stage user check pending. | ### Repeatable vector surface finishes @@ -168,8 +184,9 @@ Existing built-in crate and ball artwork may be used in Town Street if an intera 5. Ordinary EDIT selection still reaches foreground characters, Props, and Ground over broad panels. Edit Scenery makes every cutout and panel discoverable and correctable; placement and an active resize gesture can be canceled before commit, while a single-piece resize commits on release and group resize retains Keep/Cancel. Labels, icons, outlines, and focus reveal state without depending on color alone. 6. Wall panel and Ground finish choices preview correctly; wall patterns do not stretch as panels resize, and adjacent Ground pieces do not show distracting pattern restarts. Changing a palette finish does not repaint existing pieces; a selected piece can be changed deliberately. 7. Older `.scene` documents retain their prior neutral background and Ground appearance when opened. Older untagged `.prop` assets remain visible in All artwork, and a missing referenced Scenery asset yields a visible recovery cue rather than silently removing the child's placement. -8. Every required manifest item reaches **Verified**, with source/rights and final location recorded. A raster concept alone cannot satisfy a final vector content requirement. -9. New and revised `.prop` artwork is authored at its intended Stage size relative to the standard approximately 940-world-unit standing stickman, avoiding a mandatory resize on ordinary placement. The Prop Editor's Size for Stage guide is visible over artwork and above a floor/contact cue. Loading performs no automatic normalization; deliberate asset overwrites retain the usual live-reference behavior and existing Save workflow, without new copy/version prompts. Individual Scenery pieces resize independently, preserve proportions, and save/reopen at the committed scale. +8. Each chosen Sky keeps its gradient screen-filling while its details respond gently and predictably to camera pan and zoom in EDIT, DIRECT, and PLAY, as specified in the [camera-aware Sky details addendum](#camera-aware-sky-details-addendum). The action remains readable, no edge appears, and the same framing survives save/reopen. +9. Every required manifest item reaches **Verified**, with source/rights and final location recorded. A raster concept alone cannot satisfy a final vector content requirement. +10. New and revised `.prop` artwork is authored at its intended Stage size relative to the standard approximately 940-world-unit standing stickman, avoiding a mandatory resize on ordinary placement. The Prop Editor's Size for Stage guide is visible over artwork and above a floor/contact cue. Loading performs no automatic normalization; deliberate asset overwrites retain the usual live-reference behavior and existing Save workflow, without new copy/version prompts. Individual Scenery pieces resize independently, preserve proportions, and save/reopen at the committed scale. ## Current project context, risks, and dependencies @@ -186,7 +203,7 @@ Existing built-in crate and ball artwork may be used in Town Street if an intera These are design and architecture tasks for the specification stage, not unresolved user choices about Phase 19's product goal: 1. What exact UI presents Backgrounds, Artwork tags, placement layer, Ground finish, and Edit Scenery without crowding the existing Stage palette? Define keyboard, touch, narrow-viewport, dismissal, and cancellation behavior using the established Stage panel patterns; Stage Undo remains out of scope for Phase 19 and is tracked separately. -2. How will an adaptive Sky fill unbounded camera views while keeping motifs stable and detailed, and how will wall and Ground finishes keep repeatable pattern scale and alignment through resizing, drag painting, and zoom? +2. How will an adaptive Sky fill unbounded camera views while keeping its gradient fixed and its details gently camera-aware as described in the addendum, and how will wall and Ground finishes keep repeatable pattern scale and alignment through resizing, drag painting, and zoom? 3. What saved scene and optional `.prop` tag fields, validation, migration defaults, missing-art recovery, and starter creation flow preserve existing documents and the same artwork's independent Scenery/Prop placements? 4. How will selected panels, cutouts, rugs, Ground, and ordinary objects remain reachable where bounds overlap, including the window gap and a room-sized wall? 5. Which asset production workflow turns concepts into final vector content, records provenance, checks collision only for interactive Props, and advances each manifest row through integration and verification? diff --git a/docs/plans/phase_19_starter_scene_composition.md b/docs/plans/phase_19_starter_scene_composition.md new file mode 100644 index 0000000..80a8bc9 --- /dev/null +++ b/docs/plans/phase_19_starter_scene_composition.md @@ -0,0 +1,63 @@ +# Phase 19 — Starter Scene Composition + +> **Status:** Accepted product design for a focused Phase 19 starter-scene polish. This is a design handoff to the spec-pipeline, not a final technical specification. + +## Goal and audience + +Make **Town Street** and **Living Room** look like complete, usable story sets when a child opens them, moves the camera, places a stickman, and presses PLAY. The audience is a child starting from **File → New** who should see a clear place to stage action and be able to change its individual pieces. + +The selected visual direction is the upper-left **Everyday Storybook** composition from the outdoor and indoor concept boards. It favors friendly shapes, a calm backdrop, a clear floor line, and open space for characters and speech. + +## Desired child outcome + +- **Town Street:** A child immediately recognizes a neighborhood street. Shops, houses, and trees stand on the same sidewalk line; a subdued skyline gives distance; the center has room for a character to walk or speak. The sidewalk, curb, and road remain visible as a continuous set as the child pans through the intended starter area. +- **Living Room:** A child immediately recognizes a room with a complete wall and wood floor. The window is the only opening to the Sky. Furniture rests on the floor, and a centered rug gives the room warmth while leaving a clear action lane. +- In either starter, the child can select, move, resize, restyle, or remove ordinary pieces with the existing Stage tools, then save and reopen the revised scene. + +## Core behavior and composition + +**Opening and editing.** Keep the existing Blank Scene, Town Street, and Living Room choices and the current unsaved-scene flow. The two revised starters should open at a useful camera frame with no setup step. They remain editable scenes assembled from the existing Sky, Scenery, wall/background surfaces, walkable Ground, and Props. Do not flatten a starter into a single picture or merge independently useful objects merely to hide seams. + +**Outdoor set.** Use the current Cloudy Day Sky and a quiet Far skyline. Arrange the existing house, storefront, and tree artwork so their visible bases meet one consistent street datum. Preserve a generous, readable opening near the initial center for a stickman and speech. Carry Sidewalk and Asphalt as left-to-right rows of ordinary-size Ground tiles across the visible frame and agreed pan range. Add an opaque, non-walkable Background ground layer below the skyline datum to cover clouds showing through the lower scene. Buildings and trees may repeat or vary in size and spacing to support a longer street, while remaining individual editable placements. + +**Indoor set.** Carry the painted wall and a left-to-right row of ordinary-size Wood Ground tiles across the initial frame and agreed pan range. Make the wall and floor meet cleanly with a visible baseboard or equivalent visual edge. Keep the intentional opening beneath the window frame as the only place the selected Sky shows through; no Sky should leak at outer wall edges, below the floor, or through unintended seams. Position the couch, armchair, and coffee table so they visually rest on the floor. Center the existing rug in the room, show it above the wood finish and below action, and leave enough uncluttered floor for a character to move and be seen. Wall decoration remains secondary to the window and action area. + +**Camera envelope.** The approved implementation target is a **4,760-world-unit** minimum for each walkable span: a 1,280-logical-pixel viewport at saved zoom 0.5, ±1,000 world units of pan from the saved camera, and 100 world units of margin at each end. Twenty-five standard 192-unit tiles span 4,800 units and satisfy the target without oversized scaled blocks. Town Asphalt adds one normal-size tile at each end (27 total) so the lower road is physically reachable beyond the sidewalk ends. Outside the defined starter envelope, finite set edges may appear naturally because the camera remains unrestricted. The architecture spec records the exact formula and accepted trade-offs. + +## Story and kid-facing feedback + +The default shot should communicate where action can happen without explanatory text. A child placing a stickman should see feet on a walkable surface, room in the center for a short walk or conversation, and enough contrast to read the character and a speech bubble. Panning should reveal more of the same coherent set, with no sudden floating building, missing floor, or stray Sky gap. Existing Scenery and Ground editing affordances provide recovery if a child moves a piece; this polish adds no new child-facing controls. + +## Success criteria + +1. At the saved starting camera, both starters read as complete places at normal Stage size, with a clear central action area and understandable walkable surfaces. +2. In Town Street, every near building and tree appears grounded on the sidewalk/curb datum. Sidewalk and road continue across the initial view and the defined horizontal pan envelope without an exposed end or distracting join. A character can step from the sidewalk onto the road at either exposed end. The Far skyline remains subdued behind the action. +3. In Living Room, the wall, baseboard, and wood floor continue across the initial view and pan envelope. The selected Sky appears through the window opening only. Couch, chair, and table visibly contact the floor; the rug remains centered and does not consume the character's main movement lane. +4. At normal framing, a placed stickman and speech remain easy to see in both starters. PLAY preserves the intended walking/support behavior, visual order, and composition; saving and reopening preserves the revised set and camera frame. +5. The important pieces remain separately editable through their established tools. Moving or removing one house, tree, wall panel, Ground piece, rug, or furniture Prop does not require replacing a baked image. + +The pre-implementation design review found the outdoor artwork bases roughly **128 screen pixels above Ground**, the **700-world-unit Ground run** ending inside a roughly **917-pixel viewport**, and the indoor couch/chair roughly **24 screen pixels above the floor**. Those measurements motivated the composition work; they are not final placement coordinates. + +## Scope and exclusions + +**Included:** Composition and placement polish for the existing Town Street and Living Room starter scenes; continuity of existing surfaces and backdrops over a defined pan range; alignment of visible artwork and furniture with their shared ground/floor datum; initial camera framing; preservation of scene editability and existing Prop/Scenery roles. + +**Excluded:** New story mechanics, sit behavior, new controls or authoring tools, Stage-wide Undo/Redo, camera restrictions, endless scenery generation, and changes to terrain collision or a general navigation system. The existing navigation bake receives only the tile-seam and exposed 32-unit curb connectors required by these authored surfaces. New art is outside this pass unless the existing library cannot make the selected composition legible; identify any specific missing visual element before proposing an asset request. + +## Current project behavior and references + +The Phase 19 [scenery product plan](phase_19_scenery_backgrounds_surface_finishes.md) defines the starter choices, editable scenery layers, and the distinction between visual Scenery and walkable Ground. The [Background Ground and Wall Color brief](phase_19_background_ground_and_wall_color.md) defines decorative Background ground and editable wall finishes. The [Phase 19 handoff](phase_19_handoff.md) says overall user/editor verification is still pending. + +The accepted visual direction is detailed in the [starter-scene UI/UX specification](../specs/phase_19_starter_scene_composition_ui_ux.md). The implementation contract is in the [approved starter-scene architecture specification](../specs/phase_19_starter_scene_composition.md). + +Before this composition pass, the [Town Street scene](../../content/stages/starters/town_street.scene) used one Far skyline, separate Near trees/buildings, and short Sidewalk/Asphalt Ground runs. The [Living Room scene](../../content/stages/starters/living_room.scene) used three wall panels, seven Wood Ground pieces, separate decorations, and three Props. Both saved zoom 0.5. The implementation now promotes these starters to current scene format v1.6, extends required surfaces to the approved envelope, and frames starter previews from the saved camera. See the focused [architecture specification](../specs/phase_19_starter_scene_composition.md) for exact placements and verification results. The existing artwork and furniture remain in [content/props](../../content/props/), with each placement independently editable. + +## Verification status and remaining checks + +- The focused composition suite passes 515 assertions, including tile bounds, seam navigation, a physical PLAY step from sidewalk to road, and Living Room floor support. +- A render-capable capture route is not available in this run: the bounded GUI capture exited 134 without producing an image. Visual checks remain for the opening frames, pan endpoints, tile/background joins, starter cards, narrower viewport, and zoom-in framing. +- The broader scenery suite still exits 1 on nine Sky-preview/gallery and Scenery-edit interaction assertions; Town/Living Room surface assertions pass and those nine failures were also present before the latest tile and curb changes. + +## Implementation status + +The initial focused composition test passed 177 assertions, but user visual review found clouds visible through the Town Street below the skyline and rejected the oversized scaled walkable blocks. The revision adds opaque Grass Background ground, ordinary-size tile rows, and a one-tile Asphalt extension at each end so characters can reach the road. The focused composition suite now passes 515 assertions, including routing across tile seams and a grounded PLAY drop onto Asphalt. Rendered review and user/editor checks remain pending. The [architecture specification](../specs/phase_19_starter_scene_composition.md) records the measurements and test limitations. diff --git a/docs/specs/phase_19_scenery_backgrounds_surface_finishes.md b/docs/specs/phase_19_scenery_backgrounds_surface_finishes.md index 30c5604..51fb217 100644 --- a/docs/specs/phase_19_scenery_backgrounds_surface_finishes.md +++ b/docs/specs/phase_19_scenery_backgrounds_surface_finishes.md @@ -1,11 +1,11 @@ # Phase 19 — Scenery, backgrounds, and surface finishes: architecture and implementation specification -> **Status:** Original Phase 19 scenery architecture and the approved Stage-relative sizing amendment are integrated; automated and render-capable checks pass. This specification follows the [accepted product brief](../plans/phase_19_scenery_backgrounds_surface_finishes.md), the [approved UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and the [Stage panel UI system](stage_panel_ui_system.md). Full Phase 19 user/editor verification remains pending. The earlier sections describe the original implementation contract; the sizing amendment starts from the current `.scene` v1.5, `.prop` v1.1, Stage controller, and shared UI. +> **Status:** Original Phase 19 scenery architecture and the approved Stage-relative sizing amendment are integrated; automated and render-capable checks pass. The camera-aware Sky architecture addendum below is specified for user sign-off and is not yet implemented. This specification follows the [accepted product brief](../plans/phase_19_scenery_backgrounds_surface_finishes.md), the [approved UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and the [Stage panel UI system](stage_panel_ui_system.md). Full Phase 19 user/editor verification remains pending. The camera-aware Sky addendum supersedes the original static-detail statements in this document. ## Decisions and scope 1. Use independent, inert `Node2D` placements for wall panels and `.prop` Scenery artwork. The source `.prop` is a **live reference**: its current vector art is used on reopen. Missing art remains a saved, visible, repairable placement. A given `.prop` may be used as inert Scenery and, when it has valid collision geometry, as a functional Prop. -2. Sky is one adaptive, viewport-filling, screen-space vector drawing behind the world. Its details stay fixed during camera movement; it does not tint other objects. Existing scenes and Blank Scene use an internal neutral Sky. The four child-facing Sky presets are Clear Day, Cloudy Day, Sunset, and Starry Night. +2. Sky is one adaptive, viewport-filling, screen-space vector drawing behind the world. Its gradient stays fixed while its details respond gently to camera pan and zoom under the [camera-aware Sky addendum](#phase-19-addition-camera-aware-sky-details); it does not tint other objects. Existing scenes and Blank Scene use an internal neutral Sky. The four child-facing Sky presets are Clear Day, Cloudy Day, Sunset, and Starry Night. 3. The existing Prop Editor opens **inside the live Stage session** for Make artwork. The Stage and its unsaved document remain in memory, with Stage input suspended until the editor returns. The standalone F6 route remains valid. 4. Select several creates a temporary Scenery selection and snapshot-based transform preview. It does not create a persistent group or a parent that changes the saved hierarchy. Sky, Ground, characters, and functional Props are outside this transform. 5. Phase 19 adds neither Stage Undo/Redo nor a whole-set transform. Group preview has its own exact Cancel/Keep boundary. Stage-wide Undo/Redo is a separately planned phase and is logged in [technical debt](../architecture/tech_debt_and_optimizations.md). @@ -31,7 +31,7 @@ flowchart TD | Owner / location | Typed contract or change | Boundary | | --- | --- | --- | | `scripts/stage/stage_draw_order.gd`, `class_name StageDrawOrder extends RefCounted` | Constants `WALL := -40`, `FAR := -30`, `NEAR := -20`, `TERRAIN := -10`, `FLOOR := -5`; `static func scenery_z(depth: StringName) -> int`; `static func apply_terrain(node: TerrainBlock) -> void` | One drawing policy. Leave the existing Prop `-1/0/1` and character relative order intact; rebase terrain drawing only. Never serialize raw `z_index` in place of a semantic depth/finish. | -| `scripts/stage/scenery/stage_sky_view.gd`, `class_name StageSkyView extends Control` | `func set_sky(id: StringName) -> void`; `func get_sky() -> StringName`; `func apply_theme(theme: SandboxTheme) -> void` | Full-rect child of a dedicated `CanvasLayer` with `layer = -1`, mouse ignored. Deterministic vector `_draw()` on viewport resize/theme/choice only. No world hit test or physics. | +| `scripts/stage/scenery/stage_sky_view.gd`, `class_name StageSkyView extends Control` | `func set_sky(id: StringName) -> void`; `func get_sky() -> StringName`; `func apply_theme(theme: SandboxTheme) -> void`; camera binding specified in the [Sky addendum](#phase-19-addition-camera-aware-sky-details) | Full-rect child of a dedicated `CanvasLayer` with `layer = -1`, mouse ignored. Deterministic vector drawing on preset, viewport, or effective camera-framing changes. No world hit test or physics. | | `scripts/stage/scenery/stage_wall_panel.gd`, `class_name StageWallPanel extends Node2D` | `var finish_id: StringName`; `var tint: Color`; `var panel_size: Vector2`; optional `var motif_origin_offset: Vector2`; `func configure(size: Vector2, finish: StringName, color: Color) -> void`; `func get_local_bounds() -> Rect2` | Finite world rectangle centered at `position`. No `CollisionObject2D`, navigation, support, or interaction registration. Resize changes `panel_size`, never root scale, so motifs retain their world pitch. The renderer lays the pattern out from each panel's current world transform, so eraser-created fragments keep continuous brick courses; the optional offset preserves source-wall lineage in saved data. | | `scripts/stage/scenery/stage_scenery_artwork.gd`, `class_name StageSceneryArtwork extends Node2D` | `var asset_path: String`; `var display_name: String`; `var depth: StringName`; `var last_known_local_bounds: Rect2`; `var missing: bool`; `func load_visual(path: String) -> bool`; `func set_depth(value: StringName) -> void`; `func get_visual_world_bounds() -> Rect2`; `func get_saved_footprint() -> Rect2` | Build a `Visuals` child via `PropVisuals.build()` from the complete shape stack. No `PropBlock`, collision hull, physics, Use spots, anchors, or event identity. If loading fails, render a neutral marker/known footprint from saved data and preserve path/name/transform. | | `scripts/stage/scenery/stage_surface_renderer.gd`, `class_name StageSurfaceRenderer extends RefCounted` | `static func is_wall_finish(id: StringName) -> bool`; `static func is_ground_finish(id: StringName) -> bool`; `static func draw_wall(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void`; `static func draw_ground(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void` | Shared vector definitions for Paint/Brick/Wood, with Grass/Dirt/Sidewalk/Asphalt for Ground. One motif vocabulary and world-coordinate phase; no raster scale blur. Drawing helpers clip motifs to finite panel/block bounds and bound work to visible tiles. | @@ -75,7 +75,7 @@ The manual **Erase wall area** tool adds one optional wall field, `motif_origin_ The semantic draw order is Sky → wall panels → Far art → Near art → terrain → floor art → characters and Props. `StageDrawOrder` applies bands to direct `World` children. Set all existing terrain varieties to the terrain band for consistent physical-surface drawing; their collision and input remain unchanged. Prop `-1/0/1` choices and World child order among legacy depth-zero Props and stickmen are preserved. Reapply the policy on spawn, load, depth change, and reset. Selection must use the same semantic tier order plus smaller actual art footprint preference, rather than assuming child insertion order expresses depth. -`StageSkyView` lives in its own `CanvasLayer` behind `World`, not under the camera or a wall. Its draw rectangle follows viewport size, so panning/zooming cannot reveal an edge. Use deterministic vector gradient bands, stars, clouds, sun/moon and shapes with bounded point counts. The preset controls only this view. Rendering should be static between sky or viewport changes; no per-frame random placement, animation, parallax, or tint propagation. Sky thumbnails use the same drawing definitions at card size so the choice predicts the Stage. +`StageSkyView` lives in its own `CanvasLayer` behind `World`, not under the camera or a wall. Its draw rectangle follows viewport size, so panning/zooming cannot reveal an edge. Use deterministic vector gradient bands, stars, clouds, sun/moon and shapes with bounded point counts. The preset controls only this view. The gradient stays screen-fixed; the [camera-aware Sky addendum](#phase-19-addition-camera-aware-sky-details) defines bounded, camera-responsive details and redraw invalidation. There is no per-frame random placement, autonomous animation, or tint propagation. Sky thumbnails use the same drawing definitions at card size in their unbound default composition. `StageSurfaceRenderer` defines finite vector motifs with a world-aligned origin and fixed world-unit pitch. Wall and Ground may share Brick/Wood definitions but use context-aware fill/outline colors and scale. `StageWallPanel._draw()` uses its current finite rectangle as a clip and asks the renderer for only intersecting motif cells. For brick walls, cull to the camera-visible local rectangle; if that visible area exceeds 12,000 cells, increase the brick pitch uniformly for all visible courses instead of truncating rows or columns. This keeps large walls fully patterned and fragments continuous without unbounded draw work. Ground renders within its existing rectangle geometry; existing `CollisionPolygon2D` and navigation surfaces are never rebuilt merely for a finish change. The selected **New Ground look** is passed into both `StageTerrainPainter` ghost creation and each committed Ground block; it never repaints earlier blocks. During group resize, wall motif lines retain their pitch and phase. Ground blocks adjacent on a shared edge use the same world phase; shared same-finish interior borders are suppressed or visually joined so a painted run reads as one surface. The neighbor check refreshes on place, delete, move, load, and per-piece finish change, and considers only nearby Ground rectangles; it does not change occupancy/collision. Other motif types remain bounded by the camera-visible region or a maximum cell count per finite panel so `_draw()` work stays limited. @@ -185,6 +185,8 @@ On scene open, load every live artwork reference. A missing/invalid source leave Keep versioned starter documents under `content/stages/starters/` with stable IDs for Blank, Town Street, and Living Room. They are ordinary `.scene` snapshots, not locked nested instances. Blank may be assembled from the current neutral empty-scene defaults; Town Street and Living Room data use the same wall, artwork, terrain, Prop, Sky, and camera schema as a saved child scene. The bundled files reference only explicitly approved production art. A window is a genuine gap between wall rectangles with a transparent frame and Sky behind it; no cutting or glass behavior is inferred. Furniture Props receive deliberate still/block/support roles from the normal Prop contract, without promising Sit. +The focused [Phase 19 starter-scene composition specification](phase_19_starter_scene_composition.md) defines the approved pan-safe Town Street and Living Room surfaces and the opening-shot bounds used by starter-card previews. It keeps the ordinary v1.5 scene records and camera free of limits. + The current `PendingFileAction.NEW_SCENE` unsaved Save/Discard/Cancel gate remains authoritative. After it resolves, show the shared starter gallery **before** replacing the current document. Canceling the gallery leaves the existing scene and post-gate dirty state. Selecting a starter preflights its scene data, loads ordinary editable objects, resets `current_path` to empty, stamps a fresh creation time, proposes its editable name, and marks the new document unsaved. Failed starter validation leaves the prior scene intact and shows one themed error. No starter is inserted into another scene. A first-save path follows the existing Save As flow. CLI scene playback continues to load `.scene` directly, with no starter-specific runtime branch. ## UI component and theme reuse @@ -237,7 +239,7 @@ Each row names a child path and a repeatable observation. Automated checks may c | Edit Scenery → select two copies of one artwork; drag a corner on one, release; then cancel another drag with Esc/RMB/touch Cancel and release the pointer | The opposite visible corner stays at the same world position; only the edited placement changes, and it commits on release without Keep. A canceled drag restores its snapshot and the late release makes no change. With Snap on, a moved artwork or wall's visible top-left lands on a grid intersection. Smaller then Bigger returns near the initial bounds. Save/reopen and check the two copies retain distinct scales, while Props/Ground/PLAY behavior remains unchanged. A missing artwork without known bounds shows no resize handle. | | Open a pre-amendment `.prop` and `.scene`, then explicitly overwrite one referenced `.prop` through ordinary Save | Opening alone does not rescale artwork or placement values. The explicit source overwrite changes every live reference to that source on reopen, while each scene placement retains its saved scale; there is no hidden compensation, copy prompt, or version migration. | | Move a referenced `.prop` aside, reopen, repair selected missing artwork; cancel once then confirm | One neutral marker remains at saved position/depth, summary appears once, ordinary EDIT targets are not stolen, cancel keeps path/dirty state, and confirmed repair changes only that placement. Missing footprint/no-footprint group resize rules and PLAY marker presentation hold. Restore source and reopen to confirm live-reference visual update. | -| Select each Sky, pan/zoom, PLAY/stop, reopen | Sky fills screen without an edge or flicker; foreground pixel colors are unchanged by Sky choice. Layer order remains Sky/wall/Far/Near/Ground/floor/actors through camera movement, simulation, and reload; ghosts/handles/capture bars disappear in PLAY. | +| Select each Sky, pan/zoom, PLAY/stop, reopen | The fixed gradient fills the screen without an edge or flicker; details follow the bounded response in the [Sky addendum](#phase-19-addition-camera-aware-sky-details). Foreground pixel colors are unchanged by Sky choice. Layer order remains Sky/wall/Far/Near/Ground/floor/actors through camera movement, simulation, and reload; ghosts/handles/capture bars disappear in PLAY. | Visual acceptance requires opening the affected scene in a render-capable Godot GUI, capturing the named chooser, normal/zoomed Stage, missing-art, group-preview, **Prop Editor sizing guide at Small/Big and narrow width, active single-piece handles/capture bar**, and PLAY states, and inspecting the pixels. Record capture location, scene/state, and observation. Interactive acceptance requires exercising the actual controls and input events, including RMB/Esc/touch cancellation, button sizing, grid-aligned move, commit on release, and a late release after cancellation. If GUI or input automation is unavailable after an attempted route, name the unverified outcomes and hand the user a focused reproducible check; a headless parse or screenshot file alone is insufficient. Phase 19 user/editor verification remains open until the child checks the integrated behavior. @@ -250,3 +252,58 @@ Visual acceptance requires opening the affected scene in a render-capable Godot - Rebased terrain drawing enables the required floor-art tier without changing Prop-to-character depth semantics. It changes terrain's relative visual position beneath Props; physics and navigation stay unchanged and must be regression checked. - Vector motifs retain clarity and world alignment without repeated raster textures, at the cost of careful clipping/culling and seam management. Keep motif definitions centralized so wall/Ground previews match runtime drawing. - Stage-wide Undo/Redo deserves one command history across placement, deletion, style, repair, and group commits. The Phase 19 Keep/Cancel preview is a local transaction only. Whole-set transform needs its own design because Ground physics, character/Prop placement, rules, and camera all have different transform invariants. + +## Phase 19 addition: camera-aware Sky details + +This focused addition implements the accepted [product addendum](../plans/phase_19_scenery_backgrounds_surface_finishes.md#camera-aware-sky-details-addendum) and [UI/UX contract](phase_19_scenery_backgrounds_ui_ux.md#camera-aware-sky-details). The original clauses above that describe Sky details as static are superseded here. **Only the four existing child-facing Sky presets gain automatic camera response.** The internal neutral Sky may use a zero-response profile. There is no motion setting, additional Sky card, theme token, scene field, or change to Background ground behavior. + +### Ownership, nodes, and typed contract + +```mermaid +flowchart LR + Camera[SandboxStage/Camera2D\nworld position + zoom] -->|bind once; sample framing| Sky[StageSkyLayer: CanvasLayer -1\nStageSkyView: Control] + Sky --> Gradient[full-rect gradient\nscreen fixed] + Sky --> Motifs[clouds / sun / moon / stars\nbounded camera response] + World[World: Background ground + walkable Ground + actors] --> Camera + Cards[Sky chooser and starter card previews] -->|SkyView without camera binding| Still[default still composition] +``` + +`SandboxStage` (`class_name SandboxStage extends Node2D`) owns the one live `Camera2D` and passes it to the already-built `StageSkyView`; it does not dispatch a separate Sky update from every mouse, touchpad, scene-load, mode, or PLAY path. `StageSkyView` (`class_name StageSkyView extends Control`) owns all Sky drawing and the response calculation. Its narrow typed API is: + +```gdscript +func bind_camera(camera: Camera2D) -> void # A null camera restores the default still composition. +func set_sky(id: StringName) -> void +func get_sky() -> StringName +func apply_theme(theme: SandboxTheme) -> void +``` + +`StageSkyView` samples the bound camera's finite `global_position` and positive uniform `zoom.x` in its own `_process(delta: float) -> void`, compares the **effective** framing against the last drawn values, and calls `queue_redraw()` only when the visible composition changes. Its `NOTIFICATION_RESIZED` path also invalidates layout. Invalid or unavailable camera data falls back to the fixed default composition rather than propagating NaN into drawing. The first binding, preset change, and scene load invalidate immediately so a newly shown view does not display a previous frame's motifs. No signal or runtime state is serialized; the last sampled values exist only to avoid redundant redraws. The chooser's `StageBackgroundCardPreview` and starter's `StageStarterCardPreview` create unbound `StageSkyView` instances and remain representative still images. No new UI component, Stage inspector control, or `SandboxTheme.stage_panels` token is needed. + +### Deterministic response and layout + +Use the built-in Stage camera framing, `REF_POSITION := Vector2(0.0, -400.0)` and `REF_ZOOM := 0.5`, as the fixed reference for **every** scene. These values match `SandboxStage`'s current defaults and `sandbox_stage.tscn`. Evaluate each frame from absolute camera position and zoom; never accumulate input deltas, seed from elapsed time, or capture a per-session origin. Thus identical `{sky_id, camera.position, camera.zoom, viewport.size}` yields identical drawing after mode changes, PLAY/stop, and save/reopen. The existing `.scene` camera position/zoom and `sky_id` fields are sufficient; scene version and migration stay unchanged. + +Each preset has one internal response profile, shared by all its motifs. A profile contains bounded horizontal/vertical pan limits in viewport fractions, a pan response span, and a bounded logarithmic zoom strength/span. These are renderer constants, not saved data or child-facing controls. A suitable starting envelope is at most about **5% of viewport width and 3% of height** for motif displacement and about **0.85–1.15×** for uniform motif size over the Stage's supported zoom range; tune each preset by rendered comparison at ordinary, close, and wide shots. Derive pan from the camera-position difference expressed in reference-frame screen pixels and pass it through a smooth odd saturating function (for example `tanh`). Derive zoom from `log(camera_zoom / REF_ZOOM)` through a smooth bounded function, then exponentiate to keep the size positive and reciprocal around the reference zoom. Avoid hard clamps in the visible response range, which can create a conspicuous change in motion at the limit. Camera movement of world objects must visibly exceed Sky-detail movement at ordinary framing. At the reference camera framing, displacement is zero and scale is one. + +For clarity, one possible pure calculation is `shift.x = -W * limit_x * tanh(((camera_x - REF_POSITION.x) * REF_ZOOM / W) / span_x)` (and likewise for y), with `size_factor = exp(gain * tanh(log(camera_zoom / REF_ZOOM) / zoom_span))`; choose `limit/span` and `gain` so the detail response remains gentle. The final spec requirement is bounded, continuous, deterministic behavior, not these exact coefficients. Apply the same profile to all motifs of a preset as one visual group; individual stars may retain different authored sizes and positions, but do not acquire independent motion layers. Keep `sky_neutral` deterministic; a zero-response profile is acceptable for that internal default. + +The gradient remains a full `Rect2(Vector2.ZERO, size)` draw in the screen-space `CanvasLayer`; **do not apply the motif transform to the gradient**. Place motif centers using normalized viewport anchors and scale their authored vector shapes **uniformly** with a single viewport-derived scalar, rather than separate x/y stretch. Apply the camera-induced size factor about the motif's own anchor, then add the bounded shift. Let the viewport clip any motif portion outside the visible rectangle; the gradient still fills those pixels, so no Sky edge appears. On resize, recompute anchors and the scalar from the new dimensions and reevaluate the same absolute camera frame. Retune the current low and oversized clouds, especially Cloudy Day and Sunset, so the ordinary lower action area remains comparatively open. This is art composition within the renderer, not a hard mask that hides clouds over arbitrary character positions. + +The current star loop uses `fposmod(..., size.x/y)`, so changing viewport size can wrap a star abruptly to a distant position. Replace that layout with a fixed, deterministic set of normalized star anchors (or a fixed seeded list mapped to normalized coordinates); never seed it from the frame clock or call `randf()` while drawing. Existing cloud/moon curves should be sampled or cached once per shape/preset where practical, because camera movement now invalidates the drawing more often. Keep motif count and vector point count bounded; retain the gradient texture until the preset actually changes. There is no autonomous cloud drift, weather animation, horizon line, selection geometry, collision, or world tint. + +`StageBackgroundGround` remains a world-anchored `Node2D` surface behind walkable Ground. Its landscape edge and the walkable Ground therefore move and scale together through the normal `Camera2D` transform. The Sky makes no ground-line claim and does not inspect terrain or character positions. + +### Data flow and observable acceptance + +1. The child chooses Clear Day, Cloudy Day, Sunset, or Starry Night. `SandboxStage` calls the existing `set_sky(id)` path; the Sky draws its fixed gradient and reference-frame motif composition. The chooser card remains a still preview. +2. A pan or zoom changes `Camera2D` through any existing input route. The bound `StageSkyView` samples the resulting absolute framing, derives its bounded motif transform, and redraws only when that framing changes. No mode or PLAY-specific Sky branch runs. +3. Save writes the existing `sky_id` and camera fields. Reopen loads those values; the next Sky draw derives the same composition from them and the current viewport size. A resize derives a coherent composition at the new size without changing saved state. + +Repeat this in a rendered Stage: choose each preset; place a stickman on walkable Ground with a broad Background ground panel behind it; frame an ordinary shot, a close shot, and a wide shot; pan in EDIT and DIRECT, switch to PLAY and pan/zoom again, then stop. At an unchanged camera framing, motif anchors and apparent size must match across those mode and PLAY transitions. Resize the viewport twice and verify the gradient remains full-screen, motif shapes keep their proportions, and stars do not jump across the screen. Save at a nondefault camera framing, reopen at the same viewport size, and compare the motif composition; it must match without a separate Sky setting. Throughout, Sky displacement and scaling are perceptible but restrained, no edge or flicker is exposed, the lower ordinary action area remains usable, Ground and Background ground stay aligned, and foreground colors/physics remain unchanged. A screenshot or headless parse alone cannot establish the camera-response and composition outcomes; inspect rendered frames at the named camera states. + +### Architectural trade-offs + +- Keeping one `CanvasLayer` and one `StageSkyView` reuses current drawing and preview paths. The renderer must now redraw vector motifs while the camera moves; bounded geometry, geometry caching, and change-only invalidation keep that cost proportional to visible camera changes. +- Fixed reference framing avoids a new `.scene` field and makes save/reopen exact. Very distant camera positions can sit near the response limit, so additional panning there may change the Sky little; that is the accepted cost of a bounded composition. +- One profile per preset is simple to tune and preserves visual unity. It does not simulate separate cloud distances. True multilayer parallax and autonomous Sky animation remain outside this Phase 19 addition. +- Normalized anchors and uniform motif size preserve proportions across viewport shapes. Wide or narrow aspect ratios change the available spacing between details, requiring visual checks on both shapes; exact pixel placement across resizes is not promised. diff --git a/docs/specs/phase_19_scenery_backgrounds_ui_ux.md b/docs/specs/phase_19_scenery_backgrounds_ui_ux.md index a2be85d..55f1d18 100644 --- a/docs/specs/phase_19_scenery_backgrounds_ui_ux.md +++ b/docs/specs/phase_19_scenery_backgrounds_ui_ux.md @@ -1,6 +1,6 @@ # Phase 19 — Scenery, backgrounds, and surface finishes: UI/UX specification -> **Status:** Approved and implemented, including the Stage-relative asset-sizing addition (2026-10-16). Based on the [accepted product brief](../plans/phase_19_scenery_backgrounds_surface_finishes.md). Full Phase 19 user/editor verification remains pending. +> **Status:** The original Phase 19 UI/UX is approved and implemented, including the Stage-relative asset-sizing addition (2026-10-16). The accepted camera-aware Sky behavior is being specified as a focused Phase 19 addition. Based on the [accepted product brief](../plans/phase_19_scenery_backgrounds_surface_finishes.md). Full Phase 19 user/editor verification remains pending. > > **Decision:** Use a responsive Scenery chooser followed by canvas tools. Placement stays active for repeated copies. Right-click exits placement. Scenery artwork keeps live `.prop` references, with a visible, repairable placeholder if a referenced file cannot be found. The Phase 19 addition standardizes new artwork around a standing-stickman size guide and adds single-placement Scenery resizing; direct single-piece transforms commit on release, while the existing group preview keeps its Keep/Cancel boundary. Phase 19 does not add Undo; Stage Undo is deferred to its own future phase and should be recorded in tech debt during architecture planning. @@ -114,7 +114,17 @@ These are relationship examples, not fixed pixel geometry. On a narrow viewport - **Backgrounds → Sky** opens four designed thumbnail cards: **Clear Day**, **Cloudy Day**, **Sunset**, **Starry Night**. The currently selected card has a strong border, filled selected state, and the text **“Current Sky”**; keyboard focus remains a separate outline. - One tap or Enter/Space changes the Sky immediately; the chooser stays open so another card can be tried. Close means “stop choosing”, not “revert”. With no Undo in Phase 19, the child can restore the previous look by choosing its card again. Changing Sky never recolors or tints other pieces. Sky has no Stage selection handles or overlap entry. -- The thumbnail shows enough clouds, sun/moon, and color variation to predict the Stage result. A chosen Sky is static during PLAY and stable through camera movement. +- The thumbnail shows enough clouds, sun/moon, and color variation to predict the Stage result. It remains a representative still image; the Stage applies its automatic camera response without asking the child to configure motion. + +### Camera-aware Sky details + +- The four existing Sky cards and their immediate selection flow stay unchanged. The selected card still says **Current Sky**. Do not add a motion toggle, slider, new label, tutorial cue, cursor, or theme token. +- Keep the vertical gradient fixed to and filling the full viewport. Clouds, sun, moon, and stars respond automatically and subtly to Stage camera pan and zoom in **EDIT**, **DIRECT**, and **PLAY**. They move less and change size less than the stickman and Ground, so they read as distant details while connecting to a dramatic shot. +- Preset-specific composition keeps the main action area clear at ordinary framing. During close and wide views, details remain recognizable and do not grow across the character or walkable Ground. Camera response stays continuous and bounded; it does not expose an edge, pop, sweep distractingly, or reverse abruptly. +- At unchanged camera framing, switching modes, starting/stopping PLAY, and reopening a saved scene reproduce the same Sky composition. On viewport resize, the gradient continues to cover the view and the details recompose coherently. +- Keep landscape edges on world-anchored **Background ground** and scenery. Sky details do not draw a horizon, gain selection handles, enter selection, or alter the colors or behavior of world pieces. +- The visible feedback is the Sky details' restrained response to the same pan/zoom gesture already used on the Stage. No additional status message or control feedback is needed. +- Acceptance path: choose each Sky, then pan and zoom through ordinary, close, and wide framing in EDIT/DIRECT/PLAY; resize the viewport; start/stop PLAY; save/reopen with a saved camera view; and place Background ground behind walkable Ground. Confirm a subtle but perceptible Sky response, clear action area, no edge/flicker, consistent mode transitions, and Ground alignment. ### Wall panel diff --git a/docs/specs/phase_19_starter_scene_composition.md b/docs/specs/phase_19_starter_scene_composition.md new file mode 100644 index 0000000..af95e69 --- /dev/null +++ b/docs/specs/phase_19_starter_scene_composition.md @@ -0,0 +1,157 @@ +# Phase 19 — Starter Scene Composition: Architecture and Implementation Specification + +> **Status:** Implemented; focused behavior verification passed. Rendered-pixel and user/editor verification remain pending because the available Godot capture route exited before producing an image. The user requested an opaque Town Street Background ground layer and rows of ordinary-size walkable Ground tiles in both starters instead of oversized terrain blocks. +> +> **Approvals:** Everyday Storybook direction approved; architecture defaults approved: **1,280×720 logical-pixel reference viewport (16:9)**, saved zoom **0.5**, horizontal pan guarantee **±1,000 world units**, and **100 world units** of end margin. +> +> **Related documents:** [Accepted composition brief](../plans/phase_19_starter_scene_composition.md), [UI/UX specification](phase_19_starter_scene_composition_ui_ux.md), [Phase 19 scenery specification](phase_19_scenery_backgrounds_surface_finishes.md), and [Background Ground and Wall Color brief](../plans/phase_19_background_ground_and_wall_color.md). + +## Decisions and scope + +1. Revise the existing Town Street and Living Room `.scene` snapshots using the existing editable node types. Promote the edited starter snapshots to the existing current scene format v1.6; their bundled source snapshots are v1.5. Preserve current starter IDs, File → New flow, and ordinary save/reopen behavior. The existing 1.5→1.6 migration and `background_ground` object type are already supported; this feature adds no schema field and does not bump beyond v1.6. +2. At saved zoom 0.5, author a horizontal surface span of at least **4,760 world units** for each surface that must remain present during pan. Use adjacent ordinary-size terrain tiles rather than scaling one walkable block across the whole span. The current Ground template snaps to a 192×32-world-unit polygon at scale 1; 25 adjacent tiles produce a 4,800-unit run from x=−2,400 to +2,400, covering a **1,280-logical-pixel rendered viewport**, camera center travel from the saved x-position by **±1,000 world units**, and at least **100 world units** of margin at either end: + + `required_width = viewport_width / saved_zoom + 2 * pan_travel + 2 * margin` + + `= 1,280 / 0.5 + 2 * 1,000 + 2 * 100 = 4,760 world units` + + Each tile remains a normal-size, independently selectable Ground object. Adjacent edges must meet exactly: preserve one level collision top, avoid gaps/overlaps, and let the repeated tile edges read as intentional joints. + + With the current saved camera center x=0, 25 tiles span x=−2,400 to +2,400. At camera x=±1,000, the viewport reaches ±2,280, leaving a 120-unit margin. Town Asphalt uses two additional normal-size tiles, one at each end, so a character can step down from the sidewalk onto the road at an exposed curb; its 27 tiles span 5,184 units. +3. Keep camera panning unrestricted. The authored guarantee is finite and applies at the saved zoom over the stated envelope; scene edges can appear beyond that envelope or in a wider zoomed-out shot. Zooming in continues to show the same continuous surface across the viewport. +4. Keep camera and placement state as ordinary saved `.scene` fields. Adjust the saved camera y only as needed to meet the approved opening composition; do not add camera limits or runtime surface extension. +5. Reframe `StageStarterCardPreview` around the saved opening composition. Its image must not become panoramic merely because pan-only Ground or wall extents are longer. Derive the preview focus from the saved camera and visible central composition; do not add preview metadata to `.scene`. +6. Reuse existing production artwork, surface finishes, Sky presets, and child-facing choices. This pass introduces no new controls, theme tokens, scene object types, signals, or graphical assets. + +**In scope:** `content/stages/starters/town_street.scene`, `content/stages/starters/living_room.scene`, `scripts/stage/scenery/stage_starter_card_preview.gd`, and targeted navigation-ribbon coalescing plus the exposed Town curb connector in `scripts/editors/stage/sandbox_stage.gd` needed for tile seams and the 32-unit sidewalk-to-road step. + +**Out of scope:** camera restrictions; runtime-generated or auto-extending scenery; a new starter or template system; new art; changes to Ground physics/collision, navigation behavior beyond adjacent tile seams and the exposed 32-unit Town curb step, wall collision, Prop roles, Sky response, chooser controls, or scene serialization; zoom-out edge guarantees beyond the stated envelope. + +## Current state and architecture fit + +Both bundled starter files currently use scene v1.5 with `sky_id`, saved camera `{position, zoom}`, and independently editable object records. `StageSceneIO` migrates them to the current v1.6 format on read. Both currently save zoom 0.5 and camera x=0. Town Street saves a Cloudy Day Sky, one Far skyline, four Near artworks, and 1,400-world-unit Sidewalk and Asphalt runs. The buildings and trees do not share one visible base line, and their current Ground ends inside the opening view. Living Room saves a Clear Day Sky, three painted wall panels around a real window gap, seven 200-unit Wood Ground pieces, separate wall art and rug, and three functional Props. The floor run is short; the couch and armchair are visibly above its support line. + +`StageSceneIO` and `StageSceneAdapter` already persist and reload these records. `StageSpawner` creates ordinary terrain and Props. `TerrainBlock` remains the walkable surface; `StageBackgroundGround`, `StageWallPanel`, and `StageSceneryArtwork` remain inert Scenery. `StageSurfaceRenderer` already draws world-aligned surface motifs with viewport culling, and `StageRectSurface` caches wall patterns. These are existing contracts to reuse, not new abstractions. + +The source art's measured visual bottoms are useful placement references: Town house about 128 local units below its origin, storefront 119, tree 113; couch 111, armchair 107, coffee table 94. These bounds are starting measurements only. Final alignment uses rendered visible contact at Stage scale, not saved origins or selection AABBs alone. + +## Scene and rendering flow + +```mermaid +flowchart LR + FileNew[File → New → Town Street or Living Room] --> Read[StageSceneIO migrates v1.5 input to current v1.6] + Read --> Apply[StageSceneAdapter + StageSpawner create ordinary World children] + Apply --> Camera[Saved Camera2D position and zoom] + Camera --> Sky[Screen-space StageSkyView] + Camera --> World[World surfaces, scenery, Props, actors] + World --> Opening[Everyday Storybook opening composition] + Pan[Unrestricted camera pan] --> Camera + Extent[Authored 4,760-unit surfaces] --> World + Read --> Preview[StageStarterCardPreview] + Camera --> Preview + Preview --> Card[Opening-shot preview; ignores pan-only bounds] + Opening --> Save[Existing scene save/reopen path] + Save --> Read +``` + +The live Sky remains the screen-space background. World surfaces and objects keep their existing semantic draw order: Sky, Background ground, wall/Far scenery, Near scenery, walkable Ground and floor scenery, then action objects and actors. Preserve the existing Sky response and keep the chooser card's Sky unbound to the live camera. + +## Starter composition contracts + +### Town Street + +- Keep the Cloudy Day Sky, muted Far skyline, existing house/storefront/tree artwork, and normal Ground palette. Use the Far skyline and the placement/scale of existing Near art to imply more street while preserving a clear center. +- Align the visible bottoms of each building and tree to one sidewalk top line. Use source-art visible bounds to seed placement, then adjust by rendered inspection. At saved zoom 0.5, the final visible gap from an asset base to the sidewalk must be no more than 3 logical screen pixels. +- Give the initial camera frame a calm center lane at walking height: at least its middle third remains clear of Near artwork, leaving room for a standard standing stickman and a short speech bubble. +- Add an opaque `StageBackgroundGround` behind the skyline, near artwork, and walkable tiles. It begins at the skyline's lower visible datum and extends below the opening viewport, covering the clouds that otherwise show through the gap between the skyline and street. Use the existing Grass finish. It is visual only and does not create another walking line. +- Build Sidewalk as a horizontal row of 25 default-size Ground tiles, with centers from x=−2,304 to +2,304 and bounds x=−2,400…+2,400. Build Asphalt as a separate row of 27 default-size tiles, with one extra tile at either end and bounds x=−2,592…+2,592. Place Sidewalk at y=0 and Asphalt at y=32 so their edges meet and the Asphalt begins below the Sidewalk. The exposed 192-unit road ends provide a physically reachable 32-unit curb step; route navigation at those endpoints without merging the Ground blocks or colliders. Keep both rows flat and continuous; no single oversized block or deep scaled Asphalt piece. +- Keep skyline and Near artwork as separate editable placements. The Far skyline should not reveal a distracting cut edge within the pan envelope; reuse/reposition or duplicate existing skyline art only if its actual bounds require it. + +### Living Room + +- Keep the Clear Day Sky and the existing window frame and room artwork. Extend the painted wall from side to side across x=−2,380…+2,380, with a clean floor boundary and editable baseboard or equivalent edge. +- Build the wall as opaque editable panels around one deliberate window aperture. The opening is the only visible route to Sky. Extend the top and side panel bounds far enough to cover the full rendered viewport at pan endpoints; use slight opaque overlap at non-window joins to prevent subpixel Sky leaks. Do not overlap the aperture. +- Preserve the window as a real gap between wall panels with the existing transparent frame in front of the Sky. Do not add fake glass, collision, or a new wall-cutout behavior. +- Build `finish_ground_wood` as one horizontal row of 25 default-size Ground tiles. Centers are spaced 192 world units apart from x=−2,304 to +2,304 at y=200; at scale 1 the row's top is y=184 and its combined bounds are x=−2,400…+2,400. Keep one level collision top and the existing wood pattern scale; do not stretch one block across the room. +- Place the couch, armchair, coffee table, and floor art so visible supports/base edges sit at the wood floor line within 3 logical screen pixels at saved zoom. Preserve Prop roles and their normal collision/support. Keep the rug centered, visually above the wood, below actors, and outside the central movement lane. +- Keep the wall decorations separate and secondary to the window and action. No individual decoration or furniture piece may be merged into a backdrop image. + +### Shared camera and child readability + +- Retain saved zoom 0.5 unless rendered comparison shows the approved composition cannot fit. Save the chosen camera y/center in each scene so normal File → New opens directly to the intended shot. +- At the supported 1,280×720-logical-pixel reference viewport, test the saved center and camera x at −1,000 and +1,000, at zoom 0.5. At all three positions, the applicable sidewalk/road, wall/baseboard, and floor cover the full rendered viewport width with no panel or terrain end, accidental Sky seam, or distracting join. +- Tune vertical framing against the safe Stage area in a render-capable run: put the walking line about 70–80% down from the top of the usable Stage area. A standing stickman must fit from feet to head; a two-line speech bubble must remain readable with about 24 logical pixels of clearance from visible Stage chrome. +- A narrower viewport is covered by the same surfaces and must retain actor/bubble legibility. Do not assert the 1,280-pixel guarantee for a wider logical viewport without recalculating the extent formula. + +## Persistence and typed contracts + +No new API, signal, or serialized field is introduced. + +| Target | Existing contract and required update | +| --- | --- | +| `content/stages/starters/town_street.scene` | Write current v1.6 with the existing `sky_id`, camera, and ordinary object dictionaries. Add the Grass Background ground object, and use unique `file_id`s for each default-size Sidewalk (25) and Asphalt (27) terrain tile. Keep stable IDs for retained artwork and preserve the camera. | +| `content/stages/starters/living_room.scene` | Write current v1.6. Update wall panel `position`/`size`, use unique `file_id`s for each default-size Wood Ground tile, and preserve artwork/Prop transforms and camera. Keep the window aperture represented by the spatial gap between wall panels. | +| `StageSceneIO` (`class_name StageSceneIO`) | Read v1.5 legacy snapshots and write current v1.6 documents. Do not bump beyond v1.6 or add camera-envelope/preview fields. | +| `StageSceneAdapter` (`class_name StageSceneAdapter`) | Continue mapping `terrain`, `background_ground`, `wall_panel`, `scenery_artwork`, and `prop` dictionaries into their existing node classes. No starter-specific loader branch. | +| `SandboxStage` (`class_name SandboxStage extends Node2D`) and `Camera2D` | Load the saved camera normally. Do not clamp position to starter bounds. Coalesce adjacent collinear terrain top edges into continuous navigation ribbons so pathfinding crosses tile joints; connect Town's 32-unit curb step only where the lower Asphalt extends past the upper Sidewalk. Keep the terrain objects/colliders separate. Existing zoom limits and user input remain authoritative. | +| `StageStarterCardPreview` (`class_name StageStarterCardPreview extends SubViewportContainer`) | Replace the all-object-bounds fit as the preview focus rule. Keep its private preview setup and local `_world.scale`/`_world.position`; derive an opening-shot focus from scene `camera.position`/`camera.zoom` and a stable reference aspect. Ignore bounds added solely to cover the pan envelope. Do not mutate the loaded document or live camera. No public signal or caller change. | +| `TerrainBlock`, `StageBackgroundGround`, `StageWallPanel`, `StageSceneryArtwork`, `PropBlock`, `StageSurfaceRenderer`, `StageRectSurface` | Reuse existing typed configure/save/render/collision contracts. Modify one only if visual validation finds a concrete rendering, scaling, or hit-selection defect in the required bounds. | + +Representative existing object shapes (not new schema): + +```json +{"file_id":"ground_sidewalk_13","type":"terrain","asset_id":"ground","position":{"x":0,"y":0},"rotation":0,"scale":{"x":1,"y":1},"finish_id":"finish_ground_sidewalk"} +{"file_id":"ground_asphalt_13","type":"terrain","asset_id":"ground","position":{"x":0,"y":32},"rotation":0,"scale":{"x":1,"y":1},"finish_id":"finish_ground_asphalt"} +{"file_id":"town_backdrop","type":"background_ground","position":{"x":0,"y":Y},"size":{"x":4800,"y":H},"finish_id":"finish_background_grass","color":"#72A84C"} +{"file_id":"wall_left","type":"wall_panel","position":{"x":X,"y":Y},"rotation":0,"size":{"x":W,"y":H},"finish_id":"finish_wall_paint","color":"#5A9D70"} +``` + +`N`, `X`, `Y`, `W`, and `H` are scene composition values to tune from rendered visible bounds and camera framing. They do not denote new runtime fields. Prefer scene objects whose visible geometry meets at common edges rather than hiding incorrect positions with z-order. + +## Starter-card framing + +Today `_fit_scene_to_preview()` unions every loaded object's world AABB and fits that union into 256×256. The 4,800-unit rows of tiles or wall would dominate that union. The replacement should: + +1. Read the saved camera center and zoom from the already-loaded scene data. +2. Build a square preview-only focus centered on the saved camera position. Use the 16:9 reference viewport height and saved zoom for the square world extent: at 1,280×720 and zoom 0.5, the crop is 1,440×1,440 world units. This keeps the town's central buildings/trees or the room's window/furniture in the chooser card while ignoring long pan-only bounds. Fit the square crop into the existing 220×220 usable area of the 256×256 preview. +3. Fit that focus region inside the preview's usable bounds. Do not include pan-only surface tails when computing the frame; the live scene still renders those full surfaces. +4. Leave the Sky preview still and full-rect. Apply preview pan/scale only to the preview `_world` node; never modify saved camera data. +5. Inspect both cards at their actual chooser size. Each should show its identity and central composition without shrinking the actor/set to a tiny strip or clipping the window/road datum. + +Do not add per-starter preview bounds to `.scene` unless a shared, camera-derived crop demonstrably cannot keep both cards recognizable. That would require a separately approved data/authoring need. + +## User paths and observable acceptance + +| Child path / repeatable scenario | Required observation | +| --- | --- | +| File → New → Town Street | At the saved shot, the child sees a readable neighborhood, opaque land behind the skyline, continuous tiled sidewalk/road, a clear center lane, and the existing cloudy Sky above the skyline. Near artwork bases rest on the same walkable line within 3 screen pixels. | +| Town Street at saved zoom; pan to camera x=−1,000, 0, +1,000 | The full rendered width remains covered by sidewalk and Asphalt tile rows. Tile joints are intentional and edges have no collision or navigation gaps; no exposed row end or Sky/clouds show through below the skyline. A PLAY actor can walk off the sidewalk end and land on the exposed road, and navigation connects both walk lines there. The decorative backdrop does not support a stickman. | +| File → New → Living Room | The child sees a complete painted room with visible baseboard and wood floor. Sky appears through the window opening only. Furniture supports meet the floor within 3 screen pixels; rug is centered below actors, with an open action lane. | +| Living Room at saved zoom; pan to camera x=−1,000, 0, +1,000 | Wall, baseboard, and floor cover the full rendered width. No Sky leak appears at panel edges, under the floor, or outside the window. Furniture stays separate and the ground remains one support level. | +| Place a standard standing stickman and a two-line speech bubble in the central lane; inspect EDIT and PLAY | Feet contact the intended surface. Head, body, and bubble are legible with the specified chrome clearance. PLAY preserves Ground support and existing Prop behavior; no decorative Background ground or wall panel gains collision. | +| Open New Scene chooser; inspect Town Street and Living Room cards | Each card remains a recognizable opening-shot preview after the live-world pan surfaces are lengthened. The camera framing of the preview does not move the saved/live scene. | +| Save each revised starter as an ordinary scene, close/reopen it | Existing object types, finish IDs, individual placements, roles, Sky ID, and saved camera framing round-trip through the current scene v1.6 format. The loaded scene remains editable and has no starter-only runtime behavior. | +| Resize to a narrower Stage viewport; zoom in for a close shot | Continuous surfaces still cover the visible width. The action lane and window remain legible. This pass does not promise full-width coverage for zoom-out views wider than the reference envelope. | + +Phase 2 must inspect rendered captures from a render-capable Godot Stage at opening frame, both pan endpoints, PLAY, both starter cards, and a narrower viewport; record the capture paths and observed pixel gaps/seams. Exercise actual camera pan, save/reopen, Ground support and navigation across tile joints, and normal selection/edit routes. Bake adjacent collinear tile tops into continuous navigation ribbons and connect the exposed Town curb step without merging separate terrain objects or colliders. A successful import or headless parse alone does not verify composition. If an interactive rendering/input route is unavailable after trying it, list each unverified outcome and provide a focused user check. + +### Implementation and verification record (2026-10-21) + +- The initial pass promoted both snapshots to v1.6 and aligned visible bases, but used oversized scaled walkable blocks and omitted Town Background ground. The user identified those issues; that surface implementation was superseded by the tile/backdrop revision below. +- `StageStarterCardPreview` now derives a square crop from the saved camera position and zoom. At zoom 0.5, it frames a 1,440 × 1,440 world-unit opening crop in the existing 220 × 220 usable preview area. +- `/Applications/Godot.app/Contents/MacOS/Godot --path /Users/ryan/Projects/stickman --headless --rendering-method gl_compatibility --script res://tests/test_phase19_starter_scene_composition.gd` passed **515 assertions** on Godot 4.7.1. It covers production scene loading, save/reopen, surface bounds, window opening, preview transforms, base contacts, PLAY support, navigation across each tile row, and sidewalk-to-road access. Town Asphalt has 27 scale-1 tiles spanning 5,184 units; a PLAY actor moved from x=2,280 to x=2,500 and landed grounded on the road at feet y=15.31. The navigation query from center to the opposite walk line follows the exposed right curb through `(2400, -4)` and `(2400, 12)`. Measured contact gaps at the saved zoom were 0.0 logical px for Town artwork; 0.0–0.123 px for Living Room floor artwork; and 0.025–0.225 px for the three Props. These are production-loaded geometry measurements, not pixel samples from rendered captures. +- `/Applications/Godot.app/Contents/MacOS/Godot --path /Users/ryan/Projects/stickman --headless --rendering-method gl_compatibility --script res://tests/test_phase19_scenery.gd` exits 1. The Town/Living starter surface, count, scale, bounds, seam, pan-margin, and camera assertions pass; the nine remaining failures concern the Sky/surface preview card count, a wall-erasure document-change signal, and Edit Scenery erase/Cancel capture-bar behavior. These same failures were present before the latest tile and curb changes. The environment also reports a Stage autosave warning during that run. +- Rendered-pixel inspection could not be completed here: a bounded GUI capture attempt exited 134 without output or an image, and an earlier headless compatibility capture produced no image before it was stopped after 30 seconds. The default headless Vulkan test path also crashed in shader translation. The focused compatibility test exited successfully; the environment printed a macOS CA-certificate lookup warning. +- Remaining user/editor checks: opening frames, ±1,000 camera pan endpoints, the Town background and tile appearance, both starter cards, a narrower viewport, dramatic zoom-in, and rendered seams/Sky inspection. User-supplied Town Street imagery exposed the missing background; this revised pass still needs a fresh rendered visual review. Automated geometry and PLAY checks cover support across tile seams and the curb step. + +### User visual follow-up (2026-10-21) + +The user's Town Street capture showed Sky clouds visible below the skyline because the initial implementation omitted Background ground. The user also rejected the oversized scaled TerrainBlocks used for walkable surfaces and requested left-to-right rows of ordinary Ground tiles in both templates. The current revision adds opaque non-walkable Grass Background ground behind Town Street and uses adjacent scale-1 tiles for Sidewalk, Asphalt, and Living Room Wood. Sidewalk uses 25 tiles; Asphalt uses 27, extending one tile beyond each sidewalk end so the road is physically reachable. The focused production-path test passed 515 assertions, including seam routing and a grounded PLAY step onto Asphalt. Fresh rendered review and user/editor checks remain pending. + +## Trade-offs and deferred work + +- Ordinary-scale tile rows avoid oversized Ground selection bounds and keep each walkable piece independently editable. Their joints should remain intentional, with adjacent collision and visible edges meeting without gaps. The navigation bake merges contiguous coplanar tile tops for routing while the Ground pieces remain separate, and connects the Town curb step only at exposed road ends. +- The 4,800-unit sidewalk/floor rows of ordinary-size Ground tiles cover the specified 4,760-unit minimum envelope and remain individually editable. Town Asphalt extends to 5,184 units for curb access. These finite surfaces do not track arbitrary viewport resizing, very wide zoom-out shots, or unbounded camera travel. The camera remains free; no edge clamp or auto-extension obscures that finite set boundary. +- Preview framing is derived from the saved opening camera instead of saved preview metadata. This avoids schema/round-trip work but gives every card the same generic crop policy; if that cannot show a particular starter clearly, document the concrete limitation before proposing metadata. +- No new art is required based on current library bounds. A curb/baseboard visual gap that cannot be composed from existing terrain finishes and wall panels should be described as one specific missing asset and sent through the existing asset-approval workflow. +- Stage-wide Undo/Redo remains deferred. Starter polishing edits saved object data through existing flows and adds no new transaction system. diff --git a/docs/specs/phase_19_starter_scene_composition_ui_ux.md b/docs/specs/phase_19_starter_scene_composition_ui_ux.md new file mode 100644 index 0000000..484a1af --- /dev/null +++ b/docs/specs/phase_19_starter_scene_composition_ui_ux.md @@ -0,0 +1,59 @@ +# Phase 19 — Starter Scene Composition UI/UX Specification + +> **Status:** Approved UI/UX specification. The separate architecture specification is approved for implementation. This defines scene presentation, not new controls. +> +> **Source:** [Phase 19 — Starter Scene Composition brief](../plans/phase_19_starter_scene_composition.md). + +## User path + +From **File → New**, the child chooses **Town Street** or **Living Room** and immediately sees a coherent set with a clear walkable area. At the saved camera frame, a standing stickman and a short speech bubble fit comfortably in the action area. The same visual relationships remain legible in EDIT, DIRECT, PLAY, and after save/reopen. A deliberate horizontal pan reveals more of the same set. + +## Composition rules + +### Town Street + +- Keep the Cloudy Day Sky and a subdued Far skyline behind individual Near buildings and trees. +- Add an opaque, non-walkable Background ground layer below the skyline datum. It must cover the lower opening view behind the street and prevent Sky clouds from showing through the gap between the skyline and the walkable surface. +- Align all visible house, storefront, and tree bases to one sidewalk datum. Keep seams at or below 3 logical screen pixels at saved zoom. +- Carry the Sidewalk and Asphalt across the usable stage frame as horizontal rows of ordinary-size, walkable Ground tiles. Keep their tops level and their edges adjacent so the tile joints are intentional, with no collision or visual gaps. +- Leave at least the middle third of the initial frame visually calm at walking height. Near artwork may frame the action, but should not cover a character or speech placed in this area. +- Keep Background ground behind the skyline, artwork, and terrain; distinguish it from the walkable sidewalk/road. It does not create another walking line. + +### Living Room + +- Continue the painted wall and a horizontal row of ordinary-size, walkable Wood Ground tiles across the initial frame and agreed pan envelope. Keep adjacent tile edges level, with intentional joints and no collision gaps. A visible baseboard or equivalent edge marks where wall meets floor. +- Construct the window as a deliberate opening between editable wall pieces. The selected Sky is visible through that opening only; no Sky gaps appear at the room edges, below the floor, or along panel seams. +- Place the couch, armchair, and coffee table so their visible supports meet the floor within 3 logical screen pixels at saved zoom. Keep the rug centered, above the floor finish, below actors, and outside the main movement lane. +- Keep the middle third of the frame at walking height visually calm enough for a stickman and short speech bubble. Wall decoration stays secondary to the window and action. + +## Camera framing and pan continuity + +- Preserve the existing starter-choice flow and use a saved camera framing that shows the complete scene identity, walking datum, and clear action area at the actual safe Stage viewport size. +- Place the walking line around 70–80% of usable viewport height measured from the top. Show a default standing stickman from feet to head and leave about 24 logical pixels between a two-line speech bubble and visible Stage chrome. +- Define a horizontal pan envelope for each starter. At the saved zoom, the appropriate continuous surfaces must cover the full rendered viewport width at the envelope's left and right limits, with a small safety margin. Camera panning remains unrestricted beyond this authored envelope; finite set edges may appear there. +- Compute surface width from the reference viewport: `rendered_viewport_width / saved_zoom + 2 * pan_travel + 2 * safety_margin`. For a 1,280-pixel reference width, zoom 0.5, ±1,000-world-unit travel, and 100 world units of margin per side, surfaces need at least 4,760 world units. Use 25 standard 192-unit Ground tiles for the Town sidewalk and Living Room floor. Town Asphalt uses 27 standard tiles, extending one tile beyond each sidewalk end so the road is physically reachable. Do not scale one walkable block across the scene. +- Keep set parts independently editable. Tile rows use ordinary-scale pieces with adjacent edges, no collision gaps, and visible joints that read as regular surface tiles. +- Do not stretch one walkable Ground block across the full pan envelope. Use a left-to-right line of default-size terrain tiles so each surface remains an ordinary, individually editable piece. + +## Starter chooser preview + +The starter card should show a representative opening composition, not fit the full pan-only extent. The current preview fits the combined bounds of every object; extending the ground and walls could make the important scene too small. Bound preview framing around the saved opening shot (or an explicit authored preview frame) while still showing the Sky, walking datum, and recognizable key artwork. This is a preview-only framing decision: it must not crop or constrain the loaded scene. + +## Controls, copy, and theme + +- Add no controls, filters, dialogs, inspectors, cursor modes, placement overlays, or status messages. +- Keep the existing **Blank Scene**, **Town Street**, and **Living Room** choice labels. +- Add no `SandboxTheme` colors or `stage_panels` tokens. Use the existing Sky, wall, Ground, Scenery, and Prop visual styles; tune scene-art contrast so a character and speech remain easy to read. +- Reuse the existing EDIT, DIRECT, and PLAY mode treatments without adding mode-specific starter behavior. + +## Observable UI/UX acceptance outcomes + +1. Opening either starter at its saved frame reveals the intended place, a single readable walking datum, a calm central action area, and room for a default stickman plus short speech. +2. At saved zoom, there is no visible (>3 logical screen-pixel) gap between Near artwork and the street datum or between furniture supports and the room floor. +3. At the center and both ends of the specified pan envelope, the sidewalk/road or wall/baseboard/floor fills the usable Stage width without exposed ends, distracting seams, or stray Sky. In Living Room, Sky appears through the window opening only. +4. The chooser card remains a recognizable view of the opening composition after the world surfaces are extended for panning. +5. The same composition and visual ordering remain in EDIT, DIRECT, PLAY, and after save/reopen. Normal child edits can still move or remove individual pieces. + +## Not applicable + +There is no new interaction flow, so UI node/layout additions, focus behavior, cursor feedback, child-facing copy changes, dialogs, and inspector-token changes do not apply. Architecture will identify only the scene data, preview framing, or rendering changes needed to meet the observable outcomes. diff --git a/scripts/editors/stage/sandbox_stage.gd b/scripts/editors/stage/sandbox_stage.gd index 11eba1f..869a542 100644 --- a/scripts/editors/stage/sandbox_stage.gd +++ b/scripts/editors/stage/sandbox_stage.gd @@ -2660,8 +2660,6 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) _reset_scene_content() var adapter_result: Dictionary = _scene_adapter.apply(data, _world, _spawner) _sky_id = StringName(String(adapter_result.get("sky_id", "sky_neutral"))) - if _sky_view != null: - _sky_view.set_sky(_sky_id) var loaded_nodes: Array[Node2D] = adapter_result.get("loaded_nodes", []) for node: Node2D in loaded_nodes: _finalize_placed_node(node) @@ -2685,6 +2683,8 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) var camera_data: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {} _camera.global_position = STAGE_SCENE_IO.data_to_vector(camera_data.get("position"), DEFAULT_CAMERA_POSITION) _camera.zoom = Vector2.ONE * clampf(float(camera_data.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom) + if _sky_view != null: + _sky_view.set_sky(_sky_id) var raw_metadata: Variant = data.get("metadata", {}) var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {} if _document != null: @@ -4550,6 +4550,7 @@ func _build_sky_layer() -> void: _sky_view.set_sky(_sky_id) _sky_layer.add_child(_sky_view) add_child(_sky_layer) + _sky_view.bind_camera(_camera) func _build_placement_overlay() -> void: @@ -4591,29 +4592,47 @@ func _rebake_navigation() -> void: if block == null: continue var pts := PackedVector2Array() + var block_top_y: float = INF for p: Vector2 in block.polygon_points: - pts.append(block.transform * p) + var world_point: Vector2 = block.transform * p + pts.append(world_point) + block_top_y = minf(block_top_y, world_point.y) for i: int in pts.size(): var a := pts[i] var b := pts[(i + 1) % pts.size()] var edge := b - a if edge.length_squared() <= 0.001: continue + if absf(a.y - b.y) <= 0.001: + if is_equal_approx(a.y, block_top_y): + surfaces.append({"a": a, "b": b, "normal": Vector2.UP}) + continue # TerrainUtils stores clockwise polygons; this right normal is - # outward, so only its upward-facing edges are walkable. + # outward on sloped edges, so only upward-facing slopes are walkable. var outward := Vector2(edge.y, -edge.x).normalized() if outward.y > -0.15: continue surfaces.append({"a": a, "b": b, "normal": outward}) - for surface: Dictionary in surfaces: + surfaces = _merge_contiguous_horizontal_navigation_surfaces(surfaces) + var endpoint_steps: Array[Dictionary] = _find_navigation_endpoint_steps(surfaces) + var ribbon_surfaces: Array[Dictionary] = _split_navigation_surfaces_at_step_edges(surfaces, endpoint_steps) + for surface: Dictionary in ribbon_surfaces: _append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"]) + for endpoint_step: Dictionary in endpoint_steps: + _append_navigation_endpoint_step_ribbon( + verts, + triangles, + endpoint_step["upper_point"], + endpoint_step["lower_y"], + endpoint_step["outward_normal"] + ) # Join only short, climbable gaps between exposed surface ends. This connects # authored stair treads without making cliffs or terrain interiors walkable. for i: int in surfaces.size(): for j: int in range(i + 1, surfaces.size()): for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]: for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]: - if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0: + if absf(a.x - b.x) > 0.001 and absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0: _append_navigation_ribbon(verts, triangles, a, b, Vector2.UP) var poly := NavigationPolygon.new() poly.set_vertices(verts) @@ -4624,6 +4643,161 @@ func _rebake_navigation() -> void: print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()]) +func _find_navigation_endpoint_steps(surfaces: Array[Dictionary]) -> Array[Dictionary]: + var endpoint_steps: Array[Dictionary] = [] + var horizontal: Array[Dictionary] = [] + for surface: Dictionary in surfaces: + var a: Vector2 = surface.get("a", Vector2.ZERO) + var b: Vector2 = surface.get("b", Vector2.ZERO) + var normal: Vector2 = surface.get("normal", Vector2.ZERO) + if absf(a.y - b.y) <= 0.001 and normal.is_equal_approx(Vector2.UP): + horizontal.append(surface) + for upper: Dictionary in horizontal: + var upper_a: Vector2 = upper["a"] + var upper_b: Vector2 = upper["b"] + for lower: Dictionary in horizontal: + var lower_a: Vector2 = lower["a"] + var lower_b: Vector2 = lower["b"] + var vertical_step: float = lower_a.y - upper_a.y + if vertical_step <= 0.001 or vertical_step > 96.0: + continue + # A lower walk surface can join an upper row only where it extends + # past that row's end. The vertical nav strip then represents the + # physically exposed step/drop beside the upper terrain collider. + if lower_a.x < upper_a.x - 0.01 and lower_b.x >= upper_a.x - 0.01: + endpoint_steps.append({ + "upper_point": upper_a, + "lower_y": lower_a.y, + "outward_normal": Vector2.LEFT, + }) + if lower_b.x > upper_b.x + 0.01 and lower_a.x <= upper_b.x + 0.01: + endpoint_steps.append({ + "upper_point": upper_b, + "lower_y": lower_a.y, + "outward_normal": Vector2.RIGHT, + }) + return endpoint_steps + + +func _split_navigation_surfaces_at_step_edges( + surfaces: Array[Dictionary], + endpoint_steps: Array[Dictionary] +) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for surface: Dictionary in surfaces: + var a: Vector2 = surface.get("a", Vector2.ZERO) + var b: Vector2 = surface.get("b", Vector2.ZERO) + var normal: Vector2 = surface.get("normal", Vector2.ZERO) + if absf(a.y - b.y) > 0.001 or not normal.is_equal_approx(Vector2.UP): + result.append(surface) + continue + var split_x: Array[float] = [a.x, b.x] + for endpoint_step: Dictionary in endpoint_steps: + if not is_equal_approx(float(endpoint_step["lower_y"]), a.y): + continue + var upper_point: Vector2 = endpoint_step["upper_point"] + var outward_normal: Vector2 = endpoint_step["outward_normal"] + var outer_x: float = upper_point.x + outward_normal.x * 16.0 + if upper_point.x > a.x and upper_point.x < b.x: + split_x.append(upper_point.x) + if outer_x > a.x and outer_x < b.x: + split_x.append(outer_x) + split_x.sort() + var unique_split_x: Array[float] = [] + for split: float in split_x: + if unique_split_x.is_empty() or not is_equal_approx(split, unique_split_x[unique_split_x.size() - 1]): + unique_split_x.append(split) + for split_index: int in range(unique_split_x.size() - 1): + var segment_start: float = unique_split_x[split_index] + var segment_end: float = unique_split_x[split_index + 1] + if segment_end - segment_start <= 0.001: + continue + result.append({ + "a": Vector2(segment_start, a.y), + "b": Vector2(segment_end, a.y), + "normal": Vector2.UP, + }) + return result + + +func _append_navigation_endpoint_step_ribbon( + verts: PackedVector2Array, + triangles: Array[PackedInt32Array], + upper_point: Vector2, + lower_y: float, + outward_normal: Vector2 +) -> void: + var inner_x: float = upper_point.x + var outer_x: float = inner_x + outward_normal.x * 16.0 + var upper_nav_top_y: float = upper_point.y - 4.0 + var upper_nav_bottom_y: float = upper_point.y + 12.0 + var lower_nav_top_y: float = lower_y - 4.0 + var base: int = verts.size() + if outward_normal.x < 0.0: + verts.append_array(PackedVector2Array([ + Vector2(outer_x, upper_nav_top_y), + Vector2(inner_x, upper_nav_top_y), + Vector2(inner_x, upper_nav_bottom_y), + Vector2(inner_x, lower_nav_top_y), + Vector2(outer_x, lower_nav_top_y), + ])) + triangles.append(PackedInt32Array([base, base + 1, base + 2])) + triangles.append(PackedInt32Array([base, base + 2, base + 3])) + triangles.append(PackedInt32Array([base, base + 3, base + 4])) + else: + verts.append_array(PackedVector2Array([ + Vector2(inner_x, upper_nav_top_y), + Vector2(outer_x, upper_nav_top_y), + Vector2(outer_x, lower_nav_top_y), + Vector2(inner_x, lower_nav_top_y), + Vector2(inner_x, upper_nav_bottom_y), + ])) + triangles.append(PackedInt32Array([base, base + 1, base + 2])) + triangles.append(PackedInt32Array([base, base + 2, base + 4])) + triangles.append(PackedInt32Array([base + 2, base + 3, base + 4])) + + +func _merge_contiguous_horizontal_navigation_surfaces(surfaces: Array[Dictionary]) -> Array[Dictionary]: + var horizontal: Array[Dictionary] = [] + var other: Array[Dictionary] = [] + for surface: Dictionary in surfaces: + var a: Vector2 = surface.get("a", Vector2.ZERO) + var b: Vector2 = surface.get("b", Vector2.ZERO) + var normal: Vector2 = surface.get("normal", Vector2.ZERO) + if absf(a.y - b.y) > 0.001 or not normal.is_equal_approx(Vector2.UP): + other.append(surface) + continue + horizontal.append({ + "a": Vector2(minf(a.x, b.x), a.y), + "b": Vector2(maxf(a.x, b.x), a.y), + "normal": Vector2.UP, + }) + horizontal.sort_custom(_navigation_surface_precedes) + var merged: Array[Dictionary] = [] + for surface: Dictionary in horizontal: + if not merged.is_empty(): + var previous_index: int = merged.size() - 1 + var previous: Dictionary = merged[previous_index] + var previous_a: Vector2 = previous["a"] + var previous_b: Vector2 = previous["b"] + var current_a: Vector2 = surface["a"] + if is_equal_approx(previous_a.y, current_a.y) and absf(previous_b.x - current_a.x) <= 0.01: + previous["b"] = surface["b"] + merged[previous_index] = previous + continue + merged.append(surface) + merged.append_array(other) + return merged + + +func _navigation_surface_precedes(left: Dictionary, right: Dictionary) -> bool: + var left_a: Vector2 = left.get("a", Vector2.ZERO) + var right_a: Vector2 = right.get("a", Vector2.ZERO) + if not is_equal_approx(left_a.y, right_a.y): + return left_a.y < right_a.y + return left_a.x < right_a.x + + func _append_navigation_ribbon( verts: PackedVector2Array, triangles: Array[PackedInt32Array], diff --git a/scripts/stage/scenery/stage_sky_view.gd b/scripts/stage/scenery/stage_sky_view.gd index 85bf90a..d010944 100644 --- a/scripts/stage/scenery/stage_sky_view.gd +++ b/scripts/stage/scenery/stage_sky_view.gd @@ -5,27 +5,54 @@ extends Control const SKY_IDS: Array[StringName] = [&"sky_clear_day", &"sky_cloudy_day", &"sky_sunset", &"sky_starry_night", &"sky_neutral"] const GRADIENT_TEXTURE_HEIGHT: int = 2048 const CLOUD_VIEWBOX: Vector2 = Vector2(210.0, 110.0) +const BASE_VIEWPORT_SIZE: Vector2 = Vector2(800.0, 480.0) +const REF_POSITION: Vector2 = Vector2(0.0, -400.0) +const REF_ZOOM: float = 0.5 const NIGHT_STAR_COLOR: Color = Color("#FFF4CE") +const CAMERA_RESPONSE_PROFILES: Dictionary = { + &"sky_clear_day": {"pan_limits": Vector2(0.025, 0.015), "pan_spans": Vector2(0.8, 0.8), "zoom_gain": 0.10, "zoom_span": 1.2}, + &"sky_cloudy_day": {"pan_limits": Vector2(0.020, 0.012), "pan_spans": Vector2(0.85, 0.85), "zoom_gain": 0.075, "zoom_span": 1.2}, + &"sky_sunset": {"pan_limits": Vector2(0.030, 0.018), "pan_spans": Vector2(0.9, 0.9), "zoom_gain": 0.11, "zoom_span": 1.2}, + &"sky_starry_night": {"pan_limits": Vector2(0.035, 0.025), "pan_spans": Vector2(1.0, 1.0), "zoom_gain": 0.12, "zoom_span": 1.2}, + &"sky_neutral": {"pan_limits": Vector2.ZERO, "pan_spans": Vector2.ONE, "zoom_gain": 0.0, "zoom_span": 1.0}, +} var _sky_id: StringName = &"sky_neutral" var _stage_theme: SandboxTheme var _gradient_texture: GradientTexture2D +var _bound_camera: Camera2D +var _motif_shift: Vector2 = Vector2.ZERO +var _motif_scale: float = 1.0 +var _cached_cloud_outline: PackedVector2Array = PackedVector2Array() +var _cached_cloud_underside: PackedVector2Array = PackedVector2Array() +var _cached_cloud_accents: Array[PackedVector2Array] = [] +var _cached_moon_outline: PackedVector2Array = PackedVector2Array() +var _cached_moon_outer_arc: PackedVector2Array = PackedVector2Array() +var _cached_moon_warm_rim: PackedVector2Array = PackedVector2Array() +var _cached_moon_highlight: PackedVector2Array = PackedVector2Array() func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR + _cache_motif_geometry() _update_gradient_texture() queue_redraw() +func bind_camera(camera: Camera2D) -> void: + _bound_camera = camera + set_process(camera != null) + _refresh_camera_response(true) + + func set_sky(id: StringName) -> void: var next_sky_id: StringName = id if SKY_IDS.has(id) else &"sky_neutral" - if _sky_id == next_sky_id: - return + var changed: bool = _sky_id != next_sky_id _sky_id = next_sky_id - _update_gradient_texture() - queue_redraw() + if changed: + _update_gradient_texture() + _refresh_camera_response(true) func get_sky() -> StringName: @@ -39,9 +66,73 @@ func apply_theme(theme: SandboxTheme) -> void: func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED: + _refresh_camera_response(true) queue_redraw() +func _process(_delta: float) -> void: + _refresh_camera_response(false) + + +static func compute_camera_response(sky_id: StringName, camera_position: Vector2, camera_zoom: float, viewport_size: Vector2) -> Dictionary: + var fallback: Dictionary = {"shift": Vector2.ZERO, "scale": 1.0} + if not camera_position.is_finite() or not viewport_size.is_finite() or viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return fallback + if not is_finite(camera_zoom) or camera_zoom <= 0.0: + return fallback + var profile_value: Variant = CAMERA_RESPONSE_PROFILES.get(sky_id, CAMERA_RESPONSE_PROFILES[&"sky_neutral"]) + var profile: Dictionary = profile_value as Dictionary + var pan_limits: Vector2 = profile.get("pan_limits", Vector2.ZERO) + var pan_spans: Vector2 = profile.get("pan_spans", Vector2.ONE) + var zoom_gain: float = float(profile.get("zoom_gain", 0.0)) + var zoom_span: float = maxf(float(profile.get("zoom_span", 1.0)), 0.001) + var reference_frame_delta: Vector2 = (camera_position - REF_POSITION) * REF_ZOOM + var normalized_pan: Vector2 = reference_frame_delta / viewport_size + var shift: Vector2 = Vector2( + -viewport_size.x * pan_limits.x * tanh(normalized_pan.x / maxf(pan_spans.x, 0.001)), + -viewport_size.y * pan_limits.y * tanh(normalized_pan.y / maxf(pan_spans.y, 0.001)) + ) + var log_zoom: float = log(camera_zoom / REF_ZOOM) + var motif_scale: float = exp(zoom_gain * tanh(log_zoom / zoom_span)) + return {"shift": shift, "scale": motif_scale} + + +static func star_anchor_for_index(index: int) -> Vector2: + return Vector2( + fposmod(float(index) * 0.61803398875 + 0.13, 1.0), + fposmod(float(index) * 0.41421356237 + 0.08, 0.62) + ) + + +func _refresh_camera_response(force_redraw: bool) -> void: + var next_shift: Vector2 = Vector2.ZERO + var next_scale: float = 1.0 + if _bound_camera != null and is_instance_valid(_bound_camera): + var camera_position: Vector2 = _bound_camera.global_position + var camera_zoom: Vector2 = _bound_camera.zoom + if camera_position.is_finite() and camera_zoom.is_finite() and camera_zoom.x > 0.0 and camera_zoom.y > 0.0 and is_equal_approx(camera_zoom.x, camera_zoom.y): + var response: Dictionary = compute_camera_response(_sky_id, camera_position, camera_zoom.x, size) + next_shift = response.get("shift", Vector2.ZERO) + next_scale = float(response.get("scale", 1.0)) + var changed: bool = not _motif_shift.is_equal_approx(next_shift) or not is_equal_approx(_motif_scale, next_scale) + _motif_shift = next_shift + _motif_scale = next_scale + if force_redraw or changed: + queue_redraw() + + +func _cache_motif_geometry() -> void: + _cached_cloud_outline = _build_cloud_outline() + _cached_cloud_underside = _build_cloud_underside() + _cached_cloud_accents.clear() + for accent_curve: PackedVector2Array in _build_cloud_accents(): + _cached_cloud_accents.append(_sample_curve_chain([accent_curve], 12)) + _cached_moon_outline = _sample_curve_chain(_build_moon_curves(), 16) + _cached_moon_outer_arc = _sample_curve_chain(_build_moon_outer_arc_curves(), 16) + _cached_moon_warm_rim = _build_moon_warm_rim(_cached_moon_outer_arc, 7.5) + _cached_moon_highlight = _sample_curve_chain(_build_moon_highlight(), 18) + + func _draw() -> void: if size.x <= 0.0 or size.y <= 0.0: return @@ -90,8 +181,9 @@ func _update_gradient_texture() -> void: func _draw_sun() -> void: - var center: Vector2 = Vector2(size.x * 0.78125, size.y * (0.241 if _sky_id == &"sky_sunset" else 0.242)) - var radius: float = size.y * (0.0979 if _sky_id == &"sky_sunset" else 0.0792) + var center_anchor: Vector2 = Vector2(0.72, 0.241 if _sky_id == &"sky_sunset" else 0.242) + var center: Vector2 = _motif_anchor(center_anchor) + var radius: float = _base_motif_scale() * _motif_scale * (47.0 if _sky_id == &"sky_sunset" else 38.0) var halo_color: Color = Color("#FFE1A2") if _sky_id == &"sky_sunset" else Color("#FFF1B3") var sun_color: Color = Color("#FFE296") if _sky_id == &"sky_sunset" else Color("#FFE990") draw_circle(center, radius * 1.5, Color(halo_color.r, halo_color.g, halo_color.b, 0.14)) @@ -101,87 +193,87 @@ func _draw_sun() -> void: func _draw_stars() -> void: for index: int in 48: - var x: float = fposmod(float(index * 137 + 39), size.x) - var y: float = fposmod(float(index * 83 + 21), size.y * 0.62) - draw_circle(Vector2(x, y), 1.3 if index % 4 != 0 else 2.0, NIGHT_STAR_COLOR) + var anchor: Vector2 = star_anchor_for_index(index) + var center: Vector2 = _motif_anchor(anchor) + var radius: float = _base_motif_scale() * _motif_scale * (1.3 if index % 4 != 0 else 2.0) + draw_circle(center, radius, NIGHT_STAR_COLOR) for star_index: int in 3: - var center: Vector2 = Vector2(size.x * [0.3675, 0.9425, 0.22125][star_index], size.y * [0.085, 0.229, 0.348][star_index]) - var radius: float = maxf(1.3, size.y / 480.0 * 7.0) + var anchors: Array[Vector2] = [Vector2(0.3675, 0.085), Vector2(0.9425, 0.229), Vector2(0.22125, 0.348)] + var center: Vector2 = _motif_anchor(anchors[star_index]) + var radius: float = _base_motif_scale() * _motif_scale * 7.0 draw_line(center - Vector2(0.0, radius), center + Vector2(0.0, radius), Color("#FFEABB"), maxf(1.0, radius * 0.22), true) draw_line(center - Vector2(radius, 0.0), center + Vector2(radius, 0.0), Color("#FFEABB"), maxf(1.0, radius * 0.22), true) func _draw_clouds() -> void: - var origins: Array[Vector4] = [] + var origins: Array[Vector3] = [] var opacities: Array[float] = [] var outer_color: Color var underside_color: Color var accent_color: Color match _sky_id: &"sky_clear_day": - origins = [Vector4(42.0, 66.0, 205.0, 108.0), Vector4(282.0, 167.0, 244.0, 127.0), Vector4(612.0, 288.0, 187.0, 98.0)] + origins = [Vector3(0.18, 0.17, 200.0), Vector3(0.51, 0.33, 220.0), Vector3(0.88, 0.48, 174.0)] opacities = [0.93, 0.88, 0.8] outer_color = Color("#FFFFFF") underside_color = Color("#CBE6FF", 0.72) accent_color = Color("#D9EEFF", 0.86) &"sky_cloudy_day": - origins = [Vector4(-16.0, 47.0, 246.0, 129.0), Vector4(188.0, 145.0, 242.0, 127.0), Vector4(508.0, 64.0, 281.0, 147.0), Vector4(74.0, 302.0, 190.0, 100.0), Vector4(535.0, 324.0, 231.0, 121.0)] + origins = [Vector3(0.14, 0.17, 205.0), Vector3(0.48, 0.28, 208.0), Vector3(0.83, 0.17, 226.0), Vector3(0.28, 0.45, 170.0), Vector3(0.78, 0.47, 194.0)] opacities = [1.0, 0.93, 0.95, 0.7, 0.69] outer_color = Color("#F8FCFF") underside_color = Color("#BED7EF", 0.9) accent_color = Color("#D0E5F7", 1.0) &"sky_sunset": - origins = [Vector4(24.0, 82.0, 235.0, 123.0), Vector4(294.0, 244.0, 258.0, 135.0), Vector4(585.0, 310.0, 218.0, 114.0)] + origins = [Vector3(0.18, 0.20, 210.0), Vector3(0.55, 0.37, 226.0), Vector3(0.88, 0.50, 192.0)] opacities = [0.85, 0.9, 0.81] outer_color = Color("#FFE3D1") underside_color = Color("#E9A8BA", 0.8) accent_color = Color("#FFF2D7", 0.8) _: - origins = [Vector4(-4.0, 331.0, 231.0, 121.0), Vector4(314.0, 380.0, 188.0, 98.0)] + origins = [Vector3(0.16, 0.55, 220.0), Vector3(0.68, 0.62, 178.0)] opacities = [0.28, 0.23] outer_color = Color("#A9BCE1") underside_color = Color("#7898C5", 0.8) accent_color = Color("#D7E3F7", 0.45) - var viewport_scale: Vector2 = Vector2(size.x / 800.0, size.y / 480.0) for cloud_index: int in origins.size(): - var placement: Vector4 = origins[cloud_index] - var cloud_rect: Rect2 = Rect2( - Vector2(placement.x, placement.y) * viewport_scale, - Vector2(placement.z, placement.w) * viewport_scale - ) - _draw_cloud(cloud_rect, outer_color, underside_color, accent_color, opacities[cloud_index]) + var placement: Vector3 = origins[cloud_index] + _draw_cloud(Vector2(placement.x, placement.y), placement.z, outer_color, underside_color, accent_color, opacities[cloud_index]) -func _draw_cloud(rect: Rect2, outer_color: Color, underside_color: Color, accent_color: Color, opacity: float) -> void: - var outline: PackedVector2Array = _map_cloud_points(_build_cloud_outline(), rect) +func _draw_cloud(anchor: Vector2, authored_width: float, outer_color: Color, underside_color: Color, accent_color: Color, opacity: float) -> void: + var cloud_scale: float = _base_motif_scale() * _motif_scale * authored_width / CLOUD_VIEWBOX.x + var center: Vector2 = _motif_anchor(anchor) + var outline: PackedVector2Array = _map_cloud_points(_cached_cloud_outline, center, cloud_scale) var cloud_color: Color = _with_opacity(outer_color, opacity) draw_colored_polygon(outline, cloud_color) - draw_polyline(outline, cloud_color, maxf(1.0, rect.size.y / CLOUD_VIEWBOX.y * 1.5), true) - var underside: PackedVector2Array = _map_cloud_points(_build_cloud_underside(), rect) + draw_polyline(outline, cloud_color, maxf(1.0, cloud_scale * 1.5), true) + var underside: PackedVector2Array = _map_cloud_points(_cached_cloud_underside, center, cloud_scale) draw_colored_polygon(underside, _with_opacity(underside_color, opacity)) - for accent_curve: PackedVector2Array in _build_cloud_accents(): - var accent_points: PackedVector2Array = _map_cloud_points(_sample_curve_chain([accent_curve], 12), rect) - draw_polyline(accent_points, _with_opacity(accent_color, opacity), maxf(1.0, rect.size.y / CLOUD_VIEWBOX.y * 3.0), true) + for accent_points_local: PackedVector2Array in _cached_cloud_accents: + var accent_points: PackedVector2Array = _map_cloud_points(accent_points_local, center, cloud_scale) + draw_polyline(accent_points, _with_opacity(accent_color, opacity), maxf(1.0, cloud_scale * 3.0), true) func _draw_moon() -> void: - var moon_curves: Array[PackedVector2Array] = _build_moon_curves() - var moon_local: PackedVector2Array = _sample_curve_chain(moon_curves, 16) - var outer_arc: PackedVector2Array = _sample_curve_chain(_build_moon_outer_arc_curves(), 16) - var moon_scale: float = size.y / 480.0 * 0.63 - var moon_origin: Vector2 = Vector2(size.x * 0.7625, size.y * 0.09583) - var moon_size: Vector2 = Vector2(150.0, 240.0) * moon_scale - var moon_rect: Rect2 = Rect2(moon_origin, moon_size) - var moon_outline: PackedVector2Array = _map_moon_points(moon_local, moon_rect) + var moon_scale: float = _base_motif_scale() * _motif_scale * 0.63 + var moon_center: Vector2 = _motif_anchor(Vector2(0.75, 0.25)) + var moon_outline: PackedVector2Array = _map_moon_points(_cached_moon_outline, moon_center, moon_scale) draw_colored_polygon(moon_outline, Color("#17213A")) draw_colored_polygon(moon_outline, Color("#FFEA31")) - var warm_rim: PackedVector2Array = _build_moon_warm_rim(outer_arc, 7.5) - draw_colored_polygon(_map_moon_points(warm_rim, moon_rect), Color("#F4A629")) - var highlight: PackedVector2Array = _sample_curve_chain(_build_moon_highlight(), 18) - draw_polyline(_map_moon_points(highlight, moon_rect), Color(1.0, 0.98, 0.63, 0.62), maxf(1.0, moon_scale * 8.0), true) + draw_colored_polygon(_map_moon_points(_cached_moon_warm_rim, moon_center, moon_scale), Color("#F4A629")) + draw_polyline(_map_moon_points(_cached_moon_highlight, moon_center, moon_scale), Color(1.0, 0.98, 0.63, 0.62), maxf(1.0, moon_scale * 8.0), true) draw_polyline(moon_outline, Color("#17213A"), maxf(1.0, moon_scale * 5.5), true) +func _base_motif_scale() -> float: + return maxf(0.001, minf(size.x / BASE_VIEWPORT_SIZE.x, size.y / BASE_VIEWPORT_SIZE.y)) + + +func _motif_anchor(normalized_anchor: Vector2) -> Vector2: + return normalized_anchor * size + _motif_shift + + func _build_cloud_outline() -> PackedVector2Array: return _sample_curve_chain(_build_cloud_outer_curves(), 10) @@ -284,17 +376,17 @@ func _cubic_point(curve: PackedVector2Array, t: float) -> Vector2: ) -func _map_cloud_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array: +func _map_cloud_points(points: PackedVector2Array, center: Vector2, uniform_scale: float) -> PackedVector2Array: var mapped: PackedVector2Array = PackedVector2Array() for point: Vector2 in points: - mapped.append(rect.position + Vector2(point.x / CLOUD_VIEWBOX.x * rect.size.x, point.y / CLOUD_VIEWBOX.y * rect.size.y)) + mapped.append(center + (point - CLOUD_VIEWBOX * 0.5) * uniform_scale) return mapped -func _map_moon_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array: +func _map_moon_points(points: PackedVector2Array, center: Vector2, uniform_scale: float) -> PackedVector2Array: var mapped: PackedVector2Array = PackedVector2Array() for point: Vector2 in points: - mapped.append(rect.position + point * (rect.size.y / 240.0)) + mapped.append(center + (point - Vector2(75.0, 120.0)) * uniform_scale) return mapped diff --git a/scripts/stage/scenery/stage_starter_card_preview.gd b/scripts/stage/scenery/stage_starter_card_preview.gd index 44b3c97..ef73abf 100644 --- a/scripts/stage/scenery/stage_starter_card_preview.gd +++ b/scripts/stage/scenery/stage_starter_card_preview.gd @@ -5,15 +5,12 @@ extends SubViewportContainer const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd") const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd") const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd") -const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd") const STAGE_SKY_VIEW := preload("res://scripts/stage/scenery/stage_sky_view.gd") -const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd") const PROP_BLOCK := preload("res://scripts/props/prop_block.gd") const PREVIEW_SIZE := Vector2i(256, 256) const PREVIEW_MARGIN := 18.0 -const MIN_PREVIEW_ZOOM := 0.08 -const MAX_PREVIEW_ZOOM := 0.42 +const REFERENCE_VIEWPORT_SIZE := Vector2(1280.0, 720.0) var _viewport: SubViewport var _sky_view: StageSkyView @@ -82,35 +79,33 @@ func _build_preview_viewport(data: Dictionary) -> void: prop.gravity_scale = 0.0 prop.collision_layer = 0 prop.collision_mask = 0 - call_deferred("_fit_scene_to_preview") + call_deferred("_fit_scene_to_preview", data.get("camera", {}) as Dictionary) -func _fit_scene_to_preview() -> void: +func _fit_scene_to_preview(camera_state: Dictionary) -> void: if _world == null or not is_instance_valid(_world): return - var content_bounds := Rect2() - var has_bounds: bool = false - for node: Node2D in _loaded_nodes: - if node == null or not is_instance_valid(node): - continue - var bounds: Rect2 - if node.get_script() == STAGE_WALL_PANEL: - bounds = node.global_transform * (node as StageWallPanel).get_local_bounds() - else: - bounds = STAGE_SELECTION.get_world_aabb(node) - bounds = bounds.abs() - if bounds.size.x <= 0.0 or bounds.size.y <= 0.0: - continue - content_bounds = bounds if not has_bounds else content_bounds.merge(bounds) - has_bounds = true - if not has_bounds: - _world.scale = Vector2.ONE * 0.2 - _world.position = Vector2(PREVIEW_SIZE) * 0.5 - _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE - return - var usable_size: Vector2 = Vector2(PREVIEW_SIZE) - Vector2.ONE * PREVIEW_MARGIN * 2.0 - var fit_zoom: float = minf(usable_size.x / content_bounds.size.x, usable_size.y / content_bounds.size.y) - fit_zoom = clampf(fit_zoom, MIN_PREVIEW_ZOOM, MAX_PREVIEW_ZOOM) - _world.scale = Vector2.ONE * fit_zoom - _world.position = Vector2(PREVIEW_SIZE) * 0.5 - content_bounds.get_center() * fit_zoom + var preview_frame: Dictionary = _preview_transform_for_camera(camera_state) + var preview_scale: float = float(preview_frame["scale"]) + _world.scale = Vector2.ONE * preview_scale + _world.position = preview_frame["position"] as Vector2 _viewport.render_target_update_mode = SubViewport.UPDATE_ONCE + + +static func _preview_transform_for_camera(camera_state: Dictionary) -> Dictionary: + var camera_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera_state.get("position"), Vector2.ZERO) + var camera_zoom: float = float(camera_state.get("zoom", 0.5)) + if not is_finite(camera_zoom) or camera_zoom <= 0.0: + camera_zoom = 0.5 + var square_world_extent: float = REFERENCE_VIEWPORT_SIZE.y / camera_zoom + var focus_rect := Rect2( + camera_position - Vector2.ONE * square_world_extent * 0.5, + Vector2.ONE * square_world_extent + ) + var usable_size: Vector2 = Vector2(PREVIEW_SIZE) - Vector2.ONE * PREVIEW_MARGIN * 2.0 + var preview_scale: float = minf(usable_size.x / focus_rect.size.x, usable_size.y / focus_rect.size.y) + return { + "focus_rect": focus_rect, + "scale": preview_scale, + "position": Vector2(PREVIEW_SIZE) * 0.5 - camera_position * preview_scale, + } diff --git a/tests/phase19_sky_camera_visual_capture.gd b/tests/phase19_sky_camera_visual_capture.gd new file mode 100644 index 0000000..f55051a --- /dev/null +++ b/tests/phase19_sky_camera_visual_capture.gd @@ -0,0 +1,131 @@ +extends SceneTree + +const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn") +const TOWN_STREET_PATH: String = "res://content/stages/starters/town_street.scene" +const SKY_IDS: Array[StringName] = [&"sky_clear_day", &"sky_cloudy_day", &"sky_sunset", &"sky_starry_night"] +const CAPTURE_DIR: String = "/private/tmp" +const TEMP_SCENE_PATH: String = "res://tests/phase19_sky_camera_tmp.scene" + + +func _initialize() -> void: + call_deferred("_capture_sky_states") + + +func _capture_sky_states() -> void: + root.size = Vector2i(1280, 720) + var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage + root.add_child(stage) + await _wait_for_render_frames(4) + var town_loaded: bool = bool(stage.call("_load_scene_from_path", TOWN_STREET_PATH, false)) + print("VISUAL town scene loaded=", town_loaded) + stage.call("_on_grid_toggled", false) + var camera: Camera2D = stage.get_node("Camera2D") as Camera2D + var sky_view: StageSkyView = stage.get("_sky_view") as StageSkyView + var spawner: StageSpawner = stage.get("_spawner") as StageSpawner + var stickman: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(0.0, -400.0)) + if stickman != null: + stage.call("_save_object_state", stickman) + var scenery_controller: StageSceneryController = stage.get("_scenery_controller") as StageSceneryController + scenery_controller.begin_background_ground(&"finish_background_grass", Vector2(-900.0, 0.0), Color.WHITE) + var ground_press: InputEventMouseButton = InputEventMouseButton.new() + ground_press.button_index = MOUSE_BUTTON_LEFT + ground_press.pressed = true + scenery_controller.handle_mouse_button(ground_press, Vector2(-900.0, 0.0), false) + var ground_release: InputEventMouseButton = InputEventMouseButton.new() + ground_release.button_index = MOUSE_BUTTON_LEFT + ground_release.pressed = false + scenery_controller.handle_mouse_button(ground_release, Vector2(900.0, 900.0), false) + scenery_controller.exit_tool() + print("VISUAL action context stickman=", stickman != null, " background_ground_count=", (stage.get_node("World") as Node2D).get_children().filter(func(child: Node) -> bool: return child.get_script() == preload("res://scripts/stage/scenery/stage_background_ground.gd")).size()) + await _wait_for_render_frames(4) + for sky_id: StringName in SKY_IDS: + camera.global_position = Vector2(0.0, -400.0) + camera.zoom = Vector2.ONE * 0.5 + stage.set("_sky_id", sky_id) + sky_view.set_sky(sky_id) + await _wait_for_render_frames(2) + await _save_capture("sky_%s_reference.png" % String(sky_id)) + + stage.set("_sky_id", &"sky_sunset") + sky_view.set_sky(&"sky_sunset") + camera.global_position = Vector2(420.0, -210.0) + camera.zoom = Vector2.ONE * 1.5 + await _wait_for_render_frames(3) + await _save_capture("sky_sunset_close_pan.png") + + camera.global_position = Vector2(-620.0, -740.0) + camera.zoom = Vector2.ONE * 0.12 + await _wait_for_render_frames(3) + await _save_capture("sky_sunset_wide_pan.png") + + camera.global_position = Vector2(0.0, -400.0) + camera.zoom = Vector2.ONE * 0.5 + await _wait_for_render_frames(3) + root.size = Vector2i(900, 700) + await _wait_for_render_frames(4) + await _save_capture("sky_sunset_resize_900x700.png") + stage.set("_sky_id", &"sky_starry_night") + sky_view.set_sky(&"sky_starry_night") + await _wait_for_render_frames(3) + await _save_capture("sky_starry_night_resize_900x700.png") + root.size = Vector2i(1440, 720) + await _wait_for_render_frames(4) + stage.set("_sky_id", &"sky_sunset") + sky_view.set_sky(&"sky_sunset") + await _wait_for_render_frames(3) + await _save_capture("sky_sunset_resize_1440x720.png") + stage.set("_sky_id", &"sky_starry_night") + sky_view.set_sky(&"sky_starry_night") + await _wait_for_render_frames(3) + await _save_capture("sky_starry_night_resize_1440x720.png") + root.size = Vector2i(1280, 720) + stage.set("_sky_id", &"sky_sunset") + sky_view.set_sky(&"sky_sunset") + + stage.set("_cli_startup", true) + stage.call("set_mode", 1) + await _wait_for_render_frames(3) + await _save_capture("sky_sunset_direct.png") + stage.call("set_mode", 2) + await _wait_for_render_frames(3) + await _save_capture("sky_sunset_play.png") + stage.call("set_mode", 0) + await _wait_for_render_frames(3) + stage.set("_cli_startup", false) + await _save_capture("sky_sunset_play_stopped_edit.png") + + camera.global_position = Vector2(420.0, -210.0) + camera.zoom = Vector2.ONE * 1.5 + await _wait_for_render_frames(3) + var saved_shift: Vector2 = sky_view.get("_motif_shift") + var saved_scale: float = float(sky_view.get("_motif_scale")) + var saved_camera_position: Vector2 = camera.global_position + var saved_camera_zoom: Vector2 = camera.zoom + var saved: bool = stage.call("_save_scene_to_path", TEMP_SCENE_PATH, false) + var reopened: bool = bool(stage.call("_load_scene_from_path", TEMP_SCENE_PATH, false)) if saved else false + await _wait_for_render_frames(4) + var reopened_shift: Vector2 = sky_view.get("_motif_shift") + var reopened_scale: float = float(sky_view.get("_motif_scale")) + print("VISUAL sky save/reopen saved=", saved, " reopened=", reopened, " camera=", saved_camera_position, "/", saved_camera_zoom, " reopened_camera=", camera.global_position, "/", camera.zoom, " shift=", saved_shift, "/", reopened_shift, " scale=", saved_scale, "/", reopened_scale, " framing_matches=", saved_shift.is_equal_approx(reopened_shift) and is_equal_approx(saved_scale, reopened_scale)) + await _save_capture("sky_sunset_save_reopen.png") + if FileAccess.file_exists(TEMP_SCENE_PATH): + DirAccess.remove_absolute(ProjectSettings.globalize_path(TEMP_SCENE_PATH)) + stage.call("_load_scene_from_path", "res://content/stages/starters/blank.scene", false) + await _wait_for_render_frames(3) + stage.queue_free() + await process_frame + quit(0) + + +func _wait_for_render_frames(count: int) -> void: + for _frame: int in count: + await process_frame + await RenderingServer.frame_post_draw + + +func _save_capture(file_name: String) -> void: + await RenderingServer.frame_post_draw + var image: Image = root.get_texture().get_image() + var path: String = CAPTURE_DIR.path_join("phase19_" + file_name) + var error: Error = image.save_png(path) + print("VISUAL capture=", path, " size=", image.get_size(), " error=", error) diff --git a/tests/phase19_sky_camera_visual_capture.gd.uid b/tests/phase19_sky_camera_visual_capture.gd.uid new file mode 100644 index 0000000..9fec34c --- /dev/null +++ b/tests/phase19_sky_camera_visual_capture.gd.uid @@ -0,0 +1 @@ +uid://dg0vu601trn7c diff --git a/tests/test_phase19_scenery.gd b/tests/test_phase19_scenery.gd index 782c913..45cc690 100644 --- a/tests/test_phase19_scenery.gd +++ b/tests/test_phase19_scenery.gd @@ -34,6 +34,18 @@ func _run() -> void: print("PASS: Phase 19 Sky render capture") quit(0) return + if OS.get_cmdline_user_args().has("--sky-camera-only"): + _stage = SANDBOX_STAGE.instantiate() as SandboxStage + root.add_child(_stage) + await process_frame + await process_frame + await _test_live_sky_camera_binding() + for failure: String in _failures: + push_error("FAIL: " + failure) + _stage.queue_free() + await process_frame + quit(1 if not _failures.is_empty() else 0) + return for starter_path: String in STARTER_PATHS: var result: Dictionary = STAGE_SCENE_IO.read_file(starter_path) _expect(bool(result.get("ok", false)), "starter scene validates: %s" % starter_path) @@ -44,6 +56,7 @@ func _run() -> void: root.add_child(_stage) await process_frame await process_frame + await _test_live_sky_camera_binding() await _test_grid_controls_layout() var baseline_spawner: StageSpawner = _stage.get("_spawner") as StageSpawner var baseline_ground: TerrainBlock = baseline_spawner.spawn_terrain_asset("ground", Vector2.ZERO) @@ -102,14 +115,10 @@ func _run() -> void: _expect(String(_stage.get("_scene_name")) == "Town Street", "choosing Town Street loads it as a named, unsaved scene") _expect(String(_stage.get("_sky_id")) == "sky_cloudy_day", "Town Street starter selects the approved cloudy sky") var world: Node2D = _stage.get_node("World") as Node2D - var terrain_finish_count: int = 0 - var scaled_terrain_count: int = 0 - for child: Node in world.get_children(): - if child is TerrainBlock and String((child as TerrainBlock).finish_id) in ["finish_ground_sidewalk", "finish_ground_asphalt"]: - terrain_finish_count += 1 - if is_equal_approx((child as TerrainBlock).scale.x, 7.0): - scaled_terrain_count += 1 - _expect(terrain_finish_count == 2 and scaled_terrain_count == 2, "Town Street uses full-width sidewalk and asphalt strips") + var sidewalk_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_sidewalk", -16.0, "Town Street sidewalk") + var road_bounds: Rect2 = _test_starter_surface_row(world, &"finish_ground_asphalt", 16.0, "Town Street asphalt", 27, 2592.0) + _expect(is_equal_approx(road_bounds.position.y, sidewalk_bounds.end.y), "Town Street asphalt row meets the sidewalk row with no vertical gap") + _test_saved_starter_camera("res://content/stages/starters/town_street.scene", "Town Street") await physics_frame await physics_frame var navigation_region: NavigationRegion2D = _stage.get_node("NavigationRegion2D") as NavigationRegion2D @@ -273,6 +282,7 @@ func _run() -> void: await _test_single_scenery_transforms(world, String(artwork_entries[0].get("path", ""))) await _test_selected_scenery_depth(world, String(artwork_entries[0].get("path", ""))) await _test_prop_size_for_stage() + await _test_living_room_surface(world) if _stage != null and is_instance_valid(_stage): _stage.queue_free() await process_frame @@ -286,6 +296,70 @@ func _run() -> void: quit(0 if _failures.is_empty() else 1) +func _test_living_room_surface(world: Node2D) -> void: + var living_path: String = "res://content/stages/starters/living_room.scene" + var loaded: bool = bool(_stage.call("_load_scene_from_path", living_path, false)) + _expect(loaded, "Living Room loads through the ordinary Stage scene path") + if not loaded: + return + _test_starter_surface_row(world, &"finish_ground_wood", 184.0, "Living Room Wood floor") + _test_saved_starter_camera(living_path, "Living Room") + + +func _test_starter_surface_row( + world: Node2D, + finish_id: StringName, + expected_top: float, + label: String, + expected_count: int = 25, + expected_half_width: float = 2400.0 +) -> Rect2: + var tiles: Array[TerrainBlock] = [] + for child: Node in world.get_children(): + if child is TerrainBlock and StringName((child as TerrainBlock).finish_id) == finish_id: + tiles.append(child as TerrainBlock) + _expect(tiles.size() == expected_count, "%s uses %d ordinary Ground tiles" % [label, expected_count]) + if tiles.is_empty(): + return Rect2() + tiles.sort_custom(func(left: TerrainBlock, right: TerrainBlock) -> bool: return left.global_position.x < right.global_position.x) + var row_bounds: Rect2 = _terrain_world_bounds(tiles[0]) + for tile: TerrainBlock in tiles: + var bounds: Rect2 = _terrain_world_bounds(tile) + _expect(tile.scale.is_equal_approx(Vector2.ONE), "%s tile stays at normal scale" % label) + _expect(bounds.size.is_equal_approx(Vector2(192.0, 32.0)), "%s tile keeps its 192×32 footprint" % label) + _expect(is_equal_approx(bounds.position.y, expected_top), "%s tile keeps the authored surface top" % label) + row_bounds = row_bounds.merge(bounds) + for index: int in range(tiles.size() - 1): + var left_bounds: Rect2 = _terrain_world_bounds(tiles[index]) + var right_bounds: Rect2 = _terrain_world_bounds(tiles[index + 1]) + _expect(is_zero_approx(right_bounds.position.x - left_bounds.end.x), "%s adjacent tiles meet without gaps or overlap" % label) + _expect(is_equal_approx(row_bounds.position.x, -expected_half_width) and is_equal_approx(row_bounds.end.x, expected_half_width), "%s spans %.0f world units" % [label, expected_half_width * 2.0]) + _expect(expected_half_width - (1000.0 + 1280.0) >= 100.0, "%s retains 100-unit camera-pan endpoint margins" % label) + return row_bounds + + +func _test_saved_starter_camera(path: String, label: String) -> void: + var result: Dictionary = STAGE_SCENE_IO.read_file(path) + _expect(bool(result.get("ok", false)), "%s saved camera fixture loads" % label) + if not bool(result.get("ok", false)): + return + var data: Dictionary = result.get("data", {}) as Dictionary + var camera_data: Dictionary = data.get("camera", {}) as Dictionary + var expected_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera_data.get("position"), Vector2.ZERO) + var expected_zoom: float = float(camera_data.get("zoom", 0.0)) + var camera: Camera2D = _stage.get_node("Camera2D") as Camera2D + _expect(camera.global_position.is_equal_approx(expected_position) and camera.zoom.is_equal_approx(Vector2.ONE * expected_zoom), "%s runtime camera matches its single saved opening camera" % label) + + +func _terrain_world_bounds(terrain: TerrainBlock) -> Rect2: + if terrain.polygon_points.is_empty(): + return Rect2() + var bounds: Rect2 = Rect2(terrain.to_global(terrain.polygon_points[0]), Vector2.ZERO) + for point: Vector2 in terrain.polygon_points: + bounds = bounds.expand(terrain.to_global(point)) + return bounds + + func _test_large_brick_pattern_budget() -> void: var view_size: Vector2 = Vector2(12000.0, 8000.0) var detail_scale: float = STAGE_SURFACE_RENDERER.brick_detail_scale_for_view(view_size) @@ -323,8 +397,40 @@ func _test_stage_sky_visual_model() -> void: sky_view.size = Vector2(root.size) root.add_child(sky_view) await process_frame + var viewport_size: Vector2 = Vector2(root.size) + var reference_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(0.0, -400.0), 0.5, viewport_size) + _expect((reference_response["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(reference_response["scale"]), 1.0), "camera-aware Sky uses the fixed Stage reference framing") + var close_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, viewport_size) + var close_shift: Vector2 = close_response["shift"] + var close_scale: float = float(close_response["scale"]) + _expect(close_shift.length() > 0.0 and absf(close_shift.x) <= viewport_size.x * 0.05 and absf(close_shift.y) <= viewport_size.y * 0.03, "camera-aware Sky pan stays perceptible and inside the viewport-fraction limits") + _expect(close_scale > 1.0 and close_scale <= 1.15, "camera-aware Sky details grow gently during a close zoom") + var wide_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(0.0, -400.0), 0.1, viewport_size) + var wide_scale: float = float(wide_response["scale"]) + _expect(wide_scale < 1.0 and wide_scale >= 0.85, "camera-aware Sky details shrink gently during a wide zoom") + var repeated_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, viewport_size) + _expect((repeated_response["shift"] as Vector2).is_equal_approx(close_shift) and is_equal_approx(float(repeated_response["scale"]), close_scale), "absolute camera framing produces a repeatable Sky composition") + var invalid_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(INF, 0.0), 1.0, viewport_size) + _expect((invalid_response["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(invalid_response["scale"]), 1.0), "invalid camera framing falls back to the fixed default composition") + var resized_response: Dictionary = StageSkyView.compute_camera_response(&"sky_clear_day", Vector2(420.0, -210.0), 1.5, Vector2(900.0, 700.0)) + var resized_shift: Vector2 = resized_response["shift"] + _expect(absf(resized_shift.x) <= 900.0 * 0.05 and absf(resized_shift.y) <= 700.0 * 0.03, "resized viewport keeps Sky pan inside the same viewport-fraction bounds") + var stars_stay_normalized: bool = true + for star_index: int in 48: + var star_anchor: Vector2 = StageSkyView.star_anchor_for_index(star_index) + if not star_anchor.is_finite() or star_anchor.x < 0.0 or star_anchor.x >= 1.0 or star_anchor.y < 0.0 or star_anchor.y >= 0.62: + stars_stay_normalized = false + break + _expect(stars_stay_normalized, "star anchors remain stable normalized coordinates across viewport resizes") var capture_sky_views: bool = OS.get_cmdline_user_args().has("--capture-sky-v2") for sky_id: StringName in StageSkyView.SKY_IDS: + var profile_reference: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 0.5, viewport_size) + _expect((profile_reference["shift"] as Vector2).is_zero_approx() and is_equal_approx(float(profile_reference["scale"]), 1.0), "%s has a stable reference composition" % String(sky_id)) + var close_profile: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 6.0, viewport_size) + var wide_profile: Dictionary = StageSkyView.compute_camera_response(sky_id, Vector2(0.0, -400.0), 0.1, viewport_size) + var close_profile_scale: float = float(close_profile["scale"]) + var wide_profile_scale: float = float(wide_profile["scale"]) + _expect(close_profile_scale >= 0.85 and close_profile_scale <= 1.15 and wide_profile_scale >= 0.85 and wide_profile_scale <= 1.15, "%s motif size stays bounded across the supported zoom range" % String(sky_id)) sky_view.set_sky(sky_id) await process_frame var texture_value: Variant = sky_view.get("_gradient_texture") @@ -339,6 +445,9 @@ func _test_stage_sky_visual_model() -> void: var capture_path: String = ProjectSettings.globalize_path("user://phase19_sky_%s.png" % String(sky_id)) var capture_error: Error = capture.save_png(capture_path) _expect(capture_error == OK, "%s visual capture saved for inspection" % String(sky_id)) + var unbound_shift: Vector2 = sky_view.get("_motif_shift") + var unbound_scale: float = float(sky_view.get("_motif_scale")) + _expect(unbound_shift.is_zero_approx() and is_equal_approx(unbound_scale, 1.0), "unbound chooser-style Sky previews keep their default still composition") var outline_value: Variant = sky_view.call("_build_cloud_outline") var cloud_outline: PackedVector2Array = outline_value _expect(cloud_outline.size() >= 100, "cloud silhouette is a detailed tessellated Bézier contour") @@ -359,6 +468,84 @@ func _test_stage_sky_visual_model() -> void: await process_frame +func _test_live_sky_camera_binding() -> void: + var sky_view: StageSkyView = _stage.get("_sky_view") as StageSkyView + var camera: Camera2D = _stage.get_node("Camera2D") as Camera2D + _expect(sky_view != null and camera != null and sky_view.get("_bound_camera") == camera, "live Stage Sky binds once to the Stage camera") + if sky_view == null or camera == null: + return + var previous_position: Vector2 = camera.global_position + var previous_zoom: Vector2 = camera.zoom + var previous_sky: StringName = sky_view.get_sky() + sky_view.set_sky(&"sky_sunset") + camera.global_position = Vector2(0.0, -400.0) + camera.zoom = Vector2.ONE * 0.5 + await process_frame + var reference_shift: Vector2 = sky_view.get("_motif_shift") + var reference_scale: float = float(sky_view.get("_motif_scale")) + camera.global_position = Vector2(420.0, -210.0) + camera.zoom = Vector2.ONE * 1.5 + await process_frame + var moved_shift: Vector2 = sky_view.get("_motif_shift") + var moved_scale: float = float(sky_view.get("_motif_scale")) + _expect(reference_shift.is_zero_approx() and is_equal_approx(reference_scale, 1.0), "live Stage Sky starts at the fixed reference frame") + _expect(not moved_shift.is_zero_approx() and moved_scale > 1.0, "live Stage Sky samples Stage pan and zoom while the camera moves") + var saved_shift: Vector2 = moved_shift + _stage.call("set_mode", 1) + _stage.call("set_mode", 0) + await process_frame + _expect((sky_view.get("_motif_shift") as Vector2).is_equal_approx(saved_shift), "EDIT and DIRECT transitions preserve the same camera-derived Sky framing") + await _test_camera_input_route(sky_view, camera) + camera.global_position = previous_position + camera.zoom = previous_zoom + sky_view.set_sky(previous_sky) + await process_frame + + +func _test_camera_input_route(sky_view: StageSkyView, camera: Camera2D) -> void: + var input_route_passes: bool = true + _stage.set("_cli_startup", true) + var hovered_control: Control = root.gui_get_hovered_control() + var saved_mouse_filter: Control.MouseFilter = hovered_control.mouse_filter if hovered_control != null else Control.MOUSE_FILTER_STOP + if hovered_control != null: + hovered_control.mouse_filter = Control.MOUSE_FILTER_IGNORE + await process_frame + for mode: int in [0, 1, 2]: + _stage.call("set_mode", mode) + camera.global_position = Vector2(0.0, -400.0) + camera.zoom = Vector2.ONE * 0.5 + var wheel_event: InputEventMouseButton = InputEventMouseButton.new() + wheel_event.button_index = MOUSE_BUTTON_WHEEL_UP + wheel_event.pressed = true + wheel_event.position = Vector2(root.size) * 0.5 + _stage.call("_input", wheel_event) + await process_frame + var zoomed: bool = camera.zoom.x > 0.5 and float(sky_view.get("_motif_scale")) > 1.0 + var pan_press: InputEventMouseButton = InputEventMouseButton.new() + pan_press.button_index = MOUSE_BUTTON_MIDDLE + pan_press.pressed = true + pan_press.position = Vector2(root.size) * 0.5 + _stage.call("_input", pan_press) + var pan_motion: InputEventMouseMotion = InputEventMouseMotion.new() + pan_motion.position = pan_press.position + Vector2(80.0, -30.0) + pan_motion.relative = Vector2(80.0, -30.0) + _stage.call("_input", pan_motion) + var pan_release: InputEventMouseButton = InputEventMouseButton.new() + pan_release.button_index = MOUSE_BUTTON_MIDDLE + pan_release.pressed = false + pan_release.position = pan_motion.position + _stage.call("_input", pan_release) + await process_frame + var panned: bool = not camera.global_position.is_equal_approx(Vector2(0.0, -400.0)) and not (sky_view.get("_motif_shift") as Vector2).is_zero_approx() + input_route_passes = input_route_passes and zoomed and panned + _expect(zoomed and panned, "SandboxStage wheel zoom and middle-drag pan update the camera-aware Sky in mode %d" % mode) + _stage.call("set_mode", 0) + _stage.set("_cli_startup", false) + if hovered_control != null and is_instance_valid(hovered_control): + hovered_control.mouse_filter = saved_mouse_filter + _expect(input_route_passes, "SandboxStage camera input route remains active in EDIT, DIRECT, and PLAY") + + func _expect(condition: bool, description: String) -> void: if condition: print("PASS: " + description) diff --git a/tests/test_phase19_starter_scene_composition.gd b/tests/test_phase19_starter_scene_composition.gd new file mode 100644 index 0000000..73130b4 --- /dev/null +++ b/tests/test_phase19_starter_scene_composition.gd @@ -0,0 +1,408 @@ +extends SceneTree + +const STAGE_SCENE_IO: Script = preload("res://scripts/stage/stage_scene_io.gd") +const STAGE_SCENE_ADAPTER: Script = preload("res://scripts/editors/stage/stage_scene_adapter.gd") +const STAGE_SPAWNER: Script = preload("res://scripts/stage/stage_spawner.gd") +const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn") +const TERRAIN_BLOCK: Script = preload("res://scripts/terrain/terrain_block.gd") +const STAGE_SCENERY_ARTWORK: Script = preload("res://scripts/stage/scenery/stage_scenery_artwork.gd") +const STAGE_BACKGROUND_GROUND: Script = preload("res://scripts/stage/scenery/stage_background_ground.gd") +const STAGE_WALL_PANEL: Script = preload("res://scripts/stage/scenery/stage_wall_panel.gd") +const STAGE_STARTER_CARD_PREVIEW: Script = preload("res://scripts/stage/scenery/stage_starter_card_preview.gd") +const STICKMAN_LOCOMOTION: Script = preload("res://scripts/characters/stickman/stickman_locomotion.gd") + +const TOWN_PATH: String = "res://content/stages/starters/town_street.scene" +const LIVING_ROOM_PATH: String = "res://content/stages/starters/living_room.scene" +const REFERENCE_VIEWPORT: Vector2 = Vector2(1280.0, 720.0) +const SAVED_ZOOM: float = 0.5 +const PAN_TRAVEL: float = 1000.0 +const SURFACE_HALF_WIDTH: float = 2400.0 +const ASPHALT_HALF_WIDTH: float = SURFACE_HALF_WIDTH + TILE_WIDTH +const TILE_WIDTH: float = 192.0 +const TILE_HEIGHT: float = 32.0 +const TILE_COUNT: int = 25 +const TILE_SEAM_X: float = TILE_WIDTH * 0.5 +const SURFACE_TOP_LINE: float = -16.0 +const LIVING_ROOM_FLOOR_TOP: float = 184.0 +const CONTACT_GAP_LOGICAL_PIXELS: float = 3.0 + +var _failures: Array[String] = [] +var _assertion_count: int = 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_preview_transform() + await _test_scene(TOWN_PATH, true) + await _test_scene(LIVING_ROOM_PATH, false) + for failure: String in _failures: + push_error("FAIL: " + failure) + if _failures.is_empty(): + print("PASS: Phase 19 starter composition (%d assertions)" % _assertion_count) + else: + print("FAIL: Phase 19 starter composition (%d/%d failures)" % [_failures.size(), _assertion_count]) + quit(1 if not _failures.is_empty() else 0) + + +func _test_preview_transform() -> void: + var preview_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera({ + "position": {"x": 125.0, "y": -75.0}, + "zoom": SAVED_ZOOM, + }) + var focus_rect: Rect2 = preview_frame["focus_rect"] as Rect2 + var expected_extent: float = REFERENCE_VIEWPORT.y / SAVED_ZOOM + _expect(focus_rect.size.is_equal_approx(Vector2.ONE * expected_extent), "starter preview uses a 1,440-unit square camera crop") + _expect(focus_rect.get_center().is_equal_approx(Vector2(125.0, -75.0)), "starter preview crop follows the saved camera center") + _expect(is_equal_approx(float(preview_frame["scale"]), 220.0 / expected_extent), "starter preview fits the camera crop into the 220-pixel usable area") + var centered_position: Vector2 = preview_frame["position"] as Vector2 + _expect((centered_position + Vector2(125.0, -75.0) * float(preview_frame["scale"])).is_equal_approx(Vector2(128.0, 128.0)), "starter preview centers the saved camera without changing it") + var bad_zoom_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera({"zoom": 0.0}) + _expect(is_equal_approx(float(bad_zoom_frame["focus_rect"].size.x), expected_extent), "starter preview safely falls back for an invalid saved zoom") + + +func _test_scene(path: String, is_town: bool) -> void: + var read_result: Dictionary = STAGE_SCENE_IO.read_file(path) + _expect(bool(read_result.get("ok", false)), "starter snapshot loads through StageSceneIO: %s" % path) + if not bool(read_result.get("ok", false)): + return + var data: Dictionary = read_result["data"] as Dictionary + _expect(String(data.get("version", "")) == STAGE_SCENE_IO.FILE_VERSION, "starter snapshot uses current Stage format: %s" % path) + var camera: Dictionary = data.get("camera", {}) as Dictionary + var camera_position: Vector2 = STAGE_SCENE_IO.data_to_vector(camera.get("position"), Vector2.ZERO) + _expect(is_equal_approx(float(camera.get("zoom", 0.0)), SAVED_ZOOM), "starter saved opening zoom is 0.5: %s" % path) + _expect(is_equal_approx(camera_position.x, 0.0), "starter saved opening camera remains centered horizontally: %s" % path) + _test_round_trip(path, data) + await _test_production_adapter(path, data, camera_position, is_town) + await _test_play_support(path, is_town) + + +func _test_round_trip(path: String, data: Dictionary) -> void: + var temp_path: String = "/private/tmp/phase19_composition_%s.scene" % path.get_file().get_basename() + var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(temp_path, data) + var read_result: Dictionary = STAGE_SCENE_IO.read_file(temp_path) if bool(write_result.get("ok", false)) else {} + _expect(bool(write_result.get("ok", false)), "starter snapshot writes through ordinary scene serializer: %s" % path) + _expect(bool(read_result.get("ok", false)), "saved starter snapshot reopens through ordinary scene loader: %s" % path) + if bool(read_result.get("ok", false)): + var reopened: Dictionary = read_result["data"] as Dictionary + _expect(reopened.get("objects", []) == data.get("objects", []), "starter placements and finishes round-trip unchanged: %s" % path) + _expect(reopened.get("camera", {}) == data.get("camera", {}), "starter opening camera round-trips unchanged: %s" % path) + var absolute_path: String = ProjectSettings.globalize_path(temp_path) + if FileAccess.file_exists(absolute_path): + DirAccess.remove_absolute(absolute_path) + + +func _test_production_adapter(path: String, data: Dictionary, camera_position: Vector2, is_town: bool) -> void: + var world: Node2D = Node2D.new() + world.name = "CompositionTestWorld" + root.add_child(world) + var spawner: StageSpawner = STAGE_SPAWNER.new(world) as StageSpawner + var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter + var applied: Dictionary = adapter.apply(data, world, spawner) + await process_frame + var loaded_nodes: Array[Node2D] = applied.get("loaded_nodes", []) as Array[Node2D] + _expect(loaded_nodes.size() == (data.get("objects", []) as Array).size(), "production adapter creates all saved starter objects: %s" % path) + _expect((applied.get("warnings", []) as Array).is_empty(), "production adapter loads starter objects without warnings: %s" % path) + if is_town: + _test_town_geometry(data, loaded_nodes, camera_position) + else: + _test_living_room_geometry(data, loaded_nodes, camera_position) + world.queue_free() + await process_frame + + +func _test_play_support(path: String, is_town: bool) -> void: + var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage + root.add_child(stage) + await process_frame + await process_frame + stage.set("_cli_startup", true) + var loaded: bool = bool(stage.call("_load_scene_from_path", path, false)) + _expect(loaded, "starter scene loads through the production SandboxStage path before PLAY: %s" % path) + if not loaded: + stage.queue_free() + await process_frame + return + var spawner: StageSpawner = stage.get("_spawner") as StageSpawner + var support_y: float = SURFACE_TOP_LINE if is_town else LIVING_ROOM_FLOOR_TOP + await physics_frame + await physics_frame + _test_navigation_seam_path(stage, support_y, path.get_file().get_basename()) + if is_town: + _test_navigation_seam_path(stage, 16.0, "town_street_asphalt") + await _test_town_navigation_level_transition(stage) + var actor: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/test.stk", Vector2(TILE_SEAM_X, support_y - 385.0)) + _expect(actor != null, "standard stickman spawns on a starter floor tile seam") + if actor != null: + _expect(is_equal_approx(actor.global_position.x, TILE_SEAM_X), "PLAY support fixture is centered on the shared tile seam") + stage.call("_save_object_state", actor) + stage.call("set_mode", SandboxStage.StageMode.PLAY) + for _frame: int in range(48): + await physics_frame + var locomotion: StickmanLocomotion = actor.get_locomotion() + var grounded: bool = locomotion != null and locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED + print("METRIC PLAY support scene=", path.get_file().get_basename(), " actor_root_y=", actor.global_position.y, " target_floor_y=", support_y, " terrain_motion=", locomotion.terrain_motion if locomotion != null else -1) + _expect(grounded, "standard stickman reaches the starter walk line in PLAY: %s" % path) + actor.speak("Ready for a story!\nLet's begin.", 20.0) + await process_frame + var bubble: Node = actor.find_child("SpeechBubble", true, false) + _expect(bubble != null and bubble.is_visible_in_tree(), "two-line starter speech bubble appears in PLAY: %s" % path) + if is_town and grounded: + await _test_town_physical_step_down(actor) + stage.call("set_mode", SandboxStage.StageMode.EDIT) + stage.queue_free() + await process_frame + + +func _test_navigation_seam_path(stage: SandboxStage, walk_y: float, label: String) -> void: + var navigation_region: NavigationRegion2D = stage.get_node_or_null("NavigationRegion2D") as NavigationRegion2D + _expect(navigation_region != null, "%s production stage has its navigation region" % label) + if navigation_region == null: + return + var navigation_map: RID = navigation_region.get_navigation_map() + NavigationServer2D.map_force_update(navigation_map) + await physics_frame + NavigationServer2D.map_force_update(navigation_map) + var start: Vector2 = Vector2(-2208.0, walk_y) + var finish: Vector2 = Vector2(2208.0, walk_y) + var path: PackedVector2Array = NavigationServer2D.map_get_path(navigation_map, start, finish, true) + print("METRIC Navigation seam traversal scene=", label, " region_position=", navigation_region.global_position, " polygons=", navigation_region.navigation_polygon.get_polygon_count(), " vertices=", navigation_region.navigation_polygon.get_vertices(), " regions=", NavigationServer2D.map_get_regions(navigation_map).size(), " region_enabled=", navigation_region.enabled, " nearest_start=", NavigationServer2D.map_get_closest_point(navigation_map, start), " nearest_center=", NavigationServer2D.map_get_closest_point(navigation_map, Vector2(0.0, walk_y)), " nearest_end=", NavigationServer2D.map_get_closest_point(navigation_map, finish), " iteration=", NavigationServer2D.map_get_iteration_id(navigation_map), " path_points=", path.size(), " path=", path) + _expect(NavigationServer2D.map_get_iteration_id(navigation_map) > 0, "%s navigation map synchronizes" % label) + _expect(path.size() >= 2 and path[0].x <= 0.0 and path[path.size() - 1].x >= 0.0, "%s navigation path traverses multiple tile seams" % label) + + +func _test_town_navigation_level_transition(stage: SandboxStage) -> void: + var navigation_region: NavigationRegion2D = stage.get_node_or_null("NavigationRegion2D") as NavigationRegion2D + if navigation_region == null: + return + var navigation_map: RID = navigation_region.get_navigation_map() + NavigationServer2D.map_force_update(navigation_map) + await physics_frame + NavigationServer2D.map_force_update(navigation_map) + var transition_path: PackedVector2Array = NavigationServer2D.map_get_path( + navigation_map, + Vector2(0.0, SURFACE_TOP_LINE), + Vector2(0.0, 16.0), + true + ) + print("METRIC Town Street sidewalk-to-asphalt transition path_points=", transition_path.size(), " path=", transition_path) + var uses_exposed_endpoint: bool = false + var reaches_asphalt_level: bool = false + for point: Vector2 in transition_path: + uses_exposed_endpoint = uses_exposed_endpoint or absf(point.x) >= SURFACE_HALF_WIDTH - 32.0 + reaches_asphalt_level = reaches_asphalt_level or point.y >= 16.0 - 0.1 + _expect(transition_path.size() >= 3, "Town Street navigation connects sidewalk and asphalt through an exposed endpoint") + _expect(uses_exposed_endpoint, "Town Street navigation transition uses a sidewalk endpoint connector") + _expect(reaches_asphalt_level, "Town Street navigation route reaches the lower Asphalt walk line") + + +func _test_town_physical_step_down(actor: StickmanRig) -> void: + var locomotion: StickmanLocomotion = actor.get_locomotion() + if locomotion == null: + _expect(false, "Town Street physical step-down fixture has stickman locomotion") + return + actor.global_position.x = 2280.0 + for _frame: int in range(4): + await physics_frame + var starts_grounded: bool = locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED + _expect(starts_grounded, "Town Street step-down actor starts grounded near the sidewalk endpoint") + var walk_started: bool = locomotion.walk_to(Vector2(2500.0, 16.0), 300.0) + _expect(walk_started, "Town Street actor can walk from sidewalk onto the extended Asphalt row") + for _frame: int in range(180): + if not locomotion.is_walking() and locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED: + break + await physics_frame + var foot_target_y: float = actor.global_position.y - locomotion.get_foot_offset().y + var reaches_road: bool = absf(actor.global_position.x - 2500.0) <= 8.0 and absf(foot_target_y - 16.0) <= 8.0 + var grounded_on_road: bool = locomotion.terrain_motion == STICKMAN_LOCOMOTION.TerrainMotion.GROUNDED + print("METRIC Town Street physical step-down x=", actor.global_position.x, " feet_y=", foot_target_y, " terrain_motion=", locomotion.terrain_motion, " walking=", locomotion.is_walking()) + _expect(reaches_road and grounded_on_road, "Town Street actor physically reaches and stands on Asphalt below the sidewalk") + + +func _test_town_geometry(data: Dictionary, nodes: Array[Node2D], camera_position: Vector2) -> void: + var sidewalk_tiles: Array[TerrainBlock] = _find_terrain_tiles(nodes, &"finish_ground_sidewalk") + var asphalt_tiles: Array[TerrainBlock] = _find_terrain_tiles(nodes, &"finish_ground_asphalt") + _expect(sidewalk_tiles.size() == TILE_COUNT and asphalt_tiles.size() == TILE_COUNT + 2, "Town Street keeps 25 separate sidewalk tiles and extends Asphalt with two ordinary endpoint tiles") + var sidewalk_bounds: Rect2 = _test_tile_row(sidewalk_tiles, 0.0, "Town Street sidewalk") + var asphalt_bounds: Rect2 = _test_tile_row(asphalt_tiles, 32.0, "Town Street asphalt", TILE_COUNT + 2, ASPHALT_HALF_WIDTH) + print("METRIC Town Street sidewalk_bounds=", sidewalk_bounds, " asphalt_bounds=", asphalt_bounds, " endpoint_world_margin=", SURFACE_HALF_WIDTH - (PAN_TRAVEL + REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM))) + _expect(is_equal_approx(sidewalk_bounds.position.y, SURFACE_TOP_LINE), "Town Street sidewalk top remains the intended walk line") + _expect(is_equal_approx(asphalt_bounds.position.y, sidewalk_bounds.end.y), "Town Street asphalt row touches the sidewalk row with no vertical gap") + var background: StageBackgroundGround = _find_background_ground(nodes) + _expect(background != null, "Town Street includes a non-walkable Grass backdrop behind the set") + if background != null: + var backdrop_bounds: Rect2 = background.global_transform * background.get_local_bounds() + var skyline_bottom: float = -INF + var skyline_count: int = 0 + for node: Node2D in nodes: + if node.get_script() != STAGE_SCENERY_ARTWORK: + continue + var skyline: StageSceneryArtwork = node as StageSceneryArtwork + if skyline.display_name != "City skyline": + continue + skyline_count += 1 + skyline_bottom = maxf(skyline_bottom, skyline.get_visual_world_bounds().end.y) + _expect(background.z_index < skyline.z_index, "Town Street Grass draws behind the skyline") + _expect(skyline_count == 3, "Town Street keeps three skyline pieces across the opening") + _expect(background.finish_id == &"finish_background_grass", "Town Street backdrop uses the approved outdoor Grass finish") + _expect(backdrop_bounds.size.is_equal_approx(Vector2(SURFACE_HALF_WIDTH * 2.0, 2000.0)), "Town Street backdrop spans 4,800 world units at authored size") + _expect(is_equal_approx(backdrop_bounds.position.y, -340.0), "Town Street backdrop starts slightly above the skyline base") + _expect(skyline_bottom > -INF and backdrop_bounds.position.y <= skyline_bottom - 15.0, "Town Street backdrop overlaps the measured skyline base to prevent a Sky seam") + _expect(backdrop_bounds.position.x <= -SURFACE_HALF_WIDTH and backdrop_bounds.end.x >= SURFACE_HALF_WIDTH, "Town Street backdrop covers the full pan envelope") + _expect(backdrop_bounds.end.y > camera_position.y + REFERENCE_VIEWPORT.y / SAVED_ZOOM, "Town Street backdrop extends below the opening viewport") + _expect(background.z_index < (sidewalk_tiles[0] as TerrainBlock).z_index, "Town Street Grass draws behind walkable Ground") + var expected_assets: Array[String] = ["Town house", "Storefront", "Tree"] + var seen_assets: Dictionary = {} + var clear_lane: Rect2 = Rect2(Vector2(-REFERENCE_VIEWPORT.x / 6.0, -10000.0), Vector2(REFERENCE_VIEWPORT.x / 3.0, 20000.0)) + for node: Node2D in nodes: + if node.get_script() != STAGE_SCENERY_ARTWORK: + continue + var artwork: StageSceneryArtwork = node as StageSceneryArtwork + if artwork.depth != &"near": + continue + var bounds: Rect2 = artwork.get_visual_world_bounds() + var logical_gap: float = absf(bounds.end.y - sidewalk_bounds.position.y) * SAVED_ZOOM + print("METRIC Town Street contact name=", artwork.display_name, " art_bottom_y=", bounds.end.y, " surface_top_y=", sidewalk_bounds.position.y, " logical_gap_px=", logical_gap) + _expect(logical_gap <= CONTACT_GAP_LOGICAL_PIXELS, "Town Street %s visually meets the sidewalk within 3 logical pixels (%.2f px)" % [artwork.display_name, logical_gap]) + _expect(not bounds.intersects(clear_lane), "Town Street %s leaves the middle-third action lane open" % artwork.display_name) + seen_assets[artwork.display_name] = true + for artwork_name: String in expected_assets: + _expect(seen_assets.has(artwork_name), "Town Street keeps editable near artwork: %s" % artwork_name) + var preview_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera(data.get("camera", {}) as Dictionary) + _expect((preview_frame["focus_rect"] as Rect2).get_center().is_equal_approx(camera_position), "Town Street starter preview centers on its saved opening shot") + + +func _test_living_room_geometry(data: Dictionary, nodes: Array[Node2D], camera_position: Vector2) -> void: + var floor_tiles: Array[TerrainBlock] = _find_terrain_tiles(nodes, &"finish_ground_wood") + _expect(floor_tiles.size() == TILE_COUNT, "Living Room has a 25-tile Wood support row") + var floor_bounds: Rect2 = _test_tile_row(floor_tiles, 200.0, "Living Room Wood floor") + print("METRIC Living Room floor_bounds=", floor_bounds, " endpoint_world_margin=", SURFACE_HALF_WIDTH - (PAN_TRAVEL + REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM))) + _expect(is_equal_approx(floor_bounds.position.y, LIVING_ROOM_FLOOR_TOP), "Living Room Wood floor keeps one level collision top") + var wall_rects: Array[Rect2] = [] + for node: Node2D in nodes: + if node.get_script() == STAGE_WALL_PANEL: + var wall: StageWallPanel = node as StageWallPanel + wall_rects.append(wall.global_transform * wall.get_local_bounds()) + _expect(wall_rects.size() == 5, "Living Room uses editable wall panels around the window opening") + var aperture_points: Array[Vector2] = [Vector2(0.0, -550.0), Vector2(0.0, -600.0), Vector2(0.0, -410.0)] + print("METRIC Living Room window_gap_x=[-145,145] y=[-682,-402]") + for point: Vector2 in aperture_points: + var blocked: bool = false + for wall_rect: Rect2 in wall_rects: + blocked = blocked or wall_rect.has_point(point) + _expect(not blocked, "Living Room window remains a true wall-panel opening at %s" % str(point)) + for camera_x: float in [-PAN_TRAVEL, 0.0, PAN_TRAVEL]: + var viewport_left: float = camera_x - REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM) + var viewport_right: float = camera_x + REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM) + for screen_step: int in range(17): + var x: float = lerpf(viewport_left, viewport_right, float(screen_step) / 16.0) + _expect(_rect_union_covers_point(wall_rects, Vector2(x, -800.0)), "Living Room top wall covers the visible width at pan %.0f" % camera_x) + _expect(_rect_union_covers_point(wall_rects, Vector2(x, 100.0)), "Living Room side wall covers the visible width at pan %.0f" % camera_x) + var expected_grounded_art: Array[String] = ["Bookshelf", "Fireplace", "Floor rug", "Potted plant"] + var seen_grounded_art: Dictionary = {} + for node: Node2D in nodes: + if node.get_script() != STAGE_SCENERY_ARTWORK: + continue + var artwork: StageSceneryArtwork = node as StageSceneryArtwork + if not expected_grounded_art.has(artwork.display_name): + continue + var bounds: Rect2 = artwork.get_visual_world_bounds() + var logical_gap: float = absf(bounds.end.y - floor_bounds.position.y) * SAVED_ZOOM + print("METRIC Living Room scenery_contact name=", artwork.display_name, " art_bottom_y=", bounds.end.y, " floor_top_y=", floor_bounds.position.y, " logical_gap_px=", logical_gap) + _expect(logical_gap <= CONTACT_GAP_LOGICAL_PIXELS, "Living Room %s visually meets the floor within 3 logical pixels (%.2f px)" % [artwork.display_name, logical_gap]) + seen_grounded_art[artwork.display_name] = true + for artwork_name: String in expected_grounded_art: + _expect(seen_grounded_art.has(artwork_name), "Living Room retains editable set artwork: %s" % artwork_name) + var prop_count: int = 0 + for node: Node2D in nodes: + if not node is PropBlock: + continue + prop_count += 1 + var prop: PropBlock = node as PropBlock + var bounds: Rect2 = prop.global_transform * prop.get_visual_local_bounds() + var logical_gap: float = absf(bounds.end.y - floor_bounds.position.y) * SAVED_ZOOM + print("METRIC Living Room prop_contact name=", prop.stage_object_id, " prop_bottom_y=", bounds.end.y, " floor_top_y=", floor_bounds.position.y, " logical_gap_px=", logical_gap) + _expect(logical_gap <= CONTACT_GAP_LOGICAL_PIXELS, "Living Room prop %s rests on the floor within 3 logical pixels (%.2f px)" % [prop.stage_object_id, logical_gap]) + _expect(bool(prop.stage_roles.get("blocks_stickmen", false)), "Living Room prop %s preserves its normal blocking role" % prop.stage_object_id) + _expect(prop_count == 3, "Living Room keeps couch, armchair, and coffee table as separate Props") + var preview_frame: Dictionary = STAGE_STARTER_CARD_PREVIEW._preview_transform_for_camera(data.get("camera", {}) as Dictionary) + _expect((preview_frame["focus_rect"] as Rect2).get_center().is_equal_approx(camera_position), "Living Room starter preview centers on its saved opening shot") + + +func _find_terrain_tiles(nodes: Array[Node2D], finish_id: StringName) -> Array[TerrainBlock]: + var result: Array[TerrainBlock] = [] + for node: Node2D in nodes: + if node.get_script() == TERRAIN_BLOCK and StringName((node as TerrainBlock).finish_id) == finish_id: + result.append(node as TerrainBlock) + return result + + +func _find_background_ground(nodes: Array[Node2D]) -> StageBackgroundGround: + for node: Node2D in nodes: + if node.get_script() == STAGE_BACKGROUND_GROUND: + return node as StageBackgroundGround + return null + + +func _test_tile_row( + tiles: Array[TerrainBlock], + center_y: float, + label: String, + expected_tile_count: int = TILE_COUNT, + expected_half_width: float = SURFACE_HALF_WIDTH +) -> Rect2: + if tiles.is_empty(): + return Rect2() + tiles.sort_custom(_terrain_precedes) + var row_bounds: Rect2 = _terrain_world_bounds(tiles[0]) + _expect(tiles.size() == expected_tile_count, "%s contains exactly %d tiles" % [label, expected_tile_count]) + for tile: TerrainBlock in tiles: + var bounds: Rect2 = _terrain_world_bounds(tile) + _expect(tile.scale.is_equal_approx(Vector2.ONE), "%s tile %s uses normal scale" % [label, tile.name]) + _expect(bounds.size.is_equal_approx(Vector2(TILE_WIDTH, TILE_HEIGHT)), "%s tile %s keeps the ordinary 192×32 footprint" % [label, tile.name]) + _expect(is_equal_approx(bounds.get_center().y, center_y), "%s tile %s stays on the shared row level" % [label, tile.name]) + row_bounds = row_bounds.merge(bounds) + for index: int in range(tiles.size() - 1): + var left_bounds: Rect2 = _terrain_world_bounds(tiles[index]) + var right_bounds: Rect2 = _terrain_world_bounds(tiles[index + 1]) + var seam_gap: float = right_bounds.position.x - left_bounds.end.x + _expect(is_zero_approx(seam_gap), "%s tiles meet exactly at seam %d (gap %.3f)" % [label, index + 1, seam_gap]) + _expect(_covers_pan_envelope(row_bounds), "%s covers ±1,000 camera pan and at least 100-unit endpoint margins" % label) + _expect(is_equal_approx(row_bounds.position.x, -expected_half_width) and is_equal_approx(row_bounds.end.x, expected_half_width), "%s spans %.0f world units" % [label, expected_half_width * 2.0]) + return row_bounds + + +func _terrain_precedes(left: TerrainBlock, right: TerrainBlock) -> bool: + return left.global_position.x < right.global_position.x + + +func _terrain_world_bounds(terrain: TerrainBlock) -> Rect2: + if terrain.polygon_points.is_empty(): + return Rect2() + var bounds: Rect2 = Rect2(terrain.to_global(terrain.polygon_points[0]), Vector2.ZERO) + for point: Vector2 in terrain.polygon_points: + bounds = bounds.expand(terrain.to_global(point)) + return bounds + + +func _covers_pan_envelope(bounds: Rect2) -> bool: + var half_view_world_width: float = REFERENCE_VIEWPORT.x / (2.0 * SAVED_ZOOM) + for camera_x: float in [-PAN_TRAVEL, 0.0, PAN_TRAVEL]: + if bounds.position.x > camera_x - half_view_world_width or bounds.end.x < camera_x + half_view_world_width: + return false + return bounds.position.x <= -SURFACE_HALF_WIDTH and bounds.end.x >= SURFACE_HALF_WIDTH + + +func _rect_union_covers_point(rects: Array[Rect2], point: Vector2) -> bool: + for rect: Rect2 in rects: + if rect.has_point(point): + return true + return false + + +func _expect(condition: bool, message: String) -> void: + _assertion_count += 1 + if not condition: + _failures.append(message) diff --git a/tests/test_phase19_starter_scene_composition.gd.uid b/tests/test_phase19_starter_scene_composition.gd.uid new file mode 100644 index 0000000..1b71648 --- /dev/null +++ b/tests/test_phase19_starter_scene_composition.gd.uid @@ -0,0 +1 @@ +uid://paxevrnrl10b