diff --git a/art/exported/environment/terrain/starter_island.glb b/art/exported/environment/terrain/starter_island.glb index d69e5b5..5e199f4 100644 Binary files a/art/exported/environment/terrain/starter_island.glb and b/art/exported/environment/terrain/starter_island.glb differ diff --git a/art/source/environment/terrain/starter_island.blend b/art/source/environment/terrain/starter_island.blend index 9b8445c..562277c 100644 Binary files a/art/source/environment/terrain/starter_island.blend and b/art/source/environment/terrain/starter_island.blend differ diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md index c00197c..f175ee9 100644 --- a/docs/ATTRIBUTION.md +++ b/docs/ATTRIBUTION.md @@ -21,7 +21,8 @@ independent team Woofmeow. arms, ears and tails, and multiple world props and decorative assets, including trees and bridges. The contributor requested to be credited as `chillnfill`. -- Tekgator: creator of the cooler-capacity and barrier-power shop icons. +- Tekgator: creator of the cooler-capacity, reel-speed, and barrier-power shop + icons. Voyager is also the original catalog porter credit in PortMaster metadata. Third-party creators are credited with their individual records below. Those @@ -124,6 +125,7 @@ locations only and must never appear in scenes or resources. | Fish art and portraits | `fish/species/`, `art/exported/fish/` | Original 2D artwork by co-owner Endeavour. | | Item icons | `items/icons/` | Original 2D artwork by co-owner Endeavour except where separately credited below. | | Cooler-capacity shop icon | `items/icons/equipment/64_cooler_plus.png` | Original artwork by Tekgator. Repository SHA-256: `b63705be5b78915753c068170323781a90e22b555b57a5b2ca9466e2726539f7`. | +| Reel-speed shop icon | `items/icons/shop/64_speed_plus.png` | Original artwork by Tekgator. Repository SHA-256: `938452798f2be505e06ce3fbef178f52459331fd3bced8763fbc2a80157125ca`. | | Barrier-power shop icon | `items/icons/shop/64_power_plus.png` | Original artwork by Tekgator. Repository SHA-256: `164d34ce152aa6d75de79b69f0be2d9074f7891057de9636678889028ff98ae7`. | | Inventory notepad | `art/ui/ui_notepad.png` | Original 2D artwork by co-owner Endeavour; integrated without a runtime external path. | | Environment textures | `art/exported/environment/textures/` | Original 2D artwork by co-owner Endeavour. | @@ -182,10 +184,13 @@ authored `mesh_style.png` filenames. ### Tekgator shop icon contribution record - Requested credit: `Tekgator`. -- Original contributions: cooler-capacity and barrier-power shop icons. +- Original contributions: cooler-capacity, reel-speed, and barrier-power shop + icons. - Repository files and SHA-256: - `items/icons/equipment/64_cooler_plus.png`: `b63705be5b78915753c068170323781a90e22b555b57a5b2ca9466e2726539f7` + - `items/icons/shop/64_speed_plus.png`: + `938452798f2be505e06ce3fbef178f52459331fd3bced8763fbc2a80157125ca` - `items/icons/shop/64_power_plus.png`: `164d34ce152aa6d75de79b69f0be2d9074f7891057de9636678889028ff98ae7` - Permission record: retain the original permission record or a later written diff --git a/items/icons/placeholder/reel_speed_upgrade.svg b/items/icons/placeholder/reel_speed_upgrade.svg deleted file mode 100644 index a92d9fb..0000000 --- a/items/icons/placeholder/reel_speed_upgrade.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/items/icons/shop/64_speed_plus.png b/items/icons/shop/64_speed_plus.png new file mode 100644 index 0000000..bd605fe Binary files /dev/null and b/items/icons/shop/64_speed_plus.png differ diff --git a/items/icons/placeholder/reel_speed_upgrade.svg.import b/items/icons/shop/64_speed_plus.png.import similarity index 64% rename from items/icons/placeholder/reel_speed_upgrade.svg.import rename to items/icons/shop/64_speed_plus.png.import index 211c08f..6d330de 100644 --- a/items/icons/placeholder/reel_speed_upgrade.svg.import +++ b/items/icons/shop/64_speed_plus.png.import @@ -2,16 +2,16 @@ importer="texture" type="CompressedTexture2D" -uid="uid://c2hal1gkmnuqa" -path="res://.godot/imported/reel_speed_upgrade.svg-6bce0cfdbf18895938d0bcb4850467c8.ctex" +uid="uid://dk6yx5cbpblo6" +path="res://.godot/imported/64_speed_plus.png-c695fb4021c17dfa94ed7aa48ce262c1.ctex" metadata={ "vram_texture": false } [deps] -source_file="res://items/icons/placeholder/reel_speed_upgrade.svg" -dest_files=["res://.godot/imported/reel_speed_upgrade.svg-6bce0cfdbf18895938d0bcb4850467c8.ctex"] +source_file="res://items/icons/shop/64_speed_plus.png" +dest_files=["res://.godot/imported/64_speed_plus.png-c695fb4021c17dfa94ed7aa48ce262c1.ctex"] [params] @@ -38,6 +38,3 @@ process/hdr_as_srgb=false process/hdr_clamp_exposure=false process/size_limit=0 detect_3d/compress_to=1 -svg/scale=1.0 -editor/scale_with_editor_scale=false -editor/convert_colors_with_editor_theme=false diff --git a/main/main.gd b/main/main.gd index 29cf997..73269b5 100644 --- a/main/main.gd +++ b/main/main.gd @@ -420,6 +420,7 @@ func _initialize_application(dedicated: bool) -> void: _test_world.get_sun(), _world_weather, _player, + Callable(_player, "get_active_gameplay_camera"), ) if not _world_time.natural_time_advanced.is_connected( _on_natural_time_advanced diff --git a/player/player.gd b/player/player.gd index 9d89a9c..b71add7 100644 --- a/player/player.gd +++ b/player/player.gd @@ -1005,7 +1005,7 @@ func _unhandled_input(event: InputEvent) -> void: if not local_control_enabled or not _is_camera_input_enabled(): return if event.is_action_pressed("toggle_free_camera"): - _set_free_camera_active(not _free_camera_active) + toggle_free_camera() get_viewport().set_input_as_handled() return @@ -1140,6 +1140,16 @@ func _set_free_camera_active(active: bool) -> void: _camera.current = local_control_enabled +func toggle_free_camera() -> void: + if not local_control_enabled: + return + _set_free_camera_active(not _free_camera_active) + + +func is_free_camera_active() -> bool: + return _free_camera_active + + func _update_free_camera_physics() -> void: if _free_camera == null or _free_camera_body == null: _set_free_camera_active(false) diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd index 1fa4dee..7f5ea20 100644 --- a/tests/art_tools_validation.gd +++ b/tests/art_tools_validation.gd @@ -85,6 +85,14 @@ func _run() -> void: assert(not toolbar.is_docked_right()) var chat_panel := chat_ui.get_node("ChatPanel") as PanelContainer var chat_height_button := chat_ui.get_node("ChatHeightButton") as Button + var chat_status := chat_ui.find_child( + "ChatStatus", true, false + ) as Label + assert(chat_status != null) + assert( + chat_status.autowrap_mode + == TextServer.AUTOWRAP_WORD_SMART + ) assert(is_zero_approx(chat_panel.position.y)) assert(is_equal_approx(chat_panel.size.x, ChatUI.MOBILE_COMPACT_WIDTH)) assert(not chat_height_button.visible) @@ -101,6 +109,17 @@ func _run() -> void: default_docks.paint_dock_right = true assert(settings_manager.apply_settings(default_docks)) await process_frame + chat_ui.call( + "_set_status", + ( + "Unknown command: /this-command-name-is-intentionally-long-" + + "enough-to-require-smart-character-wrapping" + ), + ) + await process_frame + assert(is_equal_approx(chat_panel.size.x, ChatUI.PANEL_WIDTH)) + assert(chat_status.size.x <= ChatUI.PANEL_WIDTH) + chat_ui.call("_set_status", "") assert(not chat_ui.is_docked_right()) assert(not chat_ui.is_mobile_mode()) assert(toolbar.is_docked_right()) @@ -140,6 +159,42 @@ func _run() -> void: assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) assert(chat_ui.is_open()) chat_ui.refocus_gameplay() + var quick_menu := game_ui.get_node( + "%QuickRadialMenu" + ) as QuickRadialMenu + var emote_menu := game_ui.get_node( + "%EmoteRadialMenu" + ) as EmoteRadialMenu + assert(quick_menu != null and emote_menu != null) + assert(QuickRadialMenu.ACTIONS.size() == QuickRadialMenu.SECTOR_COUNT) + assert(QuickRadialMenu.ACTIONS.has(&"freecam")) + assert(QuickRadialMenu.ACTIONS.has(&"hud")) + game_ui.call("_on_quick_action_selected", &"freecam") + assert(player.is_free_camera_active()) + assert(player.get_active_gameplay_camera().name == &"FreeCamera") + game_ui.call("_on_quick_action_selected", &"freecam") + assert(not player.is_free_camera_active()) + game_ui.call("_on_quick_action_selected", &"hud") + await process_frame + assert(game_ui.is_gameplay_hud_hidden()) + assert(chat_ui.is_hud_hidden()) + assert(not ( + game_ui.get_node("%GameplayTransientHUD") as Control + ).visible) + assert(not ( + game_ui.get_node("%ExperiencePresentation") as Control + ).visible) + quick_menu.open_menu(true) + assert(quick_menu.visible and quick_menu.is_open()) + quick_menu.close_menu() + emote_menu.open_menu(true) + assert(emote_menu.visible and emote_menu.is_open()) + emote_menu.close_menu() + chat_ui.open_chat() + await process_frame + assert(chat_ui.is_open() and chat_panel.visible) + chat_ui.refocus_gameplay() + assert(chat_ui.is_hud_hidden()) var cancel_button := InputEventJoypadButton.new() cancel_button.button_index = JOY_BUTTON_B cancel_button.pressed = true @@ -152,10 +207,16 @@ func _run() -> void: player_menu.open_section(PlayerMenu.Section.PROFILE) for _frame: int in 12: await process_frame + assert(game_ui.is_gameplay_hud_hidden()) + assert(player_menu.visible) assert(root.gui_get_focus_owner() is not LineEdit) player_menu.close_menu() for _frame: int in 12: await process_frame + game_ui.call("_on_quick_action_selected", &"hud") + await process_frame + assert(not game_ui.is_gameplay_hud_hidden()) + assert(not chat_ui.is_hud_hidden()) assert(not service.can_activate()) assert(not service.is_active() and not toolbar.visible) assert(player.bag.add_item(ArtShopStock.ART_KIT_ITEM_ID, 1)) diff --git a/tests/world_time_multiplayer_validation.gd b/tests/world_time_multiplayer_validation.gd index 6801de0..3d1c025 100644 --- a/tests/world_time_multiplayer_validation.gd +++ b/tests/world_time_multiplayer_validation.gd @@ -31,13 +31,15 @@ func _run_host() -> void: main.get_node("%WorldWeatherService") as WorldWeatherService ) assert(session.start_private_host(TEST_PORT)) + var game_ui := main.get_node("%GameUI") as CanvasLayer + var chat_ui := game_ui.get_node("%ChatUI") as ChatUI + assert(chat_ui.call("_handle_chat_command", "/weather clear")) + assert(world_weather.get_weather() == WorldWeatherService.Weather.SUNNY) world_time.synchronize_time(INITIAL_HOST_TIME) assert(is_equal_approx( world_time.get_persistent_time_hours(), INITIAL_HOST_TIME )) - world_weather.apply_authoritative_snapshot( - WorldWeatherService.Weather.RAINY, 300.0 - ) + assert(chat_ui.call("_handle_chat_command", "/weather rainy")) assert( world_weather.get_persistent_weather() == WorldWeatherService.Weather.RAINY @@ -66,9 +68,7 @@ func _run_host() -> void: assert(is_equal_approx( world_time.get_persistent_time_hours(), UPDATED_HOST_TIME )) - world_weather.apply_authoritative_snapshot( - WorldWeatherService.Weather.FOGGY, 300.0 - ) + assert(chat_ui.call("_handle_chat_command", "/weather foggy")) assert( world_weather.get_persistent_weather() == WorldWeatherService.Weather.FOGGY diff --git a/tests/world_time_validation.gd b/tests/world_time_validation.gd index 1fed6e7..c9c0028 100644 --- a/tests/world_time_validation.gd +++ b/tests/world_time_validation.gd @@ -249,6 +249,15 @@ func _validate_environment_presentation() -> void: < 0.01 ) var day_ambient_energy: float = runtime_environment.ambient_light_energy + assert(not runtime_environment.fog_enabled) + assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.35)) + var water_shader: Shader = preload( + "res://world/materials/stylized_water.gdshader" + ) + assert("fog_disabled" in water_shader.code) + assert(is_zero_approx(float( + SaltWaterMaterial.get_shader_parameter("weather_fog_amount") + ))) var day_water_brightness := float( SaltWaterMaterial.get_shader_parameter("environment_brightness") ) @@ -292,17 +301,24 @@ func _validate_environment_presentation() -> void: ) assert(night_water_tint.get_luminance() < 0.5) var night_background_energy := runtime_environment.background_energy_multiplier + var night_ambient_energy := runtime_environment.ambient_light_energy + var night_fog_light_energy := runtime_environment.fog_light_energy var night_adjustment_brightness := runtime_environment.adjustment_brightness var night_adjustment_saturation := runtime_environment.adjustment_saturation visuals.apply_weather_immediately(WorldWeatherService.Weather.FOGGY) visuals.apply_time_immediately(0.0) + assert(runtime_environment.fog_enabled) + assert(float( + SaltWaterMaterial.get_shader_parameter("weather_fog_amount") + ) > 0.99) var foggy_sky_horizon := ( runtime_sky_material.get_shader_parameter("sky_horizon_color") as Color ) assert(foggy_sky_horizon.is_equal_approx(night_horizon)) - assert(is_zero_approx(runtime_environment.fog_sky_affect)) + assert(is_equal_approx(runtime_environment.fog_sky_affect, 1.0)) + assert(is_zero_approx(runtime_environment.fog_aerial_perspective)) assert(is_equal_approx(runtime_environment.fog_depth_begin, 3.0)) - assert(is_equal_approx(runtime_environment.fog_depth_end, 28.0)) + assert(is_equal_approx(runtime_environment.fog_depth_end, 18.0)) assert(is_equal_approx( float(SaltWaterMaterial.get_shader_parameter("environment_brightness")), night_water_brightness, @@ -311,6 +327,14 @@ func _validate_environment_presentation() -> void: runtime_environment.background_energy_multiplier, night_background_energy, )) + assert(is_equal_approx( + runtime_environment.ambient_light_energy, + night_ambient_energy, + )) + assert(is_equal_approx( + runtime_environment.fog_light_energy, + night_fog_light_energy, + )) assert(is_equal_approx( runtime_environment.adjustment_brightness, night_adjustment_brightness, @@ -321,6 +345,10 @@ func _validate_environment_presentation() -> void: )) visuals.apply_weather_immediately(WorldWeatherService.Weather.SUNNY) visuals.apply_time_immediately(20.0) + assert(not runtime_environment.fog_enabled) + assert(is_zero_approx(float( + SaltWaterMaterial.get_shader_parameter("weather_fog_amount") + ))) var dusk_horizon: Color = runtime_sky_material.get_shader_parameter( "sky_horizon_color" ) as Color diff --git a/tests/world_weather_validation.gd b/tests/world_weather_validation.gd index 1035a09..09b44aa 100644 --- a/tests/world_weather_validation.gd +++ b/tests/world_weather_validation.gd @@ -24,6 +24,7 @@ func _initialize() -> void: func _run() -> void: _validate_weather_scheduler() + _validate_authoritative_weather_override() _validate_weather_persistence() _validate_snapshot_bounds() _validate_fishing_weather_seams() @@ -74,6 +75,54 @@ func _duration_is_valid(weather: WorldWeatherServiceType) -> bool: return false +func _validate_authoritative_weather_override() -> void: + var weather := WorldWeatherServiceType.new() + root.add_child(weather) + assert(not weather.set_authoritative_weather( + WorldWeatherServiceType.Weather.FOGGY + )) + weather.begin_authoritative_session(20260812) + assert(weather.set_authoritative_weather( + WorldWeatherServiceType.Weather.FOGGY + )) + assert(weather.is_foggy()) + assert(_duration_is_valid(weather)) + weather.end_session() + assert(not weather.set_authoritative_weather( + WorldWeatherServiceType.Weather.RAINY + )) + + var clock := WorldTimeServiceType.new() + root.add_child(clock) + clock.begin_session(WorldTimeServiceType.DAY_START_HOUR) + var schedule: Array[Dictionary] = [] + for index: int in WorldWeatherServiceType.DAILY_PLAN_SEGMENT_COUNT: + schedule.append({ + "start_hour": fposmod( + WorldTimeServiceType.DAY_START_HOUR + + float(index) + * WorldWeatherServiceType.DAILY_PLAN_SEGMENT_HOURS, + WorldTimeServiceType.HOURS_PER_DAY, + ), + "weather": int(WorldWeatherServiceType.Weather.SUNNY), + }) + assert(weather.configure_daily_plan("command-test", schedule, clock)) + weather.begin_authoritative_session(20260812) + assert(weather.set_authoritative_weather( + WorldWeatherServiceType.Weather.RAINY + )) + weather.advance_weather(1.0) + assert(weather.is_raining()) + clock.synchronize_time( + WorldTimeServiceType.DAY_START_HOUR + + WorldWeatherServiceType.DAILY_PLAN_SEGMENT_HOURS + ) + weather.advance_weather(0.01) + assert(weather.get_weather() == WorldWeatherServiceType.Weather.SUNNY) + weather.queue_free() + clock.queue_free() + + func _validate_weather_persistence() -> void: var weather := WorldWeatherServiceType.new() root.add_child(weather) @@ -212,20 +261,118 @@ func _validate_weather_presentation() -> void: weather.begin_remote_session() var rain_target := Node3D.new() world_root.add_child(rain_target) + var rain_camera := Camera3D.new() + world_root.add_child(rain_camera) + rain_target.global_position = Vector3(-18.0, 2.0, 14.0) + rain_camera.global_position = Vector3(24.0, 9.0, -18.0) var visuals := WorldTimeVisualControllerType.new() world_root.add_child(visuals) visuals.setup( - clock, world_environment, sun, weather, rain_target + clock, + world_environment, + sun, + weather, + rain_target, + func() -> Camera3D: return rain_camera, ) var runtime_environment: Environment = world_environment.environment + var runtime_sky_material := ( + runtime_environment.sky.sky_material as ShaderMaterial + ) + var clear_background_energy: float = ( + runtime_environment.background_energy_multiplier + ) + var clear_ambient_energy: float = runtime_environment.ambient_light_energy + var clear_fog_light_energy: float = runtime_environment.fog_light_energy + var clear_brightness: float = runtime_environment.adjustment_brightness var clear_saturation: float = runtime_environment.adjustment_saturation + var clear_sun_energy: float = sun.light_energy + var clear_water_brightness: float = float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "environment_brightness" + ) + ) + assert(not runtime_environment.fog_enabled) + assert(is_zero_approx(float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ))) + assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.35)) + assert(is_zero_approx(float( + runtime_sky_material.get_shader_parameter("cloud_opacity") + ))) + var storm_clouds := visuals.get_node("LocalStormClouds") as Node3D + assert(storm_clouds != null) + assert(storm_clouds.call("get_patch_count") == 81) + var cloud_field := storm_clouds.get_node("CloudField") as MultiMeshInstance3D + assert(cloud_field != null) + assert(cloud_field.multimesh != null) + assert(cloud_field.multimesh.instance_count == 81) + assert(cloud_field.multimesh.mesh is ArrayMesh) + var cloud_shader: Shader = preload( + "res://world/environment/local_storm_cloud.gdshader" + ) + assert("cull_disabled" in cloud_shader.code) + assert(LocalStormCloudLayer.WRAP_RADIUS >= 315.0) + assert(is_equal_approx(LocalStormCloudLayer.MAXIMUM_OPACITY, 1.0)) + assert( + LocalStormCloudLayer.DISTANCE_FADE_END + < LocalStormCloudLayer.WRAP_RADIUS + ) + assert(is_zero_approx(float(storm_clouds.call("get_storm_amount")))) visuals.apply_weather_immediately( WorldWeatherServiceType.Weather.FOGGY ) + assert(runtime_environment.fog_enabled) + assert(float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ) > 0.99) + var water_fog_color := ( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_color" + ) as Color + ) + assert(water_fog_color.get_luminance() < 0.05) assert(runtime_environment.fog_depth_begin <= 4.01) - assert(runtime_environment.fog_depth_end <= 42.01) - assert(is_zero_approx(runtime_environment.fog_sky_affect)) - assert(runtime_environment.fog_aerial_perspective >= 0.91) + assert(is_equal_approx(runtime_environment.fog_depth_end, 18.0)) + assert(is_equal_approx(runtime_environment.fog_sky_affect, 1.0)) + assert(is_zero_approx(runtime_environment.fog_aerial_perspective)) + assert(is_zero_approx(float( + runtime_sky_material.get_shader_parameter("cloud_opacity") + ))) + assert(is_equal_approx( + runtime_environment.background_energy_multiplier, + clear_background_energy + * WorldTimeVisualControllerType.FOG_DAYLIGHT_SCENE_BRIGHTNESS, + )) + assert(is_equal_approx( + runtime_environment.ambient_light_energy, + clear_ambient_energy + * WorldTimeVisualControllerType.FOG_DAYLIGHT_SCENE_BRIGHTNESS, + )) + assert(is_equal_approx( + runtime_environment.fog_light_energy, + clear_fog_light_energy + * WorldTimeVisualControllerType.FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS, + )) + assert(is_equal_approx( + runtime_environment.adjustment_brightness, + clear_brightness + * WorldTimeVisualControllerType.FOG_DAYLIGHT_POST_BRIGHTNESS, + )) + assert(is_equal_approx(sun.light_energy, clear_sun_energy * 0.30)) + assert(is_equal_approx( + float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "environment_brightness" + ) + ), + clear_water_brightness + * WorldTimeVisualControllerType.FOG_DAYLIGHT_WATER_BRIGHTNESS, + )) assert(is_equal_approx( runtime_environment.adjustment_saturation, clear_saturation, @@ -233,8 +380,41 @@ func _validate_weather_presentation() -> void: visuals.apply_weather_immediately( WorldWeatherServiceType.Weather.RAINY ) + assert(runtime_environment.fog_enabled) + assert(float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ) > 0.99) + assert(is_equal_approx(runtime_environment.fog_sky_affect, 0.68)) + assert( + float(runtime_sky_material.get_shader_parameter("cloud_coverage")) + > 0.98 + ) + assert( + float(runtime_sky_material.get_shader_parameter("cloud_opacity")) + > 0.99 + ) + assert(float(storm_clouds.call("get_storm_amount")) > 0.87) + assert(storm_clouds.visible) + assert(is_equal_approx( + runtime_environment.adjustment_brightness, + clear_brightness + * 0.92 + * WorldTimeVisualControllerType.RAIN_DAYLIGHT_POST_BRIGHTNESS, + )) var rain := visuals.get_node("LocalRain") as GPUParticles3D assert(rain != null) + assert(rain.global_position.is_equal_approx( + rain_camera.global_position + + WorldTimeVisualControllerType.RAIN_EMITTER_OFFSET + )) + rain_camera.global_position = Vector3(-31.0, 15.0, 42.0) + visuals.call("_update_rain_position") + assert(rain.global_position.is_equal_approx( + rain_camera.global_position + + WorldTimeVisualControllerType.RAIN_EMITTER_OFFSET + )) assert(rain.emitting) assert(rain.amount_ratio > 0.99) assert(rain.amount == WorldTimeVisualControllerType.RAIN_PARTICLE_AMOUNT) @@ -253,8 +433,52 @@ func _validate_weather_presentation() -> void: assert(rain_mesh.size.is_equal_approx( WorldTimeVisualControllerType.RAIN_DROP_SIZE )) + visuals.call( + "_on_weather_changed", + WorldWeatherServiceType.Weather.SUNNY, + 60.0, + ) + visuals.call( + "_process", + WorldTimeVisualControllerType.WEATHER_TRANSITION_SECONDS * 0.5, + ) + var halfway_water_fog: float = float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ) + assert(halfway_water_fog > 0.49 and halfway_water_fog < 0.51) + assert(is_equal_approx( + float( + WorldTimeVisualControllerType.FRESH_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ), + halfway_water_fog, + )) + assert(runtime_environment.fog_enabled) + visuals.call( + "_process", + WorldTimeVisualControllerType.WEATHER_TRANSITION_SECONDS * 0.5, + ) + assert(is_zero_approx(float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ))) + assert(not runtime_environment.fog_enabled) + visuals.apply_weather_immediately( + WorldWeatherServiceType.Weather.CLOUDY + ) + assert(not runtime_environment.fog_enabled) + assert(is_zero_approx(float( + WorldTimeVisualControllerType.SALT_WATER_MATERIAL.get_shader_parameter( + "weather_fog_amount" + ) + ))) visuals.apply_weather_immediately( WorldWeatherServiceType.Weather.SUNNY ) + assert(not runtime_environment.fog_enabled) assert(not rain.emitting) world_root.queue_free() diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index f50f6e6..bf38a75 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -99,6 +99,7 @@ var _opacity_tween: Tween var _height_tween: Tween var _opened: bool = false var _available: bool = false +var _hud_hidden: bool = false var _presentation_state := PresentationState.COMPACT var _visible_state_before_collapse := PresentationState.COMPACT var _panel_hovered: bool = false @@ -435,7 +436,10 @@ func _build_ui() -> void: _history.add_theme_stylebox_override("normal", _borderless_style(Color.TRANSPARENT)) box.add_child(_history) _status = Label.new() + _status.name = "ChatStatus" _status.custom_minimum_size = Vector2(0, 20) + _status.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _status.hide() _status.mouse_filter = Control.MOUSE_FILTER_IGNORE _status.add_theme_font_override("font", UtilityPageStyle.TuffyFont) @@ -770,6 +774,8 @@ func _handle_chat_command(body: String) -> bool: match command: "time": _handle_time_command(parts) + "weather": + _handle_weather_command(parts) "": _set_status("Enter a command after /.") _: @@ -814,6 +820,41 @@ func _handle_time_command(parts: PackedStringArray) -> void: close_chat() +func _handle_weather_command(parts: PackedStringArray) -> void: + if parts.size() != 2: + _set_status("Usage: /weather [clear, cloudy, rainy, foggy]") + return + if _session == null or not _session.is_host(): + _set_status("Only the host can change world weather.") + return + if _world_weather == null: + _set_status("World weather is unavailable.") + return + var weather_name := String(parts[1]).to_lower() + var target_weather: WorldWeatherServiceType.Weather + match weather_name: + "clear", "sunny": + target_weather = WorldWeatherServiceType.Weather.SUNNY + weather_name = "clear" + "cloudy": + target_weather = WorldWeatherServiceType.Weather.CLOUDY + "rainy": + target_weather = WorldWeatherServiceType.Weather.RAINY + "foggy": + target_weather = WorldWeatherServiceType.Weather.FOGGY + _: + _set_status("Usage: /weather [clear, cloudy, rainy, foggy]") + return + if not _world_weather.set_authoritative_weather(target_weather): + _set_status("World weather could not be changed.") + return + _set_status("") + _service.broadcast_system_message( + "World weather set to %s." % weather_name + ) + close_chat() + + func _on_local_message_confirmed(message: Dictionary) -> void: if ( not _send_pending @@ -956,6 +997,16 @@ func _refresh_visibility() -> void: _unread_indicator.hide() _hint.hide() return + if _hud_hidden and not _opened: + _panel.hide() + _clock_panel.hide() + _weather_icon.hide() + _fish_finder_effect_icon.hide() + _collapse_button.hide() + _height_button.hide() + _unread_indicator.hide() + _hint.hide() + return _clock_panel.show() _weather_icon.show() _fish_finder_effect_icon.visible = ( @@ -1044,6 +1095,15 @@ func is_mobile_mode() -> bool: return _mobile_mode +func set_hud_hidden(hidden: bool) -> void: + _hud_hidden = hidden + _refresh_visibility() + + +func is_hud_hidden() -> bool: + return _hud_hidden + + func _refresh_handle_labels(state: PresentationState) -> void: var collapsed := state == PresentationState.COLLAPSED var height_state := _visible_state_before_collapse if collapsed else state diff --git a/ui/fishing_shop.tscn b/ui/fishing_shop.tscn index 1fd5314..bb3c6d7 100644 --- a/ui/fishing_shop.tscn +++ b/ui/fishing_shop.tscn @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://ui/fishing_shop.gd" id="1_script"] [ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] -[ext_resource type="Texture2D" path="res://items/icons/placeholder/reel_speed_upgrade.svg" id="3_reel"] +[ext_resource type="Texture2D" path="res://items/icons/shop/64_speed_plus.png" id="3_reel"] [ext_resource type="Texture2D" path="res://items/icons/shop/64_power_plus.png" id="4_barrier"] [ext_resource type="Texture2D" path="res://items/icons/placeholder/fish_coin.svg" id="5_coin"] [ext_resource type="Texture2D" path="res://items/icons/equipment/64_cooler_plus.png" id="6_cooler"] diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 67fb162..b142e33 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -167,6 +167,7 @@ var _bag: PlayerBagType var _item_catalog: ItemCatalogType var _player_menu_open: bool = false var _gameplay_ui_enabled: bool = false +var _gameplay_hud_hidden: bool = false var _fishing_spot: FishingSpotType var _system_menu_open: bool = false var _shop_open: bool = false @@ -428,6 +429,14 @@ func _input(event: InputEvent) -> void: if _handle_controller_chat_controls(event): get_viewport().set_input_as_handled() return + if ( + event.is_action_pressed("toggle_hud") + and not (event is InputEventKey and event.echo) + and _can_toggle_gameplay_hud() + ): + set_gameplay_hud_hidden(not _gameplay_hud_hidden) + get_viewport().set_input_as_handled() + return if ( _surface_drawing_toolbar != null and _surface_drawing_toolbar.owns_pointer_event(event) @@ -624,6 +633,11 @@ func _on_quick_action_selected(action_id: StringName) -> void: _toggle_surface_drawing() &"chat": _chat_ui.open_chat() + &"freecam": + if _player != null: + _player.toggle_free_camera() + &"hud": + set_gameplay_hud_hidden(not _gameplay_hud_hidden) func _handle_virtual_mouse_input(event: InputEvent) -> bool: @@ -1401,6 +1415,7 @@ func _on_hud_active_bait_changed(_item_id: StringName) -> void: func _refresh_active_bait_indicator_visibility() -> void: _active_bait_indicator.visible = ( _gameplay_ui_enabled + and not _gameplay_hud_hidden and not _system_menu_open and not _player_menu_open and not _shop_open @@ -1413,11 +1428,12 @@ func _on_hud_bait_inventory_changed() -> void: func set_gameplay_ui_enabled(enabled: bool) -> void: _gameplay_ui_enabled = enabled + if not enabled: + _gameplay_hud_hidden = false _refresh_active_bait_indicator_visibility() if enabled: _try_start_shop_npc_speech() - _gameplay_transient_hud.visible = enabled and not _player_menu_open - _experience_presentation.visible = enabled + _refresh_gameplay_hud_visibility() _refresh_chat_availability() if not enabled: _end_virtual_mouse() @@ -1448,10 +1464,50 @@ func set_gameplay_ui_enabled(enabled: bool) -> void: call_deferred("_start_next_experience_animation") +func set_gameplay_hud_hidden(hidden: bool) -> void: + _gameplay_hud_hidden = hidden + if _quick_radial_menu != null: + _quick_radial_menu.set_hud_hidden(hidden) + _refresh_gameplay_hud_visibility() + _refresh_active_bait_indicator_visibility() + _refresh_hotbar_visibility() + _refresh_chat_availability() + + +func is_gameplay_hud_hidden() -> bool: + return _gameplay_hud_hidden + + +func _can_toggle_gameplay_hud() -> bool: + return ( + _gameplay_ui_enabled + and not _system_menu_open + and not _player_menu_open + and not _shop_open + and not _chat_input_open + and not _showcase_active + and not _emote_radial_menu.is_open() + and not _quick_radial_menu.is_open() + ) + + +func _refresh_gameplay_hud_visibility() -> void: + var show_world_hud: bool = ( + _gameplay_ui_enabled + and not _gameplay_hud_hidden + and not _system_menu_open + and not _player_menu_open + and not _shop_open + ) + _gameplay_transient_hud.visible = show_world_hud + _experience_presentation.visible = show_world_hud + + func set_system_menu_open(is_open: bool) -> void: _system_menu_open = is_open if is_open and _surface_drawing != null: _surface_drawing.deactivate() + _refresh_gameplay_hud_visibility() _refresh_chat_availability() _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled( @@ -2049,7 +2105,7 @@ func _on_player_menu_visibility_changed(is_open: bool) -> void: _end_virtual_mouse() if is_open and _surface_drawing != null: _surface_drawing.deactivate() - _gameplay_transient_hud.visible = _gameplay_ui_enabled and not is_open + _refresh_gameplay_hud_visibility() if is_open: _hotbar_ui.set_drag_enabled(false) _hotbar_ui.set_presentation_visible(false, false) @@ -2106,6 +2162,7 @@ func _on_shop_visibility_changed(is_open: bool) -> void: shop_backdrop_visibility_changed.emit(true) if not is_open and _player_menu.is_shop_cooler_mounted(): _player_menu.unmount_shop_cooler() + _refresh_gameplay_hud_visibility() _refresh_chat_availability() _refresh_hotbar_visibility() _hotbar_ui.set_gameplay_input_enabled( @@ -2229,6 +2286,10 @@ func _on_hotbar_presentation_transition_finished( func _refresh_hotbar_visibility() -> void: _hotbar_ui.set_presentation_visible( _gameplay_ui_enabled + and ( + not _gameplay_hud_hidden + or (_player_menu_open and _player_menu_hotbar_visible) + ) and not _system_menu_open and not _shop_open and ( @@ -2250,6 +2311,7 @@ func _on_chat_text_entry_ownership_changed(active: bool) -> void: _chat_input_open = active if active and _surface_drawing != null: _surface_drawing.deactivate() + _refresh_chat_availability() _emit_interactive_pointer_ui_changed() @@ -2263,4 +2325,5 @@ func _refresh_chat_availability() -> void: and not _shop_open ) _chat_ui.set_available(chat_available) + _chat_ui.set_hud_hidden(_gameplay_hud_hidden and not _chat_input_open) passive_pointer_ui_changed.emit(chat_available) diff --git a/ui/quick_radial_menu.gd b/ui/quick_radial_menu.gd index 96a9cd5..8ae1720 100644 --- a/ui/quick_radial_menu.gd +++ b/ui/quick_radial_menu.gd @@ -9,7 +9,6 @@ const BubbleButtonScene: PackedScene = preload( const BubbleProfile: BubbleMenuProfile = preload( "res://ui/components/bubble_menu/bubble_menu_profile.tres" ) -const SECTOR_COUNT: int = 8 const RING_RADIUS: float = 172.0 const BUBBLE_SIZE: Vector2 = Vector2(92.0, 88.0) const CONTROLLER_SELECTION_DEADZONE: float = 0.35 @@ -25,6 +24,8 @@ const ACTIONS: Array[StringName] = [ &"online", &"paint", &"chat", + &"freecam", + &"hud", ] const LABELS: Array[String] = [ "stuff", @@ -35,13 +36,17 @@ const LABELS: Array[String] = [ "online", "paint", "chat", + "freecam", + "hide hud", ] +const SECTOR_COUNT: int = 10 var _buttons: Array[BubbleButton] = [] var _is_open: bool = false var _selected_sector: int = 0 var _controller_selection_mode: bool = false var _controller_mapping_manager: ControllerMappingManagerType +var _hud_hidden: bool = false func setup_controller_mapping( @@ -58,7 +63,7 @@ func _ready() -> void: bubble.profile = BubbleProfile bubble.focus_mode = Control.FOCUS_NONE bubble.mouse_filter = Control.MOUSE_FILTER_IGNORE - bubble.text = LABELS[sector] + bubble.text = _label_for_sector(sector) add_child(bubble) _buttons.append(bubble) _apply_selection_styles() @@ -88,6 +93,7 @@ func open_menu(controller_selection: bool = false) -> void: _selected_sector = 0 _controller_selection_mode = controller_selection visible = true + _refresh_labels() _layout_bubbles() _apply_selection_styles() @@ -101,6 +107,22 @@ func is_open() -> bool: return _is_open +func set_hud_hidden(hidden: bool) -> void: + _hud_hidden = hidden + _refresh_labels() + + +func _refresh_labels() -> void: + for sector: int in _buttons.size(): + _buttons[sector].text = _label_for_sector(sector) + + +func _label_for_sector(sector: int) -> String: + if ACTIONS[sector] == &"hud": + return "show hud" if _hud_hidden else "hide hud" + return LABELS[sector] + + func _emit_selected_action(action_id: StringName) -> void: action_selected.emit(action_id) @@ -157,7 +179,8 @@ func _update_mouse_selection() -> void: func _select_sector_from_offset(offset: Vector2) -> void: - var angle: float = fposmod(offset.angle() + PI * 0.5 + PI / 8.0, TAU) + var half_sector: float = TAU / (float(SECTOR_COUNT) * 2.0) + var angle: float = fposmod(offset.angle() + PI * 0.5 + half_sector, TAU) var sector: int = int(floor(angle / (TAU / float(SECTOR_COUNT)))) if sector == _selected_sector: return diff --git a/ui/title_credits_page.tscn b/ui/title_credits_page.tscn index 8c210f3..a667465 100644 --- a/ui/title_credits_page.tscn +++ b/ui/title_credits_page.tscn @@ -124,7 +124,7 @@ text = "Tekgator" layout_mode = 2 theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1) theme_override_font_sizes/font_size = 15 -text = "cooler-capacity and barrier-power shop icon artwork" +text = "cooler-capacity, reel-speed, and barrier-power shop icon artwork" autowrap_mode = 2 [node name="AdditionalCredits" type="VBoxContainer" parent="Paper/Margin/Layout/Columns"] diff --git a/world/environment/local_storm_cloud.gdshader b/world/environment/local_storm_cloud.gdshader new file mode 100644 index 0000000..b5ec03b --- /dev/null +++ b/world/environment/local_storm_cloud.gdshader @@ -0,0 +1,37 @@ +shader_type spatial; +render_mode blend_mix, depth_prepass_alpha, cull_disabled, unshaded, fog_disabled, shadows_disabled; + +uniform vec4 cloud_color : source_color = vec4(0.07, 0.10, 0.12, 1.0); +uniform float weather_opacity : hint_range(0.0, 1.0, 0.01) = 0.0; +uniform float maximum_opacity : hint_range(0.0, 1.0, 0.01) = 0.5; +uniform float distance_fade_start : hint_range(50.0, 500.0, 1.0) = 240.0; +uniform float distance_fade_end : hint_range(50.0, 500.0, 1.0) = 300.0; + +varying float world_normal_height; +varying float camera_distance; + + +void vertex() { + world_normal_height = normalize(MODEL_NORMAL_MATRIX * NORMAL).y; + vec3 world_position = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + camera_distance = distance( + world_position.xz, + CAMERA_POSITION_WORLD.xz + ); +} + + +void fragment() { + float facet_light = mix( + 0.68, + 1.06, + clamp(world_normal_height * 0.5 + 0.5, 0.0, 1.0) + ); + ALBEDO = cloud_color.rgb * facet_light; + float distance_fade = 1.0 - smoothstep( + distance_fade_start, + distance_fade_end, + camera_distance + ); + ALPHA = weather_opacity * maximum_opacity * distance_fade * cloud_color.a; +} diff --git a/world/environment/local_storm_cloud.gdshader.uid b/world/environment/local_storm_cloud.gdshader.uid new file mode 100644 index 0000000..b07fd92 --- /dev/null +++ b/world/environment/local_storm_cloud.gdshader.uid @@ -0,0 +1 @@ +uid://c20wlon7hc48g diff --git a/world/environment/local_storm_cloud_layer.gd b/world/environment/local_storm_cloud_layer.gd new file mode 100644 index 0000000..2ec0008 --- /dev/null +++ b/world/environment/local_storm_cloud_layer.gd @@ -0,0 +1,228 @@ +class_name LocalStormCloudLayer +extends Node3D + +const CLOUD_SHADER: Shader = preload( + "res://world/environment/local_storm_cloud.gdshader" +) +const MIN_CLOUD_ALTITUDE: float = 15.0 +const MAX_CLOUD_ALTITUDE: float = 23.0 +const GRID_AXIS_COUNT: int = 9 +const GRID_SPACING: float = 70.0 +const FIELD_HALF_EXTENT: float = GRID_AXIS_COUNT * GRID_SPACING * 0.5 +const DISTANCE_FADE_START: float = 240.0 +const DISTANCE_FADE_END: float = 300.0 +const WRAP_RADIUS: float = FIELD_HALF_EXTENT +const MAXIMUM_OPACITY: float = 1.0 +const BASE_VELOCITY := Vector2(0.825, -0.45) +const BODY_OUTLINE: Array[Vector2] = [ + Vector2(-6.9, -0.8), + Vector2(-6.6, -2.0), + Vector2(-5.7, -3.0), + Vector2(-4.5, -3.5), + Vector2(-3.0, -3.7), + Vector2(-1.8, -4.2), + Vector2(-0.5, -4.3), + Vector2(0.7, -4.0), + Vector2(2.0, -4.1), + Vector2(3.0, -3.5), + Vector2(4.6, -3.3), + Vector2(5.8, -2.5), + Vector2(6.6, -1.5), + Vector2(7.0, -0.2), + Vector2(6.8, 1.0), + Vector2(6.1, 1.9), + Vector2(6.0, 2.9), + Vector2(5.1, 3.7), + Vector2(3.8, 4.0), + Vector2(2.7, 4.7), + Vector2(1.4, 4.9), + Vector2(0.2, 5.3), + Vector2(-1.2, 5.0), + Vector2(-2.1, 4.4), + Vector2(-3.6, 4.2), + Vector2(-4.9, 3.5), + Vector2(-5.4, 2.5), + Vector2(-6.3, 1.7), + Vector2(-6.8, 0.6), +] + +var _target: Node3D +var _material: ShaderMaterial +var _multimesh: MultiMesh +var _cloud_mesh: MultiMeshInstance3D +var _drift_offset := Vector2.ZERO +var _storm_amount: float = 0.0 + + +func setup(target: Node3D) -> void: + _target = target + _build_cloud_field() + _update_cloud_transforms() + set_process(true) + + +func set_storm_amount(amount: float, color: Color) -> void: + _storm_amount = clampf(amount, 0.0, 1.0) + var should_be_visible: bool = _storm_amount > 0.001 + if should_be_visible and not visible: + _update_cloud_transforms() + visible = should_be_visible + if _material == null: + return + _material.set_shader_parameter("weather_opacity", _storm_amount) + _material.set_shader_parameter("cloud_color", color) + + +func get_storm_amount() -> float: + return _storm_amount + + +func get_patch_count() -> int: + return GRID_AXIS_COUNT * GRID_AXIS_COUNT + + +func _process(delta: float) -> void: + if not visible or _target == null or not is_instance_valid(_target): + return + _drift_offset += BASE_VELOCITY * delta + _drift_offset = Vector2( + _wrap_axis(_drift_offset.x), + _wrap_axis(_drift_offset.y), + ) + _update_cloud_transforms() + + +func _build_cloud_field() -> void: + _material = ShaderMaterial.new() + _material.shader = CLOUD_SHADER + _material.set_shader_parameter("weather_opacity", 0.0) + _material.set_shader_parameter("maximum_opacity", MAXIMUM_OPACITY) + _material.set_shader_parameter("distance_fade_start", DISTANCE_FADE_START) + _material.set_shader_parameter("distance_fade_end", DISTANCE_FADE_END) + + var cloud_body_mesh: ArrayMesh = _build_cloud_body_mesh() + cloud_body_mesh.surface_set_material(0, _material) + + _multimesh = MultiMesh.new() + _multimesh.transform_format = MultiMesh.TRANSFORM_3D + _multimesh.mesh = cloud_body_mesh + _multimesh.instance_count = get_patch_count() + _multimesh.custom_aabb = AABB( + Vector3( + -FIELD_HALF_EXTENT - 60.0, + MIN_CLOUD_ALTITUDE - 5.0, + -FIELD_HALF_EXTENT - 60.0, + ), + Vector3( + (FIELD_HALF_EXTENT + 60.0) * 2.0, + MAX_CLOUD_ALTITUDE - MIN_CLOUD_ALTITUDE + 10.0, + (FIELD_HALF_EXTENT + 60.0) * 2.0, + ), + ) + + _cloud_mesh = MultiMeshInstance3D.new() + _cloud_mesh.name = "CloudField" + _cloud_mesh.multimesh = _multimesh + _cloud_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + add_child(_cloud_mesh) + visible = false + + +func _build_cloud_body_mesh() -> ArrayMesh: + var outline := PackedVector2Array(BODY_OUTLINE) + var surface_tool := SurfaceTool.new() + surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES) + var face_indices: PackedInt32Array = Geometry2D.triangulate_polygon( + outline + ) + for triangle_offset: int in range(0, face_indices.size(), 3): + var point_a: Vector2 = outline[face_indices[triangle_offset]] + var point_b: Vector2 = outline[face_indices[triangle_offset + 1]] + var point_c: Vector2 = outline[face_indices[triangle_offset + 2]] + _add_oriented_triangle( + surface_tool, + _to_cloud_vertex(point_a), + _to_cloud_vertex(point_b), + _to_cloud_vertex(point_c), + Vector3.DOWN, + ) + surface_tool.index() + return surface_tool.commit() + + +func _to_cloud_vertex(point: Vector2) -> Vector3: + return Vector3(point.x, 0.0, point.y) + + +func _add_oriented_triangle( + surface_tool: SurfaceTool, + point_a: Vector3, + point_b: Vector3, + point_c: Vector3, + expected_normal: Vector3, +) -> void: + if (point_b - point_a).cross(point_c - point_a).dot(expected_normal) < 0.0: + var swap: Vector3 = point_b + point_b = point_c + point_c = swap + for point: Vector3 in [point_a, point_b, point_c]: + surface_tool.set_normal(expected_normal) + surface_tool.add_vertex(point) + + +func _update_cloud_transforms() -> void: + if _target == null or _multimesh == null: + return + global_position = _target.global_position + var target_xz := Vector2( + _target.global_position.x, + _target.global_position.z, + ) + var grid_center: float = float(GRID_AXIS_COUNT - 1) * 0.5 + var instance_index: int = 0 + for grid_z: int in GRID_AXIS_COUNT: + for grid_x: int in GRID_AXIS_COUNT: + var patch_index: int = grid_z * GRID_AXIS_COUNT + grid_x + var world_offset := Vector2( + (float(grid_x) - grid_center) * GRID_SPACING, + (float(grid_z) - grid_center) * GRID_SPACING, + ) + _drift_offset + var relative := Vector2( + _wrap_axis(world_offset.x - target_xz.x), + _wrap_axis(world_offset.y - target_xz.y), + ) + var altitude: float = lerpf( + MIN_CLOUD_ALTITUDE, + MAX_CLOUD_ALTITUDE, + float(patch_index % 4) / 3.0, + ) + var patch_scale: float = 2.20 + float( + (patch_index * 7) % 10 + ) * 0.10 + var width_variation: float = 0.90 + float( + (patch_index * 5) % 7 + ) * 0.04 + var depth_variation: float = 0.90 + float( + (patch_index * 3 + 2) % 7 + ) * 0.04 + var patch_basis := Basis( + Vector3.UP, + deg_to_rad(float(patch_index * 17 % 41) - 20.0), + ).scaled(Vector3( + patch_scale * 2.35 * width_variation, + patch_scale * 0.34, + patch_scale * 2.65 * depth_variation, + )) + _multimesh.set_instance_transform( + instance_index, + Transform3D( + patch_basis, + Vector3(relative.x, altitude, relative.y), + ), + ) + instance_index += 1 + + +func _wrap_axis(value: float) -> float: + return fposmod(value + FIELD_HALF_EXTENT, FIELD_HALF_EXTENT * 2.0) \ + - FIELD_HALF_EXTENT diff --git a/world/environment/local_storm_cloud_layer.gd.uid b/world/environment/local_storm_cloud_layer.gd.uid new file mode 100644 index 0000000..d274b64 --- /dev/null +++ b/world/environment/local_storm_cloud_layer.gd.uid @@ -0,0 +1 @@ +uid://krinusur24lh diff --git a/world/environment/netfishing_environment.tres b/world/environment/netfishing_environment.tres index 747d3ce..4f625e4 100644 --- a/world/environment/netfishing_environment.tres +++ b/world/environment/netfishing_environment.tres @@ -10,7 +10,7 @@ ambient_light_source = 2 ambient_light_color = Color(0.82, 0.9, 0.92, 1) ambient_light_energy = 1.08 reflected_light_source = 2 -fog_enabled = true +fog_enabled = false fog_mode = 1 fog_light_color = Color(0.54902, 0.74902, 0.764706, 1) fog_light_energy = 0.82 diff --git a/world/environment/netfishing_sky.gdshader b/world/environment/netfishing_sky.gdshader index bff5c56..f7f971b 100644 --- a/world/environment/netfishing_sky.gdshader +++ b/world/environment/netfishing_sky.gdshader @@ -1,5 +1,7 @@ shader_type sky; +#include "res://world/environment/netfishing_sky_clouds.gdshaderinc" + uniform vec4 sky_top_color : source_color = vec4(0.204, 0.498, 0.643, 1.0); uniform vec4 sky_horizon_color : source_color = vec4(0.663, 0.843, 0.847, 1.0); uniform vec4 ground_bottom_color : source_color = vec4(0.157, 0.361, 0.439, 1.0); @@ -83,6 +85,7 @@ void sky() { base_color = mix( base_color, moon_color.rgb, moon_disc * moon_visible_strength ); + base_color = apply_weather_clouds(base_color, view_direction); COLOR = base_color; } diff --git a/world/environment/netfishing_sky_clouds.gdshaderinc b/world/environment/netfishing_sky_clouds.gdshaderinc new file mode 100644 index 0000000..d0cbbfb --- /dev/null +++ b/world/environment/netfishing_sky_clouds.gdshaderinc @@ -0,0 +1,70 @@ +uniform float cloud_coverage : hint_range(0.0, 1.0, 0.01) = 0.0; +uniform float cloud_opacity : hint_range(0.0, 1.0, 0.01) = 0.0; +uniform vec4 cloud_light_color : source_color = vec4(0.70, 0.76, 0.77, 1.0); +uniform vec4 cloud_shadow_color : source_color = vec4(0.31, 0.38, 0.40, 1.0); +uniform vec2 cloud_wind = vec2(0.010, -0.006); +uniform float cloud_scale : hint_range(0.1, 3.0, 0.05) = 0.72; + + +float cloud_hash(vec2 point) { + return fract(sin(dot(point, vec2(127.1, 311.7))) * 43758.5453); +} + + +float cloud_value_noise(vec2 point) { + vec2 cell = floor(point); + vec2 local = fract(point); + vec2 blend = local * local * (3.0 - 2.0 * local); + float bottom = mix( + cloud_hash(cell), + cloud_hash(cell + vec2(1.0, 0.0)), + blend.x + ); + float top = mix( + cloud_hash(cell + vec2(0.0, 1.0)), + cloud_hash(cell + vec2(1.0, 1.0)), + blend.x + ); + return mix(bottom, top, blend.y); +} + + +float cloud_shape(vec2 point) { + vec2 broad_point = floor(point * 5.0) / 5.0; + vec2 detail_point = floor(point * 13.0) / 13.0; + float broad = cloud_value_noise(broad_point * 0.62); + float detail = cloud_value_noise(detail_point * 1.55 + vec2(17.3, 9.1)); + float combined = broad * 0.72 + detail * 0.28; + return floor(combined * 7.0 + 0.5) / 7.0; +} + + +vec3 apply_weather_clouds(vec3 base_color, vec3 view_direction) { + if (cloud_opacity <= 0.001 || view_direction.y <= 0.0) { + return base_color; + } + float safe_height = max(view_direction.y, 0.08); + vec2 cloud_plane = view_direction.xz / safe_height; + vec2 cloud_point = ( + cloud_plane + cloud_wind * TIME + ) * cloud_scale; + float shape = cloud_shape(cloud_point); + float threshold = mix(0.82, 0.27, cloud_coverage); + float mask = smoothstep(threshold - 0.08, threshold + 0.08, shape); + mask = floor(mask * 4.0 + 0.5) / 4.0; + mask *= smoothstep(0.025, 0.14, view_direction.y); + mask *= cloud_opacity; + float body_depth = smoothstep(threshold, 1.0, shape); + float underside = clamp( + (1.0 - smoothstep(0.08, 0.72, view_direction.y)) * 0.35 + + body_depth * 0.65, + 0.0, + 1.0 + ); + vec3 cloud_color = mix( + cloud_light_color.rgb, + cloud_shadow_color.rgb, + underside + ); + return mix(base_color, cloud_color, mask); +} diff --git a/world/environment/netfishing_sky_clouds.gdshaderinc.uid b/world/environment/netfishing_sky_clouds.gdshaderinc.uid new file mode 100644 index 0000000..cf79ca0 --- /dev/null +++ b/world/environment/netfishing_sky_clouds.gdshaderinc.uid @@ -0,0 +1 @@ +uid://cuqo54hgbjcrf diff --git a/world/materials/stylized_water.gdshader b/world/materials/stylized_water.gdshader index 7aa0b19..ea13815 100644 --- a/world/materials/stylized_water.gdshader +++ b/world/materials/stylized_water.gdshader @@ -57,6 +57,11 @@ uniform float distant_alpha_start : hint_range(10.0, 2000.0, 1.0) = 260.0; uniform float distant_alpha_end : hint_range(20.0, 5000.0, 1.0) = 1400.0; uniform vec4 environment_tint : source_color = vec4(1.0); uniform float environment_brightness : hint_range(0.1, 1.2, 0.01) = 1.0; +uniform vec4 weather_fog_color : source_color = vec4(0.025, 0.038, 0.045, 1.0); +uniform float weather_fog_amount : hint_range(0.0, 1.0, 0.01) = 0.0; +uniform float weather_fog_begin : hint_range(0.0, 500.0, 0.5) = 20.0; +uniform float weather_fog_end : hint_range(1.0, 1000.0, 0.5) = 95.0; +uniform float weather_fog_curve : hint_range(0.1, 4.0, 0.01) = 1.4; varying vec3 world_position; varying float surface_view_depth; @@ -569,6 +574,22 @@ void fragment() { visible_highlight_color, clamp(visible_highlight, 0.0, 1.0) ); + float weather_fog_distance = smoothstep( + weather_fog_begin, + max(weather_fog_end, weather_fog_begin + 1.0), + surface_view_depth + ); + weather_fog_distance = pow( + clamp(weather_fog_distance, 0.0, 1.0), + weather_fog_curve + ); + float weather_fog_mix = weather_fog_amount * weather_fog_distance; + water_color = mix( + water_color, + weather_fog_color.rgb, + weather_fog_mix + ); + water_alpha = mix(water_alpha, 0.96, weather_fog_mix); ALBEDO = water_color; ALPHA = clamp(water_alpha, 0.1, 0.96); diff --git a/world/world_time_visual_controller.gd b/world/world_time_visual_controller.gd index c6a9ed4..63024ff 100644 --- a/world/world_time_visual_controller.gd +++ b/world/world_time_visual_controller.gd @@ -9,12 +9,21 @@ const RAIN_PARTICLE_AMOUNT: int = 560 const RAIN_VELOCITY_MIN: float = 16.0 const RAIN_VELOCITY_MAX: float = 20.0 const RAIN_DROP_SIZE := Vector3(0.014, 0.34, 0.014) +const FOG_DAYLIGHT_SCENE_BRIGHTNESS: float = 0.42 +const FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS: float = 0.42 +const FOG_DAYLIGHT_WATER_BRIGHTNESS: float = 0.42 +const FOG_DAYLIGHT_POST_BRIGHTNESS: float = 0.50 +const RAIN_DAYLIGHT_POST_BRIGHTNESS: float = 0.52 +const WATER_FOG_COLOR := Color(0.025, 0.038, 0.045) const SALT_WATER_MATERIAL: ShaderMaterial = preload( "res://world/materials/stylized_water.tres" ) const FRESH_WATER_MATERIAL: ShaderMaterial = preload( "res://world/materials/stylized_water_fresh.tres" ) +const LocalStormCloudLayerType = preload( + "res://world/environment/local_storm_cloud_layer.gd" +) const DAY_SKY_TOP := Color(0.204, 0.498, 0.643) const DAY_SKY_HORIZON := Color(0.663, 0.843, 0.847) @@ -44,15 +53,27 @@ const WARM_SUN := Color(1.0, 0.58, 0.30) const WARM_WATER_TINT := Color(0.78, 0.62, 0.58) const WARM_SUN_DISC := Color(1.0, 0.45, 0.16) const MOON_DISC := Color(0.78, 0.88, 1.0) +const DAY_CLOUD_LIGHT := Color(0.70, 0.76, 0.77) +const DAY_CLOUD_SHADOW := Color(0.31, 0.38, 0.40) +const NIGHT_CLOUD_LIGHT := Color(0.18, 0.22, 0.30) +const NIGHT_CLOUD_SHADOW := Color(0.06, 0.09, 0.14) +const WARM_CLOUD_LIGHT := Color(0.76, 0.55, 0.45) +const WARM_CLOUD_SHADOW := Color(0.31, 0.23, 0.24) +const STORM_CLOUD_SHADOW := Color(0.08, 0.12, 0.14) +const DAY_LOWER_CLOUD := Color(0.22, 0.25, 0.26) +const NIGHT_LOWER_CLOUD := Color(0.08, 0.10, 0.14) +const WARM_LOWER_CLOUD := Color(0.22, 0.16, 0.16) var _time_service: WorldTimeService var _weather_service: WorldWeatherService var _world_environment: WorldEnvironment var _sun: DirectionalLight3D var _rain_target: Node3D +var _rain_camera_provider: Callable var _environment: Environment var _sky_material: ShaderMaterial var _rain: GPUParticles3D +var _storm_clouds: LocalStormCloudLayer var _elapsed: float = 0.0 var _weather_from: WorldWeatherService.Weather = ( WorldWeatherService.Weather.SUNNY @@ -69,16 +90,19 @@ func setup( sun: DirectionalLight3D, weather_service: WorldWeatherService = null, rain_target: Node3D = null, + rain_camera_provider: Callable = Callable(), ) -> void: _time_service = time_service _weather_service = weather_service _world_environment = world_environment _sun = sun _rain_target = rain_target + _rain_camera_provider = rain_camera_provider if not _prepare_runtime_environment(): set_process(false) return _prepare_rain() + _prepare_storm_clouds() if _weather_service != null: _weather_from = _weather_service.get_weather() _weather_to = _weather_from @@ -184,6 +208,13 @@ func _prepare_rain() -> void: _update_rain_position() +func _prepare_storm_clouds() -> void: + _storm_clouds = LocalStormCloudLayerType.new() + _storm_clouds.name = "LocalStormClouds" + add_child(_storm_clouds) + _storm_clouds.setup(_rain_target) + + func _apply_time(time_hours: float) -> void: if _environment == null or _sky_material == null or _sun == null: return @@ -220,6 +251,19 @@ func _apply_time(time_hours: float) -> void: var fog_color: Color = _blended_color( NIGHT_FOG, DAY_FOG, WARM_FOG, daylight, warmth ) + var fog_daylight_amount: float = ( + daylight * _weather_value(0.0, 0.0, 0.0, 1.0) + ) + var rain_daylight_amount: float = ( + daylight * _weather_value(0.0, 0.0, 1.0, 0.0) + ) + var weather_fog_amount: float = _weather_value(0.0, 0.0, 1.0, 1.0) + _environment.fog_enabled = weather_fog_amount > 0.001 + var fog_scene_brightness: float = lerpf( + 1.0, + FOG_DAYLIGHT_SCENE_BRIGHTNESS, + fog_daylight_amount, + ) _sky_material.set_shader_parameter( "sky_top_color", sky_top ) @@ -235,24 +279,34 @@ func _apply_time(time_hours: float) -> void: "ground_horizon_color", ground_horizon, ) + _apply_sky_clouds(daylight, warmth) _environment.background_energy_multiplier = ( lerpf(0.52, 0.85, daylight) * _weather_value(1.0, 0.82, 0.66, 1.0) + * fog_scene_brightness ) _environment.ambient_light_color = ambient _environment.ambient_light_energy = ( lerpf(0.66, 1.08, daylight) - * _weather_value(1.0, 0.88, 0.76, 0.82) + * _weather_value(1.0, 0.88, 0.76, 1.0) + * fog_scene_brightness ) _environment.fog_light_color = fog_color _environment.fog_light_energy = ( lerpf(0.56, 0.82, daylight) - * _weather_value(1.0, 0.92, 0.82, 1.05) + * _weather_value(1.0, 0.92, 0.82, 1.0) + * lerpf( + 1.0, + FOG_DAYLIGHT_FOG_LIGHT_BRIGHTNESS, + fog_daylight_amount, + ) ) _environment.fog_aerial_perspective = _weather_value( - 0.35, 0.48, 0.62, 0.92 + 0.35, 0.48, 0.62, 0.0 + ) + _environment.fog_sky_affect = _weather_value( + 0.35, 0.48, 0.68, 1.0 ) - _environment.fog_sky_affect = 0.0 _environment.fog_depth_curve = _weather_value( 1.6, 1.5, 1.4, 1.18 ) @@ -260,12 +314,22 @@ func _apply_time(time_hours: float) -> void: 42.0, 32.0, 20.0, 3.0 ) _environment.fog_depth_end = _weather_value( - 170.0, 140.0, 95.0, 28.0 + 170.0, 140.0, 95.0, 18.0 ) - _apply_water_environment(daylight, warmth) + _apply_water_environment(daylight, warmth, weather_fog_amount) _environment.adjustment_brightness = ( lerpf(0.93, 0.98, daylight) * _weather_value(1.0, 0.97, 0.92, 1.0) + * lerpf( + 1.0, + FOG_DAYLIGHT_POST_BRIGHTNESS, + fog_daylight_amount, + ) + * lerpf( + 1.0, + RAIN_DAYLIGHT_POST_BRIGHTNESS, + rain_daylight_amount, + ) ) _environment.adjustment_saturation = ( lerpf(0.82, 0.91, daylight) @@ -299,7 +363,7 @@ func _apply_time(time_hours: float) -> void: ) _sun.light_energy = ( lerpf(0.0, 0.10, daylight) - * _weather_value(1.0, 0.55, 0.25, 0.35) + * _weather_value(1.0, 0.55, 0.25, 0.30) ) _update_rain_amount() @@ -307,6 +371,7 @@ func _apply_time(time_hours: float) -> void: func _apply_water_environment( daylight: float, warmth: float, + weather_fog_amount: float, ) -> void: var water_tint := _blended_color( NIGHT_WATER_TINT, @@ -318,6 +383,11 @@ func _apply_water_environment( var water_brightness := ( lerpf(0.34, 1.0, daylight) * _weather_value(1.0, 0.90, 0.78, 1.0) + * lerpf( + 1.0, + FOG_DAYLIGHT_WATER_BRIGHTNESS, + daylight * _weather_value(0.0, 0.0, 0.0, 1.0), + ) ) for material: ShaderMaterial in [ SALT_WATER_MATERIAL, @@ -328,6 +398,68 @@ func _apply_water_environment( "environment_brightness", water_brightness, ) + material.set_shader_parameter("weather_fog_color", WATER_FOG_COLOR) + material.set_shader_parameter( + "weather_fog_amount", + weather_fog_amount, + ) + material.set_shader_parameter( + "weather_fog_begin", + _weather_value(42.0, 32.0, 20.0, 3.0), + ) + material.set_shader_parameter( + "weather_fog_end", + _weather_value(170.0, 140.0, 95.0, 18.0), + ) + material.set_shader_parameter( + "weather_fog_curve", + _weather_value(1.6, 1.5, 1.4, 1.18), + ) + + +func _apply_sky_clouds(daylight: float, warmth: float) -> void: + var cloud_light := _blended_color( + NIGHT_CLOUD_LIGHT, + DAY_CLOUD_LIGHT, + WARM_CLOUD_LIGHT, + daylight, + warmth, + ) + var cloud_shadow := _blended_color( + NIGHT_CLOUD_SHADOW, + DAY_CLOUD_SHADOW, + WARM_CLOUD_SHADOW, + daylight, + warmth, + ) + var storm_amount: float = _weather_value(0.0, 0.18, 0.55, 0.0) + cloud_light = cloud_light.lerp(cloud_shadow, storm_amount * 0.35) + cloud_shadow = cloud_shadow.lerp( + STORM_CLOUD_SHADOW, + storm_amount * 0.10, + ) + var lower_cloud_color := _blended_color( + NIGHT_LOWER_CLOUD, + DAY_LOWER_CLOUD, + WARM_LOWER_CLOUD, + daylight, + warmth, + ) + _sky_material.set_shader_parameter( + "cloud_coverage", + _weather_value(0.0, 0.58, 0.985, 0.0), + ) + _sky_material.set_shader_parameter( + "cloud_opacity", + _weather_value(0.0, 0.76, 1.0, 0.0), + ) + _sky_material.set_shader_parameter("cloud_light_color", cloud_light) + _sky_material.set_shader_parameter("cloud_shadow_color", cloud_shadow) + if _storm_clouds != null: + _storm_clouds.set_storm_amount( + _weather_value(0.0, 0.0, 1.0, 0.0), + lower_cloud_color, + ) func _on_weather_changed( @@ -382,7 +514,14 @@ func _update_rain_amount() -> void: func _update_rain_position() -> void: if _rain == null or _rain_target == null: return - _rain.global_position = _rain_target.global_position + RAIN_EMITTER_OFFSET + var anchor_position: Vector3 = _rain_target.global_position + if _rain_camera_provider.is_valid(): + var camera_candidate: Variant = _rain_camera_provider.call() + if camera_candidate is Camera3D: + var active_camera := camera_candidate as Camera3D + if is_instance_valid(active_camera): + anchor_position = active_camera.global_position + _rain.global_position = anchor_position + RAIN_EMITTER_OFFSET func _daylight_amount(hour: float) -> float: diff --git a/world/world_weather_service.gd b/world/world_weather_service.gd index fd09d60..4fce78e 100644 --- a/world/world_weather_service.gd +++ b/world/world_weather_service.gd @@ -30,6 +30,8 @@ var _rng := RandomNumberGenerator.new() var _daily_plan_id: String = "" var _daily_schedule: Array[Dictionary] = [] var _world_time: WorldTimeService +var _manual_override_weather: Weather = DEFAULT_WEATHER +var _manual_override_segment_index: int = -1 func _ready() -> void: @@ -44,6 +46,7 @@ func begin_authoritative_session(seed_value: int) -> void: _rng.seed = seed_value _running_authority = true set_process(true) + _clear_manual_override() if _daily_schedule.is_empty() or _world_time == null: if _has_persistent_state: _set_weather( @@ -64,12 +67,14 @@ func begin_authoritative_session(seed_value: int) -> void: func begin_remote_session() -> void: _running_authority = false set_process(false) + _clear_manual_override() _set_weather(DEFAULT_WEATHER, SUNNY_DURATION_RANGE.x, true) func end_session() -> void: _running_authority = false set_process(false) + _clear_manual_override() _set_weather(DEFAULT_WEATHER, SUNNY_DURATION_RANGE.x, true) @@ -98,6 +103,22 @@ func apply_authoritative_snapshot( _set_weather(weather, maxf(seconds_remaining, 0.0), false) +func set_authoritative_weather(weather: Weather) -> bool: + if not _running_authority or not is_valid_weather(int(weather)): + return false + var duration: float = _roll_duration(weather) + if not _daily_schedule.is_empty() and _world_time != null: + _manual_override_weather = weather + _manual_override_segment_index = _current_daily_segment_index() + duration = _current_daily_segment_seconds_remaining( + _manual_override_segment_index + ) + else: + _clear_manual_override() + _set_weather(weather, duration, true) + return true + + func set_persistence_tracking_enabled(enabled: bool) -> void: if _persistence_tracking_enabled == enabled: return @@ -181,6 +202,7 @@ func configure_daily_plan( return false _daily_plan_id = plan_id _daily_schedule.clear() + _clear_manual_override() for value: Variant in schedule: _daily_schedule.append((value as Dictionary).duplicate(true)) _world_time = world_time @@ -193,6 +215,7 @@ func clear_daily_plan() -> void: _daily_plan_id = "" _daily_schedule.clear() _world_time = null + _clear_manual_override() func get_daily_plan_id() -> String: @@ -327,21 +350,43 @@ func _duration_range(weather: Weather) -> Vector2: func _update_scheduled_weather(force_emit: bool) -> void: if _daily_schedule.is_empty() or _world_time == null: return + var segment_index: int = _current_daily_segment_index() + var entry: Dictionary = _daily_schedule[segment_index] + var weather: Weather = int(entry.get("weather", DEFAULT_WEATHER)) as Weather + if _manual_override_segment_index == segment_index: + weather = _manual_override_weather + elif _manual_override_segment_index >= 0: + _clear_manual_override() + var seconds_remaining: float = _current_daily_segment_seconds_remaining( + segment_index + ) + _set_weather(weather, seconds_remaining, force_emit) + + +func _current_daily_segment_index() -> int: var elapsed_hours: float = fposmod( _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR, WorldTimeService.HOURS_PER_DAY, ) - var segment_hours: float = DAILY_PLAN_SEGMENT_HOURS - var segment_index: int = clampi( - floori(elapsed_hours / segment_hours), + return clampi( + floori(elapsed_hours / DAILY_PLAN_SEGMENT_HOURS), 0, _daily_schedule.size() - 1, ) - var entry: Dictionary = _daily_schedule[segment_index] - var weather: Weather = int(entry.get("weather", DEFAULT_WEATHER)) as Weather - var next_boundary: float = float(segment_index + 1) * segment_hours - var hours_remaining: float = maxf(next_boundary - elapsed_hours, 0.0) - var seconds_remaining: float = ( - hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND + + +func _current_daily_segment_seconds_remaining(segment_index: int) -> float: + var elapsed_hours: float = fposmod( + _world_time.get_time_hours() - WorldTimeService.DAY_START_HOUR, + WorldTimeService.HOURS_PER_DAY, ) - _set_weather(weather, seconds_remaining, force_emit) + var next_boundary: float = ( + float(segment_index + 1) * DAILY_PLAN_SEGMENT_HOURS + ) + var hours_remaining: float = maxf(next_boundary - elapsed_hours, 0.0) + return hours_remaining / WorldTimeService.HOURS_PER_REAL_SECOND + + +func _clear_manual_override() -> void: + _manual_override_weather = DEFAULT_WEATHER + _manual_override_segment_index = -1