forked from woofmeow/straywild
Compare commits
13 commits
main
...
fix/untrim
| Author | SHA1 | Date | |
|---|---|---|---|
| 91b54e4a01 | |||
|
|
c20bb35929 | ||
|
|
b1061f0165 | ||
|
|
00ec796c7a | ||
|
|
97c74323d8 | ||
| 876c243a8b | |||
| 9649cc8623 | |||
| 4731bfae61 | |||
| 3d34ed0bee | |||
| 5d55afcc26 | |||
| b82f6fe15c | |||
| 86372a652e | |||
| 2710544070 |
179 changed files with 6820 additions and 550 deletions
BIN
audio/music/world/tulips.wav
Normal file
BIN
audio/music/world/tulips.wav
Normal file
Binary file not shown.
24
audio/music/world/tulips.wav.import
Normal file
24
audio/music/world/tulips.wav.import
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://5hgmht4bq1my"
|
||||
path="res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://audio/music/world/tulips.wav"
|
||||
dest_files=["res://.godot/imported/tulips.wav-7c18a300a62f25396913efb28c2ba3f6.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
|
|
@ -28,18 +28,21 @@ directory. The templates in `scripts/portmaster/` are authoritative.
|
|||
|
||||
- `port.json` uses schema version 4, names `netfishing.zip`, and marks the
|
||||
self-contained package ready to run.
|
||||
- `NETfishing.sh` starts with `# PORTMASTER: netfishing.zip, NETfishing.sh`.
|
||||
- `NETfishing.sh` does not contain a `# PORTMASTER:` signature in the source or
|
||||
distributable archive. HarbourMaster adds that installation marker when it
|
||||
installs the port.
|
||||
- The executable is `netfishing/NETfishing.aarch64`.
|
||||
- The package declares AArch64, two analog sticks, GLIBC 2.28, and
|
||||
`weston_pkg_0.2.squashfs`.
|
||||
- The launcher starts GPTOKEYB with the explicit `netfishing.gptk` no-op map.
|
||||
Every keyboard and mouse binding is disabled, leaving only PortMaster's
|
||||
Every keyboard and mouse binding is disabled, leaving only GPTOKEYB's
|
||||
device-specific force-quit chord while Godot and NETfishing's controller
|
||||
mapping manager handle gameplay input.
|
||||
mapping manager handle gameplay input. The explicit map also avoids the
|
||||
anomalous controller behavior observed on ROCKNIX without a passthrough map.
|
||||
- The launcher calls `pm_platform_helper` for the game executable after
|
||||
starting GPTOKEYB and calls `pm_finish` after the game exits.
|
||||
- New installations automatically create player data under
|
||||
`ports/saves/netfishing/` and do not present the in-game folder picker.
|
||||
- NETfishing automatically creates player data for new installations under
|
||||
`ports/saves/netfishing/` and does not present the in-game folder picker.
|
||||
- Existing installations with a selected data root keep that location when
|
||||
upgraded. Device-local data, configuration, and cache remain under
|
||||
`netfishing/conf/data`, `netfishing/conf/config`, and
|
||||
|
|
@ -103,15 +106,15 @@ Before upgrading an existing installation, preserve
|
|||
device. After installation:
|
||||
|
||||
1. Confirm the installed executable and PCK hashes match the staged release.
|
||||
2. Confirm the installed launcher contains the canonical PortMaster header,
|
||||
starts GPTOKEYB with `netfishing.gptk`, and calls
|
||||
`pm_platform_helper` for the game executable.
|
||||
2. Confirm HarbourMaster added its `# PORTMASTER:` installation signature to
|
||||
the installed launcher, then confirm the launcher starts GPTOKEYB with
|
||||
`netfishing.gptk` and calls `pm_platform_helper` for the game executable.
|
||||
3. Confirm `conf/` and the external save directory were not replaced or
|
||||
removed.
|
||||
4. Launch the installed port through the normal muOS menu.
|
||||
5. Verify the displayed game version and controller face-button mapping.
|
||||
6. Verify PortMaster's force-quit chord exits the game. This is Start+Select on
|
||||
most devices.
|
||||
6. Verify GPTOKEYB's device-specific force-quit chord exits the game. This is
|
||||
Start+Select on most devices.
|
||||
|
||||
Installing the public PortMaster catalog entry can still install an older
|
||||
catalog build until the upstream `netfishing.zip` is updated. A local release
|
||||
|
|
@ -173,12 +176,12 @@ the game process. This applies the mapping at the same point as the working
|
|||
reconstruction. Do not move the mapping back to either side of
|
||||
`westonwrap.sh`.
|
||||
|
||||
Legacy GPTOKEYB builds can load their compiled default keyboard map when no
|
||||
`-c` file is supplied. That map translates face buttons, D-pad directions, and
|
||||
sticks into keyboard or mouse events on top of Godot's native controller input.
|
||||
Always pass `netfishing.gptk`, which explicitly unassigns every direct and
|
||||
hotkey binding with GPTOKEYB's `\"` value. Do not add gameplay mappings to this
|
||||
file. The Start+Select kill chord remains independent of the no-op bindings.
|
||||
hotkey binding with GPTOKEYB's `\"` value. This keeps GPTOKEYB from injecting
|
||||
keyboard or mouse events on top of Godot's native controller input and retains
|
||||
the device-specific kill chord. Supplying the explicit passthrough map also
|
||||
avoids the anomalous controller behavior observed on ROCKNIX when no map is
|
||||
provided. Do not add gameplay mappings to this file.
|
||||
|
||||
Do not call `Input.add_joy_mapping(..., true)` for a recognized connected muOS
|
||||
controller. Updating this virtual controller after connection can stop Godot
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
NETfishing
|
||||
v0.13.1-alpha
|
||||
Alpha 0.13.1
|
||||
v0.14.0-alpha
|
||||
Alpha 0.14.0
|
||||
|
||||
Thank you for trying this early private playtest.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const MAGNET_ID: StringName = &"magnet"
|
|||
const BATTERIES_ID: StringName = &"batteries"
|
||||
const CRAB_NET_ID: StringName = &"crab_net"
|
||||
const STANDARD_SHOVEL_ID: StringName = &"standard_shovel"
|
||||
const FISHING_NET_ID: StringName = &"fishing_net"
|
||||
|
||||
const ITEM_PRICES: Dictionary[StringName, int] = {
|
||||
&"worms": 1,
|
||||
|
|
@ -30,6 +31,7 @@ const ITEM_PRICES: Dictionary[StringName, int] = {
|
|||
BATTERIES_ID: 15,
|
||||
CRAB_NET_ID: 50,
|
||||
STANDARD_SHOVEL_ID: 75,
|
||||
FISHING_NET_ID: 250,
|
||||
}
|
||||
const BAIT_UNLOCK_PRICES: Dictionary[StringName, int] = {
|
||||
&"snails": 400,
|
||||
|
|
@ -52,6 +54,7 @@ const ITEM_ORDER: Array[StringName] = [
|
|||
MAGNET_ID,
|
||||
CRAB_NET_ID,
|
||||
STANDARD_SHOVEL_ID,
|
||||
FISHING_NET_ID,
|
||||
&"coffee",
|
||||
&"energy_drink",
|
||||
&"snack",
|
||||
|
|
@ -120,6 +123,7 @@ static func is_permanent_unlock(
|
|||
or item_id == MAGNET_ID
|
||||
or item_id == CRAB_NET_ID
|
||||
or item_id == STANDARD_SHOVEL_ID
|
||||
or item_id == FISHING_NET_ID
|
||||
or item is FishingRodDataType
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,scripts/*"
|
||||
export_path="builds/v0.13.1-alpha/windows-x86_64/NETfishing.exe"
|
||||
export_path="builds/v0.14.0-alpha/windows-x86_64/NETfishing.exe"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -35,11 +35,11 @@ application/modify_resources=true
|
|||
application/icon="res://art/exported/system_icons/netfishing.ico"
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version="0.13.1.0"
|
||||
application/product_version="0.13.1.0"
|
||||
application/file_version="0.14.0.0"
|
||||
application/product_version="0.14.0.0"
|
||||
application/company_name="Woofmeow"
|
||||
application/product_name="NETfishing"
|
||||
application/file_description="NETfishing v0.13.1-alpha"
|
||||
application/file_description="NETfishing v0.14.0-alpha"
|
||||
application/copyright="Copyright © 2026 Woofmeow"
|
||||
application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md"
|
||||
application/export_angle=0
|
||||
|
|
@ -62,7 +62,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,scripts/*"
|
||||
export_path="builds/v0.13.1-alpha/linux-arm64/NETfishing.arm64"
|
||||
export_path="builds/v0.14.0-alpha/linux-arm64/NETfishing.arm64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -92,7 +92,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,scripts/*"
|
||||
export_path="builds/v0.13.1-alpha/macos/NETfishing.zip"
|
||||
export_path="builds/v0.14.0-alpha/macos/NETfishing.zip"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -108,8 +108,8 @@ custom_template/release=""
|
|||
application/bundle_identifier="io.woofmeow.netfishing"
|
||||
application/icon="res://art/exported/system_icons/netfishing_1024.png"
|
||||
application/icon_interpolation=0
|
||||
application/short_version="0.13.1"
|
||||
application/version="0.13.1"
|
||||
application/short_version="0.14.0"
|
||||
application/version="0.14.0"
|
||||
application/architecture="universal"
|
||||
codesign/enable=false
|
||||
notarization/enable=false
|
||||
|
|
@ -125,7 +125,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,scripts/*,tests/*"
|
||||
export_path="builds/v0.13.1-alpha/server-linux-x86_64/NETfishingServer.x86_64"
|
||||
export_path="builds/v0.14.0-alpha/server-linux-x86_64/NETfishingServer.x86_64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -155,7 +155,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,scripts/*"
|
||||
export_path="builds/v0.13.1-alpha/android/NETfishing.apk"
|
||||
export_path="builds/v0.14.0-alpha/android/NETfishing.apk"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -176,8 +176,8 @@ architectures/armeabi-v7a=false
|
|||
architectures/arm64-v8a=true
|
||||
architectures/x86=false
|
||||
architectures/x86_64=false
|
||||
version/code=130100
|
||||
version/name="v0.13.1-alpha"
|
||||
version/code=140000
|
||||
version/name="v0.14.0-alpha"
|
||||
package/unique_name="io.woofmeow.netfishing"
|
||||
package/name="NETfishing"
|
||||
package/signed=true
|
||||
|
|
@ -214,7 +214,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,docs/ATTRIBUTION.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,scripts/*"
|
||||
export_path="builds/v0.13.1-alpha/linux-x86_64/NETfishing.x86_64"
|
||||
export_path="builds/v0.14.0-alpha/linux-x86_64/NETfishing.x86_64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ enum LogbookSection {
|
|||
SHELLFISH,
|
||||
}
|
||||
|
||||
enum CreatureGroup {
|
||||
FISH,
|
||||
INSECT,
|
||||
SHELLFISH,
|
||||
}
|
||||
|
||||
@export var id: StringName
|
||||
@export var display_name: String
|
||||
@export_category("Developer Catalog")
|
||||
|
|
@ -94,6 +100,14 @@ func is_fishable() -> bool:
|
|||
return is_selectable() and collection_method == CollectionMethod.FISHING
|
||||
|
||||
|
||||
func get_creature_group() -> CreatureGroup:
|
||||
if logbook_section == LogbookSection.SHELLFISH:
|
||||
return CreatureGroup.SHELLFISH
|
||||
if collection_method == CollectionMethod.FISHING:
|
||||
return CreatureGroup.FISH
|
||||
return CreatureGroup.INSECT
|
||||
|
||||
|
||||
func get_habitat_label() -> String:
|
||||
if not habitat_label.strip_edges().is_empty():
|
||||
return habitat_label.strip_edges().to_lower()
|
||||
|
|
|
|||
|
|
@ -111,7 +111,9 @@ const BITE_QUICK_PROBABILITY: float = 0.30
|
|||
const BITE_TYPICAL_PROBABILITY: float = 0.65
|
||||
const BITE_LONG_PROBABILITY: float = 0.04
|
||||
const NETWORK_INPUT_RESEND_INTERVAL_SECONDS: float = 0.1
|
||||
const ALTERNATE_REEL_ACTION: StringName = &"reel_alternate"
|
||||
@export_range(0.1, 10.0, 0.1) var cooldown_duration: float = 1.0
|
||||
@export_range(0.1, 5.0, 0.05) var minimum_showcase_duration: float = 0.8
|
||||
|
||||
@export_category("Selection")
|
||||
@export_range(0.0, 10.0, 0.05) var undiscovered_weight_multiplier: float = 1.5
|
||||
|
|
@ -172,6 +174,7 @@ var _selected_fish: FishDataType
|
|||
var _pending_catch: FishCatchType
|
||||
var _showcase_ready: bool = false
|
||||
var _put_away_press_armed: bool = false
|
||||
var _showcase_input_lock_remaining: float = 0.0
|
||||
var _showcase_restore_generation: int = 0
|
||||
var _showcase_outcome_completed: bool = false
|
||||
var _network_auto_click_accumulator: float = 0.0
|
||||
|
|
@ -444,6 +447,7 @@ func _secure_showcase_catch_for_recovery() -> void:
|
|||
_pending_catch = null
|
||||
_showcase_ready = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
showcase_changed.emit("", "", 0.0, 0, false)
|
||||
if _active_player != null:
|
||||
_active_player.end_catch_showcase(Callable(), true)
|
||||
|
|
@ -473,14 +477,21 @@ func _exit_tree() -> void:
|
|||
_selected_fish = null
|
||||
_showcase_ready = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if state == FishingState.READY and not Input.is_action_pressed("fish_primary"):
|
||||
_new_cast_press_armed = true
|
||||
if state == FishingState.SHOWING_CATCH:
|
||||
_showcase_input_lock_remaining = maxf(
|
||||
_showcase_input_lock_remaining - delta,
|
||||
0.0,
|
||||
)
|
||||
if (
|
||||
state == FishingState.SHOWING_CATCH
|
||||
and _showcase_ready
|
||||
and _showcase_input_lock_remaining <= 0.0
|
||||
and not Input.is_action_pressed("fish_primary")
|
||||
):
|
||||
_put_away_press_armed = true
|
||||
|
|
@ -501,7 +512,7 @@ func _process(delta: float) -> void:
|
|||
_get_effective_reel_speed(),
|
||||
_get_effective_barrier_damage()
|
||||
)
|
||||
if not Input.is_action_pressed("fish_primary"):
|
||||
if not _is_reel_input_pressed():
|
||||
_catch_controller.set_reel_input(false)
|
||||
else:
|
||||
_resend_network_input_state(delta)
|
||||
|
|
@ -521,7 +532,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
return
|
||||
if _local_player == null or not _local_player.is_local_control_enabled():
|
||||
return
|
||||
if not event.is_action("fish_primary"):
|
||||
var primary_action: bool = event.is_action("fish_primary")
|
||||
var alternate_reel: bool = event.is_action(ALTERNATE_REEL_ACTION)
|
||||
if not primary_action and not (
|
||||
alternate_reel and state == FishingState.FIGHTING
|
||||
):
|
||||
return
|
||||
var selected_item: ItemDataType = _get_active_item()
|
||||
if (
|
||||
|
|
@ -582,7 +597,11 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
FishingPresentationType.LineMode.TAUT
|
||||
)
|
||||
FishingState.SHOWING_CATCH:
|
||||
if _showcase_ready and _put_away_press_armed:
|
||||
if (
|
||||
_showcase_ready
|
||||
and _showcase_input_lock_remaining <= 0.0
|
||||
and _put_away_press_armed
|
||||
):
|
||||
_put_away_catch()
|
||||
else:
|
||||
return
|
||||
|
|
@ -619,15 +638,23 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||
_presentation.set_line_mode(FishingPresentationType.LineMode.SLACK)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif state == FishingState.FIGHTING:
|
||||
var reel_input_held: bool = _is_reel_input_pressed()
|
||||
if _network_fishing != null:
|
||||
_network_primary_input_held = false
|
||||
_network_primary_input_held = reel_input_held
|
||||
_network_input_resend_elapsed = 0.0
|
||||
_network_fishing.submit_local_input(false, false)
|
||||
_network_fishing.submit_local_input(reel_input_held, false)
|
||||
else:
|
||||
_catch_controller.set_reel_input(false)
|
||||
_catch_controller.set_reel_input(reel_input_held)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _is_reel_input_pressed() -> bool:
|
||||
return (
|
||||
Input.is_action_pressed("fish_primary")
|
||||
or Input.is_action_pressed(ALTERNATE_REEL_ACTION)
|
||||
)
|
||||
|
||||
|
||||
func _begin_aiming(player: PlayerType) -> void:
|
||||
if state != FishingState.READY or _active_player != null:
|
||||
return
|
||||
|
|
@ -756,6 +783,17 @@ func is_returning() -> bool:
|
|||
return state == FishingState.RETURNING
|
||||
|
||||
|
||||
func is_fishing_sequence_active() -> bool:
|
||||
return state in [
|
||||
FishingState.AIMING_CAST,
|
||||
FishingState.CASTING,
|
||||
FishingState.WAITING_FOR_BITE,
|
||||
FishingState.FIGHTING,
|
||||
FishingState.SHOWING_CATCH,
|
||||
FishingState.RETURNING,
|
||||
]
|
||||
|
||||
|
||||
func refresh_active_item_status() -> void:
|
||||
if state == FishingState.READY and has_active_fishing_rod():
|
||||
status_changed.emit("")
|
||||
|
|
@ -780,6 +818,7 @@ func present_external_catch(fish_catch: FishCatchType) -> bool:
|
|||
_showcase_ready = true
|
||||
_showcase_outcome_completed = true
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = minimum_showcase_duration
|
||||
_active_player.begin_catch_showcase(fish_catch)
|
||||
showcase_changed.emit(
|
||||
fish_catch.fish.display_name,
|
||||
|
|
@ -1112,7 +1151,7 @@ func _activate_bite(confirmation_override: bool = false) -> void:
|
|||
_pending_catch.quality,
|
||||
)
|
||||
_catch_controller.set_reel_input(
|
||||
Input.is_action_pressed("fish_primary")
|
||||
_is_reel_input_pressed()
|
||||
)
|
||||
_presentation.show_bite()
|
||||
|
||||
|
|
@ -1197,6 +1236,7 @@ func _on_catch_completed() -> void:
|
|||
_showcase_ready = false
|
||||
_showcase_outcome_completed = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
_catch_controller.reset()
|
||||
_presentation.set_line_mode(FishingPresentationType.LineMode.TAUT)
|
||||
_presentation.play_outcome(&"catch")
|
||||
|
|
@ -1232,6 +1272,7 @@ func _on_outcome_completed(outcome: StringName) -> void:
|
|||
return
|
||||
_showcase_outcome_completed = true
|
||||
_showcase_ready = true
|
||||
_showcase_input_lock_remaining = minimum_showcase_duration
|
||||
_active_player.begin_catch_showcase(_pending_catch)
|
||||
showcase_changed.emit(
|
||||
_pending_catch.fish.display_name,
|
||||
|
|
@ -1265,6 +1306,7 @@ func _put_away_catch() -> void:
|
|||
_showcase_ready = false
|
||||
_showcase_outcome_completed = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
showcase_changed.emit("", "", 0.0, 0, false)
|
||||
_showcase_restore_generation += 1
|
||||
var restore_generation: int = _showcase_restore_generation
|
||||
|
|
@ -1338,6 +1380,7 @@ func _cleanup_attempt(
|
|||
_showcase_ready = false
|
||||
_showcase_outcome_completed = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
showcase_changed.emit("", "", 0.0, 0, false)
|
||||
_catch_controller.reset()
|
||||
state = FishingState.RETURNING
|
||||
|
|
@ -1387,6 +1430,7 @@ func _finalize_attempt_cleanup(
|
|||
_showcase_ready = false
|
||||
_showcase_outcome_completed = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
showcase_changed.emit("", "", 0.0, 0, false)
|
||||
_catch_controller.reset()
|
||||
_presentation.cleanup()
|
||||
|
|
@ -1724,7 +1768,7 @@ func _on_network_bite_started(_attempt_id: String) -> void:
|
|||
if _active_player != null:
|
||||
_active_player.set_fighting_visual(true)
|
||||
_withdrawal_input_held = false
|
||||
_network_primary_input_held = Input.is_action_pressed("fish_primary")
|
||||
_network_primary_input_held = _is_reel_input_pressed()
|
||||
_network_input_resend_elapsed = 0.0
|
||||
_network_auto_click_accumulator = 0.0
|
||||
_network_active_barrier_index = -1
|
||||
|
|
@ -1796,7 +1840,7 @@ func _on_network_fishing_snapshot(snapshot: Dictionary) -> void:
|
|||
func _update_network_auto_click(delta: float) -> void:
|
||||
if (
|
||||
not _catch_controller.auto_click_enabled
|
||||
or not Input.is_action_pressed("fish_primary")
|
||||
or not _is_reel_input_pressed()
|
||||
or _network_active_barrier_index < 0
|
||||
):
|
||||
_network_auto_click_accumulator = 0.0
|
||||
|
|
@ -1823,6 +1867,7 @@ func _on_network_catch_received(fish_catch: FishCatchType) -> void:
|
|||
_showcase_ready = false
|
||||
_showcase_outcome_completed = false
|
||||
_put_away_press_armed = false
|
||||
_showcase_input_lock_remaining = 0.0
|
||||
catch_display_changed.emit(
|
||||
0.0, 0.0, PackedFloat32Array(), PackedInt32Array(),
|
||||
PackedInt32Array(), -1, false
|
||||
|
|
|
|||
16
items/catalog/fishing_net.tres
Normal file
16
items/catalog/fishing_net.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[gd_resource type="Resource" script_class="ItemData" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_item")
|
||||
item_id = &"fishing_net"
|
||||
display_name = "fishing net"
|
||||
description = "a placeable water net reserved for a future passive fishing system."
|
||||
active = true
|
||||
category = 1
|
||||
stackable = false
|
||||
max_stack = 1
|
||||
usable = false
|
||||
equippable = false
|
||||
hotbar_allowed = false
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=40 format=3]
|
||||
[gd_resource type="Resource" script_class="ItemCatalog" load_steps=41 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://items/item_catalog.gd" id="1_script"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/basic_fishing_rod.tres" id="2_rod"]
|
||||
|
|
@ -39,7 +39,8 @@
|
|||
[ext_resource type="Resource" path="res://items/catalog/magnet.tres" id="38_magnet"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/crab_net.tres" id="39_crab_net"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/standard_shovel.tres" id="40_shovel"]
|
||||
[ext_resource type="Resource" path="res://items/catalog/fishing_net.tres" id="41_fishing_net"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1_script")
|
||||
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel")]
|
||||
items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("8_art_kit"), ExtResource("9_worms"), ExtResource("10_snails"), ExtResource("11_shrimp"), ExtResource("12_squid"), ExtResource("14_sardine"), ExtResource("13_anchovy"), ExtResource("15_roe"), ExtResource("16_standby"), ExtResource("17_batteries"), ExtResource("18_cardboard"), ExtResource("19_pond"), ExtResource("20_river"), ExtResource("21_lake"), ExtResource("22_salt"), ExtResource("23_whisker"), ExtResource("24_reef"), ExtResource("25_moonbeam"), ExtResource("26_sun"), ExtResource("27_rain"), ExtResource("28_fog"), ExtResource("29_guide"), ExtResource("30_small"), ExtResource("31_heavy"), ExtResource("32_rocket"), ExtResource("33_lucky"), ExtResource("34_showboat"), ExtResource("35_deep"), ExtResource("36_oddity"), ExtResource("37_aurora"), ExtResource("38_magnet"), ExtResource("39_crab_net"), ExtResource("40_shovel"), ExtResource("41_fishing_net")]
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ enum Kind {
|
|||
REACH_LEVEL,
|
||||
DISCOVER_SPECIES,
|
||||
MASTER_QUALITIES,
|
||||
CATCH_CREATURE_GROUP,
|
||||
DISCOVER_CREATURE_GROUP,
|
||||
MASTER_CREATURE_GROUP,
|
||||
}
|
||||
|
||||
const DAILY_JOB_COUNT: int = 4
|
||||
|
|
@ -111,6 +114,28 @@ static func generate_daily_jobs(
|
|||
50,
|
||||
{"fish_id": String(selected.id)},
|
||||
))
|
||||
if _has_creature_group(candidates, FishDataType.CreatureGroup.INSECT):
|
||||
optional.append(_job(
|
||||
"insect_roundup",
|
||||
"bug hunt",
|
||||
"catch 3 insects",
|
||||
Kind.CATCH_CREATURE_GROUP,
|
||||
3,
|
||||
40,
|
||||
50,
|
||||
{"creature_group": int(FishDataType.CreatureGroup.INSECT)},
|
||||
))
|
||||
if _has_creature_group(candidates, FishDataType.CreatureGroup.SHELLFISH):
|
||||
optional.append(_job(
|
||||
"shellfish_search",
|
||||
"shore patrol",
|
||||
"catch 3 shellfish",
|
||||
Kind.CATCH_CREATURE_GROUP,
|
||||
3,
|
||||
45,
|
||||
50,
|
||||
{"creature_group": int(FishDataType.CreatureGroup.SHELLFISH)},
|
||||
))
|
||||
_shuffle(optional, rng)
|
||||
while jobs.size() < DAILY_JOB_COUNT and not optional.is_empty():
|
||||
jobs.append(optional.pop_back())
|
||||
|
|
@ -172,6 +197,8 @@ static func generate_weather_schedule(
|
|||
|
||||
static func lifetime_chains(
|
||||
registered_species_count: int,
|
||||
registered_insect_count: int = 0,
|
||||
registered_shellfish_count: int = 0,
|
||||
) -> Array[Dictionary]:
|
||||
var chains: Array[Dictionary] = [
|
||||
_chain("catch", Kind.CATCH_TOTAL, [100, 1000, 5000], [
|
||||
|
|
@ -197,15 +224,33 @@ static func lifetime_chains(
|
|||
[registered_species_count],
|
||||
[[1500, 2000]],
|
||||
))
|
||||
_append_creature_lifetime_chains(
|
||||
chains,
|
||||
"insect",
|
||||
FishDataType.CreatureGroup.INSECT,
|
||||
registered_insect_count,
|
||||
)
|
||||
_append_creature_lifetime_chains(
|
||||
chains,
|
||||
"shellfish",
|
||||
FishDataType.CreatureGroup.SHELLFISH,
|
||||
registered_shellfish_count,
|
||||
)
|
||||
return chains
|
||||
|
||||
|
||||
static func visible_lifetime_jobs(
|
||||
registered_species_count: int,
|
||||
claimed_ids: Array[String],
|
||||
registered_insect_count: int = 0,
|
||||
registered_shellfish_count: int = 0,
|
||||
) -> Array[Dictionary]:
|
||||
var visible: Array[Dictionary] = []
|
||||
for chain: Dictionary in lifetime_chains(registered_species_count):
|
||||
for chain: Dictionary in lifetime_chains(
|
||||
registered_species_count,
|
||||
registered_insect_count,
|
||||
registered_shellfish_count,
|
||||
):
|
||||
var targets: Array = chain.get("targets", [])
|
||||
var rewards: Array = chain.get("rewards", [])
|
||||
for tier_index: int in targets.size():
|
||||
|
|
@ -216,6 +261,9 @@ static func visible_lifetime_jobs(
|
|||
if job_id in claimed_ids:
|
||||
continue
|
||||
var reward: Array = rewards[tier_index]
|
||||
var extra: Dictionary = {}
|
||||
if chain.has("creature_group"):
|
||||
extra["creature_group"] = int(chain["creature_group"])
|
||||
visible.append(_job(
|
||||
job_id,
|
||||
_lifetime_title(str(chain.get("id", "")), int(targets[tier_index])),
|
||||
|
|
@ -226,6 +274,7 @@ static func visible_lifetime_jobs(
|
|||
int(targets[tier_index]),
|
||||
int(reward[0]),
|
||||
int(reward[1]),
|
||||
extra,
|
||||
))
|
||||
break
|
||||
return visible
|
||||
|
|
@ -256,10 +305,10 @@ static func is_valid_job(value: Variant) -> bool:
|
|||
and typeof(job.get("description")) == TYPE_STRING
|
||||
and str(job["description"]).length() <= 160
|
||||
and is_bounded_integer(
|
||||
job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_QUALITIES
|
||||
job.get("kind"), Kind.CATCH_TOTAL, Kind.MASTER_CREATURE_GROUP
|
||||
)
|
||||
and kind >= Kind.CATCH_TOTAL
|
||||
and kind <= Kind.MASTER_QUALITIES
|
||||
and kind <= Kind.MASTER_CREATURE_GROUP
|
||||
and is_bounded_integer(job.get("target"), 1, 1000000000)
|
||||
and is_bounded_integer(
|
||||
job.get("fish_coin"), 0, MAX_JOB_REWARD_COINS
|
||||
|
|
@ -318,6 +367,12 @@ static func is_valid_job(value: Variant) -> bool:
|
|||
and not str(job.get("fish_id", "")).is_empty()
|
||||
and str(job.get("fish_id", "")).length() <= 96
|
||||
)
|
||||
Kind.CATCH_CREATURE_GROUP, Kind.DISCOVER_CREATURE_GROUP, Kind.MASTER_CREATURE_GROUP:
|
||||
return is_bounded_integer(
|
||||
job.get("creature_group"),
|
||||
FishDataType.CreatureGroup.FISH,
|
||||
FishDataType.CreatureGroup.SHELLFISH,
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
|
|
@ -401,13 +456,16 @@ static func _chain(
|
|||
kind: Kind,
|
||||
targets: Array[int],
|
||||
rewards: Array,
|
||||
extra: Dictionary = {},
|
||||
) -> Dictionary:
|
||||
return {
|
||||
var result: Dictionary = {
|
||||
"id": id,
|
||||
"kind": int(kind),
|
||||
"targets": targets,
|
||||
"rewards": rewards,
|
||||
}
|
||||
result.merge(extra, true)
|
||||
return result
|
||||
|
||||
|
||||
static func _lifetime_title(chain_id: String, target: int) -> String:
|
||||
|
|
@ -422,6 +480,18 @@ static func _lifetime_title(chain_id: String, target: int) -> String:
|
|||
return "complete the catalog"
|
||||
"master":
|
||||
return "quality master"
|
||||
"insect_catch":
|
||||
return "seasoned bug catcher"
|
||||
"insect_discover":
|
||||
return "complete the insect catalog"
|
||||
"insect_master":
|
||||
return "insect quality master"
|
||||
"shellfish_catch":
|
||||
return "seasoned beachcomber"
|
||||
"shellfish_discover":
|
||||
return "complete the shellfish catalog"
|
||||
"shellfish_master":
|
||||
return "shellfish quality master"
|
||||
return "long-term job"
|
||||
|
||||
|
||||
|
|
@ -437,9 +507,67 @@ static func _lifetime_description(chain_id: String, target: int) -> String:
|
|||
return "discover all %d cataloged fish" % target
|
||||
"master":
|
||||
return "collect every quality of all %d fish" % target
|
||||
"insect_catch":
|
||||
return "catch %d insects" % target
|
||||
"insect_discover":
|
||||
return "discover all %d cataloged insects" % target
|
||||
"insect_master":
|
||||
return "collect every quality of all %d insects" % target
|
||||
"shellfish_catch":
|
||||
return "catch %d shellfish" % target
|
||||
"shellfish_discover":
|
||||
return "discover all %d cataloged shellfish" % target
|
||||
"shellfish_master":
|
||||
return "collect every quality of all %d shellfish" % target
|
||||
return "keep fishing"
|
||||
|
||||
|
||||
static func _has_creature_group(
|
||||
candidates: Array[FishDataType],
|
||||
creature_group: FishDataType.CreatureGroup,
|
||||
) -> bool:
|
||||
for fish: FishDataType in candidates:
|
||||
if (
|
||||
fish != null
|
||||
and fish.is_selectable()
|
||||
and fish.get_creature_group() == creature_group
|
||||
):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
static func _append_creature_lifetime_chains(
|
||||
chains: Array[Dictionary],
|
||||
id_prefix: String,
|
||||
creature_group: FishDataType.CreatureGroup,
|
||||
registered_count: int,
|
||||
) -> void:
|
||||
if registered_count <= 0:
|
||||
return
|
||||
var group_data := {"creature_group": int(creature_group)}
|
||||
chains.append(_chain(
|
||||
"%s_catch" % id_prefix,
|
||||
Kind.CATCH_CREATURE_GROUP,
|
||||
[10, 50, 250],
|
||||
[[75, 100], [250, 350], [900, 1100]],
|
||||
group_data,
|
||||
))
|
||||
chains.append(_chain(
|
||||
"%s_discover" % id_prefix,
|
||||
Kind.DISCOVER_CREATURE_GROUP,
|
||||
[registered_count],
|
||||
[[250, 350]],
|
||||
group_data,
|
||||
))
|
||||
chains.append(_chain(
|
||||
"%s_master" % id_prefix,
|
||||
Kind.MASTER_CREATURE_GROUP,
|
||||
[registered_count],
|
||||
[[750, 1000]],
|
||||
group_data,
|
||||
))
|
||||
|
||||
|
||||
static func _shuffle(values: Array[Dictionary], rng: RandomNumberGenerator) -> void:
|
||||
for index: int in range(values.size() - 1, 0, -1):
|
||||
var swap_index: int = rng.randi_range(0, index)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ var _session: NetworkSession
|
|||
var _save_manager: PlayerSaveManager
|
||||
var _network_fishing: NetworkFishingService
|
||||
var _network_sale: NetworkSaleService
|
||||
var _network_world_spawns: NetworkWorldSpawnService
|
||||
|
||||
var _progression_ready: bool = false
|
||||
var _host_board: Dictionary = {}
|
||||
|
|
@ -35,6 +36,8 @@ var _daily_completions: Dictionary[String, int] = {}
|
|||
var _pending_rewards: Array[Dictionary] = []
|
||||
var _lifetime_claimed: Array[String] = []
|
||||
var _total_catches: int = 0
|
||||
var _total_insects: int = 0
|
||||
var _total_shellfish: int = 0
|
||||
var _total_sold: int = 0
|
||||
var _daily_clock_hour: float = WorldTimeService.DEFAULT_START_HOUR
|
||||
|
||||
|
|
@ -65,9 +68,11 @@ func setup(
|
|||
func bind_authoritative_services(
|
||||
network_fishing: NetworkFishingService,
|
||||
network_sale: NetworkSaleService,
|
||||
network_world_spawns: NetworkWorldSpawnService,
|
||||
) -> void:
|
||||
_network_fishing = network_fishing
|
||||
_network_sale = network_sale
|
||||
_network_world_spawns = network_world_spawns
|
||||
if not _network_fishing.local_catch_received.is_connected(
|
||||
_on_authoritative_catch
|
||||
):
|
||||
|
|
@ -78,6 +83,12 @@ func bind_authoritative_services(
|
|||
_network_sale.local_sale_finished.connect(
|
||||
_on_authoritative_sale_finished
|
||||
)
|
||||
if not _network_world_spawns.local_capture_received.is_connected(
|
||||
_on_authoritative_catch
|
||||
):
|
||||
_network_world_spawns.local_capture_received.connect(
|
||||
_on_authoritative_catch
|
||||
)
|
||||
|
||||
|
||||
func set_save_manager(save_manager: PlayerSaveManager) -> void:
|
||||
|
|
@ -123,7 +134,10 @@ func get_daily_jobs() -> Array[Dictionary]:
|
|||
|
||||
func get_lifetime_jobs() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = JobCatalog.visible_lifetime_jobs(
|
||||
_registered_species_count(), _lifetime_claimed
|
||||
_registered_species_count(),
|
||||
_lifetime_claimed,
|
||||
_registered_creature_count(FishDataType.CreatureGroup.INSECT),
|
||||
_registered_creature_count(FishDataType.CreatureGroup.SHELLFISH),
|
||||
)
|
||||
for job: Dictionary in result:
|
||||
var progress: int = _lifetime_progress(job)
|
||||
|
|
@ -236,6 +250,8 @@ func to_save_data() -> Dictionary:
|
|||
"lifetime_claimed": _lifetime_claimed.duplicate(),
|
||||
"statistics": {
|
||||
"fish_caught": _total_catches,
|
||||
"insects_caught": _total_insects,
|
||||
"shellfish_caught": _total_shellfish,
|
||||
"fish_sold": _total_sold,
|
||||
},
|
||||
}
|
||||
|
|
@ -294,6 +310,8 @@ func restore_from_save_data(data: Dictionary) -> bool:
|
|||
_lifetime_claimed.append(str(value))
|
||||
var statistics: Dictionary = data.get("statistics", {})
|
||||
_total_catches = int(statistics.get("fish_caught", 0))
|
||||
_total_insects = int(statistics.get("insects_caught", 0))
|
||||
_total_shellfish = int(statistics.get("shellfish_caught", 0))
|
||||
_total_sold = int(statistics.get("fish_sold", 0))
|
||||
if _session != null and _session.is_host():
|
||||
_active_board = _host_board.duplicate(true)
|
||||
|
|
@ -318,6 +336,8 @@ func reset_to_defaults() -> void:
|
|||
_pending_rewards.clear()
|
||||
_lifetime_claimed.clear()
|
||||
_total_catches = 0
|
||||
_total_insects = 0
|
||||
_total_shellfish = 0
|
||||
_total_sold = 0
|
||||
_daily_clock_hour = WorldTimeService.DEFAULT_START_HOUR
|
||||
changed.emit()
|
||||
|
|
@ -333,7 +353,12 @@ static func default_save_data() -> Dictionary:
|
|||
"daily_clock_hour": WorldTimeService.DEFAULT_START_HOUR,
|
||||
"pending_rewards": [],
|
||||
"lifetime_claimed": [],
|
||||
"statistics": {"fish_caught": 0, "fish_sold": 0},
|
||||
"statistics": {
|
||||
"fish_caught": 0,
|
||||
"insects_caught": 0,
|
||||
"shellfish_caught": 0,
|
||||
"fish_sold": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -456,6 +481,12 @@ static func validate_save_data(value: Variant) -> bool:
|
|||
JobCatalog.is_bounded_integer(
|
||||
statistics.get("fish_caught"), 0, MAX_PROGRESS_VALUE
|
||||
)
|
||||
and JobCatalog.is_bounded_integer(
|
||||
statistics.get("insects_caught", 0), 0, MAX_PROGRESS_VALUE
|
||||
)
|
||||
and JobCatalog.is_bounded_integer(
|
||||
statistics.get("shellfish_caught", 0), 0, MAX_PROGRESS_VALUE
|
||||
)
|
||||
and JobCatalog.is_bounded_integer(
|
||||
statistics.get("fish_sold"), 0, MAX_PROGRESS_VALUE
|
||||
)
|
||||
|
|
@ -514,7 +545,7 @@ func _generate_host_board(cycle: int) -> void:
|
|||
var candidates: Array[FishDataType] = []
|
||||
if _catalog != null:
|
||||
for fish: FishDataType in _catalog.candidates:
|
||||
if fish != null and fish.is_fishable():
|
||||
if fish != null and fish.is_selectable():
|
||||
candidates.append(fish)
|
||||
var schedule_anchor_index: int = _current_weather_segment()
|
||||
var allow_weather_jobs: bool = (
|
||||
|
|
@ -608,7 +639,13 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
|
|||
func _on_authoritative_catch(fish_catch: FishCatchType) -> void:
|
||||
if not _progression_ready or fish_catch == null or not fish_catch.is_valid():
|
||||
return
|
||||
_total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE)
|
||||
match fish_catch.fish.get_creature_group():
|
||||
FishDataType.CreatureGroup.FISH:
|
||||
_total_catches = mini(_total_catches + 1, MAX_PROGRESS_VALUE)
|
||||
FishDataType.CreatureGroup.INSECT:
|
||||
_total_insects = mini(_total_insects + 1, MAX_PROGRESS_VALUE)
|
||||
FishDataType.CreatureGroup.SHELLFISH:
|
||||
_total_shellfish = mini(_total_shellfish + 1, MAX_PROGRESS_VALUE)
|
||||
_update_daily_for_catch(fish_catch)
|
||||
changed.emit()
|
||||
|
||||
|
|
@ -630,23 +667,40 @@ func _on_authoritative_sale_finished(
|
|||
|
||||
|
||||
func _update_daily_for_catch(fish_catch: FishCatchType) -> void:
|
||||
var creature_group: int = int(fish_catch.fish.get_creature_group())
|
||||
var is_fish: bool = creature_group == FishDataType.CreatureGroup.FISH
|
||||
for job: Dictionary in get_daily_jobs():
|
||||
var matches: bool = false
|
||||
match int(job.get("kind", -1)):
|
||||
JobCatalog.Kind.CATCH_TOTAL:
|
||||
matches = true
|
||||
matches = is_fish
|
||||
JobCatalog.Kind.CATCH_WATER:
|
||||
matches = fish_catch.fish.is_allowed_in_water(
|
||||
matches = is_fish and fish_catch.fish.is_allowed_in_water(
|
||||
int(job.get("water_type", WaterType.Type.OTHER)) as WaterType.Type
|
||||
)
|
||||
JobCatalog.Kind.CATCH_QUALITY:
|
||||
matches = fish_catch.quality >= int(job.get("minimum_quality", 0))
|
||||
matches = (
|
||||
is_fish
|
||||
and fish_catch.quality >= int(job.get("minimum_quality", 0))
|
||||
)
|
||||
JobCatalog.Kind.CATCH_PHASE:
|
||||
matches = int(_world_time.get_phase()) == int(job.get("phase", -1))
|
||||
matches = (
|
||||
is_fish
|
||||
and int(_world_time.get_phase()) == int(job.get("phase", -1))
|
||||
)
|
||||
JobCatalog.Kind.CATCH_WEATHER:
|
||||
matches = int(_world_weather.get_weather()) == int(job.get("weather", -1))
|
||||
matches = (
|
||||
is_fish
|
||||
and int(_world_weather.get_weather())
|
||||
== int(job.get("weather", -1))
|
||||
)
|
||||
JobCatalog.Kind.CATCH_SPECIES:
|
||||
matches = String(fish_catch.fish_id) == str(job.get("fish_id", ""))
|
||||
matches = (
|
||||
is_fish
|
||||
and String(fish_catch.fish_id) == str(job.get("fish_id", ""))
|
||||
)
|
||||
JobCatalog.Kind.CATCH_CREATURE_GROUP:
|
||||
matches = creature_group == int(job.get("creature_group", -1))
|
||||
if matches:
|
||||
_advance_daily(job, 1)
|
||||
|
||||
|
|
@ -741,26 +795,74 @@ func _lifetime_progress(job: Dictionary) -> int:
|
|||
JobCatalog.Kind.REACH_LEVEL:
|
||||
return _experience.get_level() if _experience != null else 0
|
||||
JobCatalog.Kind.DISCOVER_SPECIES:
|
||||
return _collection.get_discovered_ids().size() if _collection != null else 0
|
||||
return _collection_progress(
|
||||
FishDataType.CreatureGroup.FISH, false
|
||||
)
|
||||
JobCatalog.Kind.MASTER_QUALITIES:
|
||||
var mastered: int = 0
|
||||
if _collection != null and _catalog != null:
|
||||
for fish: FishDataType in _catalog.candidates:
|
||||
if fish != null and _collection.has_mastered(fish.id):
|
||||
mastered += 1
|
||||
return mastered
|
||||
return _collection_progress(
|
||||
FishDataType.CreatureGroup.FISH, true
|
||||
)
|
||||
JobCatalog.Kind.CATCH_CREATURE_GROUP:
|
||||
match int(job.get("creature_group", -1)):
|
||||
FishDataType.CreatureGroup.INSECT:
|
||||
return _total_insects
|
||||
FishDataType.CreatureGroup.SHELLFISH:
|
||||
return _total_shellfish
|
||||
JobCatalog.Kind.DISCOVER_CREATURE_GROUP:
|
||||
return _collection_progress(
|
||||
int(job.get("creature_group", -1)) as FishDataType.CreatureGroup,
|
||||
false,
|
||||
)
|
||||
JobCatalog.Kind.MASTER_CREATURE_GROUP:
|
||||
return _collection_progress(
|
||||
int(job.get("creature_group", -1)) as FishDataType.CreatureGroup,
|
||||
true,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
func _registered_species_count() -> int:
|
||||
return _registered_creature_count(FishDataType.CreatureGroup.FISH)
|
||||
|
||||
|
||||
func _registered_creature_count(
|
||||
creature_group: FishDataType.CreatureGroup,
|
||||
) -> int:
|
||||
var count: int = 0
|
||||
if _catalog != null:
|
||||
for fish: FishDataType in _catalog.candidates:
|
||||
if fish != null and fish.is_fishable():
|
||||
if (
|
||||
fish != null
|
||||
and fish.is_selectable()
|
||||
and fish.get_creature_group() == creature_group
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _collection_progress(
|
||||
creature_group: FishDataType.CreatureGroup,
|
||||
require_mastery: bool,
|
||||
) -> int:
|
||||
var progress: int = 0
|
||||
if _collection == null or _catalog == null:
|
||||
return progress
|
||||
for fish: FishDataType in _catalog.candidates:
|
||||
if (
|
||||
fish == null
|
||||
or not fish.is_selectable()
|
||||
or fish.get_creature_group() != creature_group
|
||||
):
|
||||
continue
|
||||
if (
|
||||
_collection.has_mastered(fish.id)
|
||||
if require_mastery
|
||||
else _collection.has_discovered(fish.id)
|
||||
):
|
||||
progress += 1
|
||||
return progress
|
||||
|
||||
|
||||
func _expire_incomplete_daily_jobs() -> void:
|
||||
_daily_progress.clear()
|
||||
_daily_completions.clear()
|
||||
|
|
@ -795,7 +897,7 @@ func _matches_canonical_board(board: Dictionary) -> bool:
|
|||
var candidates: Array[FishDataType] = []
|
||||
if _catalog != null:
|
||||
for fish: FishDataType in _catalog.candidates:
|
||||
if fish != null and fish.is_fishable():
|
||||
if fish != null and fish.is_selectable():
|
||||
candidates.append(fish)
|
||||
var anchor_index: int = int(board.get("schedule_anchor_index", -1))
|
||||
var allow_weather_jobs: bool = (
|
||||
|
|
|
|||
164
main/main.gd
164
main/main.gd
|
|
@ -6,6 +6,7 @@ const FishPoolType = preload("res://fish/fish_pool.gd")
|
|||
const GameUIType = preload("res://ui/game_ui.gd")
|
||||
const PlayerType = preload("res://player/player.gd")
|
||||
const TestWorldType = preload("res://world/test_world.gd")
|
||||
const WorldLayoutType = preload("res://world/world_layout.gd")
|
||||
const WaterRecoveryControllerType = preload(
|
||||
"res://world/water_recovery_controller.gd"
|
||||
)
|
||||
|
|
@ -124,6 +125,7 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
|
|||
const TITLE_MUSIC_PATH: String = (
|
||||
"res://audio/music/title/as_in_four_wolves.ogg"
|
||||
)
|
||||
const NEW_GAME_MUSIC_PATH: String = "res://audio/music/world/tulips.wav"
|
||||
const DUSK_MUSIC_PATH: String = "res://audio/music/world/craft.mp3"
|
||||
const TIME_CROSSING_EPSILON_HOURS: float = 0.000001
|
||||
const PLAYER_MENU_PATTERN_SCALE: float = 0.85
|
||||
|
|
@ -138,6 +140,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
|
|||
@export_category("Title Music")
|
||||
@export_range(-40.0, 0.0, 0.5) var title_music_volume_db: float = -6.0
|
||||
@export_range(0.0, 10.0, 0.05) var title_music_fade_out_seconds: float = 5.0
|
||||
@export_range(-40.0, 0.0, 0.5) var new_game_music_volume_db: float = -6.0
|
||||
|
||||
@onready var _test_world: TestWorldType = $TestWorld
|
||||
@onready var _player: PlayerType = %Player
|
||||
|
|
@ -154,6 +157,7 @@ const SHOP_PATTERN_SCALE: float = 1.75
|
|||
)
|
||||
@onready var _interface_fonts: InterfaceFontController = %InterfaceFontController
|
||||
@onready var _title_music: AudioStreamPlayer = %TitleMusic
|
||||
@onready var _new_game_music: AudioStreamPlayer = %NewGameMusic
|
||||
@onready var _dusk_music: AudioStreamPlayer = %DuskMusic
|
||||
@onready var _ui_pixelation: UIPixelationPresenterType = %UIPresentation
|
||||
@onready var _pixelation_reset: PixelationResetOverlayType = (
|
||||
|
|
@ -356,6 +360,7 @@ func _configure_presentation_performance_profile(light_profile: bool) -> void:
|
|||
|
||||
func _configure_audio_performance_profile(light_profile: bool) -> void:
|
||||
_title_music.stream = _load_optional_audio_stream(TITLE_MUSIC_PATH)
|
||||
_new_game_music.stream = _load_optional_audio_stream(NEW_GAME_MUSIC_PATH)
|
||||
_dusk_music.stream = _load_optional_audio_stream(DUSK_MUSIC_PATH)
|
||||
if light_profile:
|
||||
_shoreline_ambience.set_audio_enabled(false)
|
||||
|
|
@ -409,10 +414,25 @@ func _start_dedicated_server() -> void:
|
|||
):
|
||||
_fail_dedicated_server("The server operator list is invalid.")
|
||||
return
|
||||
if not _apply_generated_world(config.world_seed, false):
|
||||
if not _network_chat.configure_dedicated_history(
|
||||
config.chat_logging,
|
||||
config.chat_log_path,
|
||||
):
|
||||
_fail_dedicated_server(
|
||||
"The dedicated-server chat privacy configuration is invalid."
|
||||
)
|
||||
return
|
||||
if not _apply_world(
|
||||
WorldLayoutType.GENERATED,
|
||||
config.world_seed,
|
||||
false,
|
||||
):
|
||||
_fail_dedicated_server("The configured world seed could not be generated.")
|
||||
return
|
||||
if not _network_session.set_host_world_seed(config.world_seed):
|
||||
if not _network_session.set_host_world(
|
||||
WorldLayoutType.GENERATED,
|
||||
config.world_seed,
|
||||
):
|
||||
_fail_dedicated_server("The configured world seed is invalid.")
|
||||
return
|
||||
_configure_portable_stores()
|
||||
|
|
@ -636,6 +656,7 @@ func _initialize_application(dedicated: bool) -> void:
|
|||
_world_time,
|
||||
_world_weather,
|
||||
_player_jobs,
|
||||
_player,
|
||||
)
|
||||
_player_jobs.set_save_manager(_save_manager)
|
||||
_save_manager.set_autosave_enabled(false)
|
||||
|
|
@ -747,7 +768,11 @@ func _initialize_application(dedicated: bool) -> void:
|
|||
_asset_reservations,
|
||||
_player.inventory_layout,
|
||||
)
|
||||
_player_jobs.bind_authoritative_services(_network_fishing, _network_sale)
|
||||
_player_jobs.bind_authoritative_services(
|
||||
_network_fishing,
|
||||
_network_sale,
|
||||
_network_world_spawns,
|
||||
)
|
||||
_network_shop.setup(
|
||||
_network_session,
|
||||
_player_spawn_service,
|
||||
|
|
@ -824,6 +849,7 @@ func _initialize_application(dedicated: bool) -> void:
|
|||
)
|
||||
_game_ui.setup_data_and_identity(
|
||||
_data_root,
|
||||
_save_manager,
|
||||
_identity_backups,
|
||||
_player_identity,
|
||||
_host_identity,
|
||||
|
|
@ -1571,7 +1597,10 @@ func _on_natural_time_advanced(advanced_hours: float) -> void:
|
|||
)
|
||||
):
|
||||
_dusk_music_played_for_natural_day = true
|
||||
if _dusk_music.stream != null:
|
||||
if (
|
||||
_dusk_music.stream != null
|
||||
and not _new_game_music.playing
|
||||
):
|
||||
_dusk_music.play(0.0)
|
||||
|
||||
|
||||
|
|
@ -1672,26 +1701,34 @@ func _resize_native_overlays() -> void:
|
|||
_shop_backdrop.size = Vector2(get_window().size)
|
||||
|
||||
|
||||
func _on_new_game_requested(world_seed: int) -> void:
|
||||
func _on_new_game_requested(
|
||||
world_layout: StringName,
|
||||
world_seed: int,
|
||||
) -> void:
|
||||
if _gameplay_started or _quit_in_progress:
|
||||
return
|
||||
if not _apply_generated_world(world_seed, true):
|
||||
_start_new_game_music()
|
||||
if not _apply_world(world_layout, world_seed, true):
|
||||
_show_title_music(true)
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Could not generate that world seed. Try rolling another world."
|
||||
"Could not prepare that world. Try another seed or the starter island."
|
||||
)
|
||||
return
|
||||
if not _prepare_host_world_seed(world_seed):
|
||||
if not _prepare_host_world(world_layout, world_seed):
|
||||
_show_title_music(true)
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Could not prepare the generated world for hosting."
|
||||
"Could not prepare that world for hosting."
|
||||
)
|
||||
return
|
||||
if not _prepare_private_host():
|
||||
_show_title_music(true)
|
||||
return
|
||||
if (
|
||||
not _save_manager.delete_progression_save()
|
||||
or not _save_manager.initialize_new_game(world_seed)
|
||||
or not _save_manager.initialize_new_game(world_seed, world_layout)
|
||||
):
|
||||
_network_session.disconnect_session("New Game setup failed.")
|
||||
_show_title_music(true)
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"Could not initialize local progression. Existing data was preserved where possible."
|
||||
)
|
||||
|
|
@ -1707,13 +1744,14 @@ func _on_continue_game_requested() -> void:
|
|||
"Failed to load save. The original was preserved."
|
||||
)
|
||||
return
|
||||
var world_layout: StringName = _save_manager.get_world_layout()
|
||||
var world_seed: int = _save_manager.get_world_seed()
|
||||
if (
|
||||
not _apply_generated_world(world_seed, true)
|
||||
or not _prepare_host_world_seed(world_seed)
|
||||
not _apply_world(world_layout, world_seed, true)
|
||||
or not _prepare_host_world(world_layout, world_seed)
|
||||
):
|
||||
_game_ui.get_title_screen().report_network_error(
|
||||
"The saved world could not be generated. The save was preserved."
|
||||
"The saved world could not be loaded. The save was preserved."
|
||||
)
|
||||
return
|
||||
if not _prepare_private_host():
|
||||
|
|
@ -1740,13 +1778,16 @@ func _prepare_private_host() -> bool:
|
|||
return true
|
||||
|
||||
|
||||
func _prepare_host_world_seed(world_seed: int) -> bool:
|
||||
func _prepare_host_world(
|
||||
world_layout: StringName,
|
||||
world_seed: int,
|
||||
) -> bool:
|
||||
if _network_session.state in [
|
||||
NetworkSessionType.State.CONNECTION_FAILED,
|
||||
NetworkSessionType.State.SERVER_LOST,
|
||||
]:
|
||||
_network_session.reset_failure()
|
||||
return _network_session.set_host_world_seed(world_seed)
|
||||
return _network_session.set_host_world(world_layout, world_seed)
|
||||
|
||||
|
||||
func _enter_gameplay() -> void:
|
||||
|
|
@ -1950,11 +1991,14 @@ func _on_reset_progress_requested() -> void:
|
|||
pause_menu.report_reset_failure()
|
||||
return
|
||||
var world_seed: int = PlayerSaveManager.roll_world_seed()
|
||||
if not _save_manager.initialize_new_game(world_seed):
|
||||
if not _save_manager.initialize_new_game(
|
||||
world_seed,
|
||||
WorldLayoutType.GENERATED,
|
||||
):
|
||||
pause_menu.report_reset_failure()
|
||||
return
|
||||
_network_session.disconnect_session("Progress reset.")
|
||||
if not _apply_generated_world(world_seed, true):
|
||||
if not _apply_world(WorldLayoutType.GENERATED, world_seed, true):
|
||||
pause_menu.report_reset_failure()
|
||||
return
|
||||
pause_menu.close_for_title_transition()
|
||||
|
|
@ -1964,8 +2008,18 @@ func _on_reset_progress_requested() -> void:
|
|||
|
||||
|
||||
func _apply_joined_world(server_metadata: Dictionary) -> bool:
|
||||
var world_layout: StringName = WorldLayoutType.normalized(
|
||||
server_metadata.get(
|
||||
"world_layout",
|
||||
String(WorldLayoutType.GENERATED),
|
||||
),
|
||||
&"",
|
||||
)
|
||||
var world_seed: int = int(server_metadata.get("world_seed", 0))
|
||||
if _apply_generated_world(world_seed, true):
|
||||
if (
|
||||
not world_layout.is_empty()
|
||||
and _apply_world(world_layout, world_seed, true)
|
||||
):
|
||||
return true
|
||||
_network_session.disconnect_session("World generation failed.")
|
||||
_join_requested_from_title = false
|
||||
|
|
@ -1974,20 +2028,26 @@ func _apply_joined_world(server_metadata: Dictionary) -> bool:
|
|||
var title_screen: TitleScreenType = _game_ui.get_title_screen()
|
||||
title_screen.reopen_to_menu()
|
||||
title_screen.report_network_error(
|
||||
"The host's generated world could not be built locally."
|
||||
"The host's world could not be built locally."
|
||||
)
|
||||
_show_title_music(true)
|
||||
return false
|
||||
|
||||
|
||||
func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool:
|
||||
if not _test_world.generate_world(world_seed):
|
||||
func _apply_world(
|
||||
world_layout: StringName,
|
||||
world_seed: int,
|
||||
reposition_player: bool,
|
||||
) -> bool:
|
||||
if not _test_world.activate_world(world_layout, world_seed):
|
||||
return false
|
||||
var spawn_transform: Transform3D = _test_world.get_player_spawn_transform()
|
||||
_player_spawn_service.set_spawn_transform(spawn_transform)
|
||||
if reposition_player:
|
||||
_player.global_transform = spawn_transform
|
||||
_player.velocity = Vector3.ZERO
|
||||
if _application_initialized:
|
||||
_refresh_active_world_bindings()
|
||||
if _application_initialized and not _dedicated_runtime:
|
||||
_water_recovery.update_world_context(
|
||||
spawn_transform,
|
||||
|
|
@ -2001,6 +2061,51 @@ func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool:
|
|||
return true
|
||||
|
||||
|
||||
func _refresh_active_world_bindings() -> void:
|
||||
if is_instance_valid(_shop_interaction):
|
||||
if _shop_interaction.local_player_range_changed.is_connected(
|
||||
_on_shop_range_changed
|
||||
):
|
||||
_shop_interaction.local_player_range_changed.disconnect(
|
||||
_on_shop_range_changed
|
||||
)
|
||||
if is_instance_valid(_storage_interaction):
|
||||
if _storage_interaction.local_player_range_changed.is_connected(
|
||||
_on_storage_range_changed
|
||||
):
|
||||
_storage_interaction.local_player_range_changed.disconnect(
|
||||
_on_storage_range_changed
|
||||
)
|
||||
_shop_interaction = _test_world.get_fishing_shop()
|
||||
_storage_interaction = _test_world.get_player_storage()
|
||||
_network_sale.set_shop_interaction(_shop_interaction)
|
||||
_network_shop.set_shop_interaction(_shop_interaction)
|
||||
if _dedicated_runtime:
|
||||
return
|
||||
if _shop_interaction != null:
|
||||
_shop_interaction.setup_local_player(_player)
|
||||
_shop_interaction.local_player_range_changed.connect(
|
||||
_on_shop_range_changed
|
||||
)
|
||||
if _storage_interaction != null:
|
||||
_storage_interaction.setup_local_player(_player)
|
||||
_storage_interaction.local_player_range_changed.connect(
|
||||
_on_storage_range_changed
|
||||
)
|
||||
_game_ui.set_world_interactions(
|
||||
_shop_interaction,
|
||||
_storage_interaction,
|
||||
)
|
||||
_on_shop_range_changed(
|
||||
_shop_interaction != null
|
||||
and _shop_interaction.is_local_player_in_range()
|
||||
)
|
||||
_on_storage_range_changed(
|
||||
_storage_interaction != null
|
||||
and _storage_interaction.is_local_player_in_range()
|
||||
)
|
||||
|
||||
|
||||
func _on_water_recovery_starting() -> void:
|
||||
_local_recovery_attempt_id = (
|
||||
"recovery:%s"
|
||||
|
|
@ -2120,6 +2225,7 @@ func _on_quit_requested() -> void:
|
|||
|
||||
|
||||
func _show_title_music(restart_from_beginning: bool = false) -> void:
|
||||
_new_game_music.stop()
|
||||
_title_music_requested = true
|
||||
_replace_title_music_transition()
|
||||
if _title_music.stream == null:
|
||||
|
|
@ -2131,6 +2237,19 @@ func _show_title_music(restart_from_beginning: bool = false) -> void:
|
|||
_title_music.play(0.0)
|
||||
|
||||
|
||||
func _start_new_game_music() -> void:
|
||||
if _new_game_music.stream == null:
|
||||
return
|
||||
_title_music_requested = false
|
||||
_replace_title_music_transition()
|
||||
_title_music.stop()
|
||||
_title_music.volume_db = title_music_volume_db
|
||||
_dusk_music.stop()
|
||||
_new_game_music.stop()
|
||||
_new_game_music.volume_db = new_game_music_volume_db
|
||||
_new_game_music.play(0.0)
|
||||
|
||||
|
||||
func _fade_out_title_music(on_complete: Callable = Callable()) -> void:
|
||||
_title_music_requested = false
|
||||
var generation: int = _replace_title_music_transition()
|
||||
|
|
@ -2196,6 +2315,9 @@ func _exit_tree() -> void:
|
|||
if is_instance_valid(_title_music):
|
||||
_title_music.stop()
|
||||
_title_music.stream = null
|
||||
if is_instance_valid(_new_game_music):
|
||||
_new_game_music.stop()
|
||||
_new_game_music.stream = null
|
||||
if is_instance_valid(_dusk_music):
|
||||
_dusk_music.stop()
|
||||
_dusk_music.stream = null
|
||||
|
|
|
|||
|
|
@ -361,6 +361,10 @@ unique_name_in_owner = true
|
|||
volume_db = -80.0
|
||||
bus = &"Music"
|
||||
|
||||
[node name="NewGameMusic" type="AudioStreamPlayer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
bus = &"Music"
|
||||
|
||||
[node name="DuskMusic" type="AudioStreamPlayer" parent="." unique_id=992744241]
|
||||
unique_name_in_owner = true
|
||||
bus = &"Music"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ const MAX_HISTORY: int = 100
|
|||
const LATE_JOIN_HISTORY: int = 50
|
||||
const MAX_ID_LENGTH: int = 96
|
||||
|
||||
static var _invisible_edge_pattern: RegEx = RegEx.create_from_string(
|
||||
"^[\\s\\p{Z}\\p{Cf}\\x{2800}]+|[\\s\\p{Z}\\p{Cf}\\x{2800}]+$"
|
||||
)
|
||||
static var _visible_content_pattern: RegEx = RegEx.create_from_string(
|
||||
"[^\\s\\p{Z}\\p{Cf}\\x{2800}]"
|
||||
)
|
||||
|
||||
enum Kind { PLAYER, SYSTEM }
|
||||
|
||||
|
||||
|
|
@ -16,8 +23,13 @@ static func sanitize_body(value: Variant) -> String:
|
|||
if typeof(value) != TYPE_STRING:
|
||||
return ""
|
||||
var result: String = str(value).replace("\r", " ").replace("\n", " ")
|
||||
result = result.replace("\t", " ").strip_edges()
|
||||
if result.is_empty() or result.length() > MAX_VISIBLE_CHARACTERS:
|
||||
result = result.replace("\t", " ")
|
||||
result = _invisible_edge_pattern.sub(result, "", true)
|
||||
if (
|
||||
result.is_empty()
|
||||
or _visible_content_pattern.search(result) == null
|
||||
or result.length() > MAX_VISIBLE_CHARACTERS
|
||||
):
|
||||
return ""
|
||||
if result.to_utf8_buffer().size() > MAX_UTF8_BYTES:
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ extends Node
|
|||
const BURST_COUNT: int = 3
|
||||
const WINDOW_COUNT: int = 5
|
||||
const WINDOW_SECONDS: float = 10.0
|
||||
const CALL_COOLDOWN_MILLISECONDS: int = 180
|
||||
const CALL_COOLDOWN_MILLISECONDS: int = 90
|
||||
const DEDICATED_CHAT_WARNING: String = (
|
||||
"Privacy notice: This dedicated server records and retains chat messages."
|
||||
)
|
||||
const CALL_PITCH_VARIANTS: Array[float] = [
|
||||
0.96,
|
||||
1.03,
|
||||
|
|
@ -38,6 +41,32 @@ var _call_variant_indices: Dictionary[int, int] = {}
|
|||
var _sequence: int = 0
|
||||
var _peer_names: Dictionary[int, String] = {}
|
||||
var _relationships: PlayerRelationshipStore
|
||||
var _dedicated_history_enabled: bool = false
|
||||
var _chat_log_path: String = ""
|
||||
|
||||
|
||||
func configure_dedicated_history(enabled: bool, log_path: String) -> bool:
|
||||
_dedicated_history_enabled = false
|
||||
_chat_log_path = ""
|
||||
if not enabled:
|
||||
return true
|
||||
var normalized_path: String = log_path.strip_edges()
|
||||
if normalized_path.is_empty() or not normalized_path.is_absolute_path():
|
||||
return false
|
||||
var directory: String = normalized_path.get_base_dir()
|
||||
if DirAccess.make_dir_recursive_absolute(directory) != OK:
|
||||
return false
|
||||
var file: FileAccess
|
||||
if FileAccess.file_exists(normalized_path):
|
||||
file = FileAccess.open(normalized_path, FileAccess.READ_WRITE)
|
||||
else:
|
||||
file = FileAccess.open(normalized_path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.close()
|
||||
_dedicated_history_enabled = true
|
||||
_chat_log_path = normalized_path
|
||||
return true
|
||||
|
||||
|
||||
func setup(session: NetworkSession) -> void:
|
||||
|
|
@ -288,7 +317,8 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
|
|||
var request_id: String = data["request_id"]
|
||||
var ledger: Dictionary = _request_ledgers.get(peer_id, {})
|
||||
if ledger.has(request_id):
|
||||
_send_message(peer_id, ledger[request_id])
|
||||
if typeof(ledger[request_id]) == TYPE_DICTIONARY:
|
||||
_send_message(peer_id, ledger[request_id])
|
||||
return
|
||||
if not _consume_rate(peer_id):
|
||||
_send_rejection(peer_id, "Slow down.")
|
||||
|
|
@ -308,7 +338,11 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
|
|||
message["request_id"] = request_id
|
||||
message["sender_fingerprint"] = data["sender_fingerprint"]
|
||||
message["sender_signature"] = data["sender_signature"]
|
||||
ledger[request_id] = message.duplicate(true)
|
||||
ledger[request_id] = (
|
||||
message.duplicate(true)
|
||||
if _should_store_host_history()
|
||||
else true
|
||||
)
|
||||
while ledger.size() > 64:
|
||||
ledger.erase(ledger.keys().front())
|
||||
_request_ledgers[peer_id] = ledger
|
||||
|
|
@ -404,9 +438,12 @@ func _apply_message(
|
|||
kind == NetworkChatProtocol.Kind.PLAYER
|
||||
and is_sender_filtered(str(data.get("sender_fingerprint", "")))
|
||||
)
|
||||
_history.append(stored_message)
|
||||
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
|
||||
_history.pop_front()
|
||||
if _should_store_local_history():
|
||||
_history.append(stored_message)
|
||||
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
|
||||
_history.pop_front()
|
||||
if _should_log_message(stored_message):
|
||||
_append_chat_log(stored_message)
|
||||
if emit_live_signals and _message_is_visible(stored_message):
|
||||
message_received.emit(stored_message.duplicate(true))
|
||||
if (
|
||||
|
|
@ -433,8 +470,26 @@ func _on_peer_authenticated(peer_id: int, display_name: String) -> void:
|
|||
if not _session.is_host():
|
||||
return
|
||||
_peer_names[peer_id] = display_name
|
||||
var start := maxi(0, _history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY)
|
||||
receive_chat_history.rpc_id(peer_id, _history.slice(start))
|
||||
var history: Array[Dictionary] = []
|
||||
if _dedicated_history_enabled and _session.is_dedicated_host():
|
||||
var start := maxi(
|
||||
0,
|
||||
_history.size() - NetworkChatProtocol.LATE_JOIN_HISTORY,
|
||||
)
|
||||
history = _history.slice(start)
|
||||
# An explicit empty replacement is intentional for player-hosted rooms and
|
||||
# dedicated servers without logging. It destroys any stale client scrollback
|
||||
# instead of replaying another player's live-session chat on reconnect.
|
||||
receive_chat_history.rpc_id(peer_id, history)
|
||||
if _dedicated_history_enabled and _session.is_dedicated_host():
|
||||
# Deliver the disclosure as a live system message after scrollback. This
|
||||
# raises the normal unread indicator without creating speech or audio.
|
||||
_send_message(peer_id, _make_message(
|
||||
NetworkChatProtocol.Kind.SYSTEM,
|
||||
0,
|
||||
"",
|
||||
DEDICATED_CHAT_WARNING,
|
||||
))
|
||||
_broadcast(_make_message(
|
||||
NetworkChatProtocol.Kind.SYSTEM, 0, "", "%s joined." % display_name
|
||||
))
|
||||
|
|
@ -494,6 +549,44 @@ func _consume_rate(peer_id: int) -> bool:
|
|||
return true
|
||||
|
||||
|
||||
func _should_store_host_history() -> bool:
|
||||
return (
|
||||
_session == null
|
||||
or not _session.is_dedicated_host()
|
||||
or _dedicated_history_enabled
|
||||
)
|
||||
|
||||
|
||||
func _should_store_local_history() -> bool:
|
||||
return _should_store_host_history()
|
||||
|
||||
|
||||
func _should_log_message(message: Dictionary) -> bool:
|
||||
return (
|
||||
_dedicated_history_enabled
|
||||
and not _chat_log_path.is_empty()
|
||||
and _session != null
|
||||
and _session.is_dedicated_host()
|
||||
and int(message.get("kind", -1)) == NetworkChatProtocol.Kind.PLAYER
|
||||
)
|
||||
|
||||
|
||||
func _append_chat_log(message: Dictionary) -> void:
|
||||
var file := FileAccess.open(_chat_log_path, FileAccess.READ_WRITE)
|
||||
if file == null:
|
||||
push_error("The configured dedicated-server chat log is unavailable.")
|
||||
return
|
||||
file.seek_end()
|
||||
file.store_line(JSON.stringify({
|
||||
"recorded_at_utc": (
|
||||
Time.get_datetime_string_from_system(true, false) + "Z"
|
||||
),
|
||||
"sender_display_name": str(message.get("sender_display_name", "")),
|
||||
"body": str(message.get("body", "")),
|
||||
}))
|
||||
file.close()
|
||||
|
||||
|
||||
func _on_session_state_changed(state: NetworkSession.State) -> void:
|
||||
if state in [
|
||||
NetworkSession.State.INACTIVE,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
class_name NetworkProtocol
|
||||
extends RefCounted
|
||||
|
||||
const WorldLayoutType = preload("res://world/world_layout.gd")
|
||||
|
||||
const PROTOCOL_VERSION: int = 9
|
||||
const GAME_BUILD: String = "prealpha"
|
||||
const MAX_GAME_VERSION_LENGTH: int = 64
|
||||
|
|
@ -29,6 +31,7 @@ const JOBS_CAPABILITY: String = "jobs_v1"
|
|||
const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1"
|
||||
const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1"
|
||||
const WORLD_GENERATION_CAPABILITY: String = "world_generation_v1"
|
||||
const WORLD_LAYOUT_CAPABILITY: String = "world_layout_v1"
|
||||
const DEFAULT_WORLD_SEED: int = 13001
|
||||
const MAX_WORLD_SEED: int = 2147483646
|
||||
|
||||
|
|
@ -182,6 +185,7 @@ static func make_client_hello(
|
|||
APPEARANCE_PREVIEW_CAPABILITY,
|
||||
BACKPACK_SHOP_CAPABILITY,
|
||||
WORLD_GENERATION_CAPABILITY,
|
||||
WORLD_LAYOUT_CAPABILITY,
|
||||
]),
|
||||
"cosmetic_snapshot": cosmetic_snapshot,
|
||||
"identity_fingerprint": identity_fingerprint,
|
||||
|
|
@ -286,6 +290,7 @@ static func make_server_hello(
|
|||
max_players: int,
|
||||
server_display_name: String = "NETfishing",
|
||||
world_seed: int = DEFAULT_WORLD_SEED,
|
||||
world_layout: StringName = WorldLayoutType.GENERATED,
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"accepted": accepted,
|
||||
|
|
@ -298,6 +303,7 @@ static func make_server_hello(
|
|||
"player_count": player_count,
|
||||
"max_players": max_players,
|
||||
"world_seed": world_seed,
|
||||
"world_layout": String(world_layout),
|
||||
"capability_flags": PackedStringArray([
|
||||
"movement_v1",
|
||||
"fishing_v1",
|
||||
|
|
@ -316,6 +322,7 @@ static func make_server_hello(
|
|||
WORLD_SPAWN_CAPABILITY,
|
||||
APPEARANCE_PREVIEW_CAPABILITY,
|
||||
WORLD_GENERATION_CAPABILITY,
|
||||
WORLD_LAYOUT_CAPABILITY,
|
||||
"chat_v1",
|
||||
"mail_v1",
|
||||
"profile_v1",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ func setup(
|
|||
_session.state_changed.connect(_on_session_state_changed)
|
||||
|
||||
|
||||
func set_shop_interaction(interaction: FishingShopInteraction) -> void:
|
||||
_shop_interaction = interaction
|
||||
|
||||
|
||||
func is_local_sale_pending() -> bool:
|
||||
return not _pending_local_request_id.is_empty()
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ var _last_server_player_count: int = 0
|
|||
var _last_server_display_name: String = ""
|
||||
var _last_server_protocol_version: int = 0
|
||||
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
|
||||
var _last_server_world_layout: StringName = WorldLayout.GENERATED
|
||||
var _server_capabilities: PackedStringArray = PackedStringArray()
|
||||
var _profile_ready: bool = false
|
||||
var _player_identity: PlayerIdentityStore
|
||||
|
|
@ -113,6 +114,7 @@ var _session_operator_fingerprints: Dictionary[String, bool] = {}
|
|||
var _operator_peer_ids: Dictionary[int, bool] = {}
|
||||
var _local_operator: bool = false
|
||||
var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
|
||||
var _host_world_layout: StringName = WorldLayout.GENERATED
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -275,6 +277,7 @@ func _register_player_host() -> void:
|
|||
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
|
||||
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
|
||||
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
|
||||
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
|
||||
]),
|
||||
)
|
||||
_registry.update_appearance(1, _local_appearance_snapshot)
|
||||
|
|
@ -531,20 +534,35 @@ func get_last_server_metadata() -> Dictionary:
|
|||
"player_count": _last_server_player_count,
|
||||
"max_players": _last_server_max_players,
|
||||
"world_seed": _last_server_world_seed,
|
||||
"world_layout": String(_last_server_world_layout),
|
||||
}
|
||||
|
||||
|
||||
func set_host_world_seed(seed: int) -> bool:
|
||||
return set_host_world(WorldLayout.GENERATED, seed)
|
||||
|
||||
|
||||
func set_host_world(world_layout: StringName, seed: int) -> bool:
|
||||
if (
|
||||
state != State.INACTIVE
|
||||
or not WorldLayout.is_valid(world_layout)
|
||||
or seed <= 0
|
||||
or seed > NetworkProtocol.MAX_WORLD_SEED
|
||||
):
|
||||
return false
|
||||
_host_world_layout = world_layout
|
||||
_host_world_seed = seed
|
||||
return true
|
||||
|
||||
|
||||
func get_authoritative_world_layout() -> StringName:
|
||||
return (
|
||||
_last_server_world_layout
|
||||
if is_joined_client()
|
||||
else _host_world_layout
|
||||
)
|
||||
|
||||
|
||||
func get_authoritative_world_seed() -> int:
|
||||
return _last_server_world_seed if is_joined_client() else _host_world_seed
|
||||
|
||||
|
|
@ -583,6 +601,7 @@ func supports_server_capability(capability: StringName) -> bool:
|
|||
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
|
||||
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
|
||||
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
|
||||
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
|
||||
"chat_v1",
|
||||
"mail_v1",
|
||||
"profile_v1",
|
||||
|
|
@ -1190,6 +1209,15 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
|
||||
)
|
||||
return
|
||||
if (
|
||||
_host_world_layout == WorldLayout.STARTER_ISLAND
|
||||
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in client_capabilities
|
||||
):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
|
||||
)
|
||||
return
|
||||
var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
|
||||
if identity.is_empty():
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
|
||||
|
|
@ -1278,6 +1306,7 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
session_max_players,
|
||||
_session_display_name,
|
||||
_host_world_seed,
|
||||
_host_world_layout,
|
||||
)
|
||||
)
|
||||
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
|
||||
|
|
@ -1332,6 +1361,9 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
or typeof(data.get("game_version")) != TYPE_STRING
|
||||
or typeof(data.get("rejection_code")) != TYPE_INT
|
||||
or typeof(data.get("world_seed")) != TYPE_INT
|
||||
or typeof(
|
||||
data.get("world_layout", String(WorldLayout.GENERATED))
|
||||
) not in [TYPE_STRING, TYPE_STRING_NAME]
|
||||
):
|
||||
_teardown_peer()
|
||||
_fail("The server sent an invalid handshake response.")
|
||||
|
|
@ -1362,6 +1394,14 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
_teardown_peer()
|
||||
_fail("The server sent an invalid world seed.")
|
||||
return
|
||||
var received_world_layout := WorldLayout.normalized(
|
||||
data.get("world_layout", String(WorldLayout.GENERATED)),
|
||||
&"",
|
||||
)
|
||||
if received_world_layout.is_empty():
|
||||
_teardown_peer()
|
||||
_fail("The server sent an invalid world layout.")
|
||||
return
|
||||
_session_id = str(data.get("session_id", ""))
|
||||
_last_server_max_players = int(data.get(
|
||||
"max_players",
|
||||
|
|
@ -1373,6 +1413,7 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
"protocol_version", NetworkProtocol.PROTOCOL_VERSION
|
||||
))
|
||||
_last_server_world_seed = received_world_seed
|
||||
_last_server_world_layout = received_world_layout
|
||||
_server_capabilities = PackedStringArray()
|
||||
var advertised_capabilities: Variant = data.get(
|
||||
"capability_flags", PackedStringArray()
|
||||
|
|
@ -1391,6 +1432,13 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
_teardown_peer()
|
||||
_fail("This server does not support generated worlds.")
|
||||
return
|
||||
if (
|
||||
received_world_layout == WorldLayout.STARTER_ISLAND
|
||||
and NetworkProtocol.WORLD_LAYOUT_CAPABILITY not in _server_capabilities
|
||||
):
|
||||
_teardown_peer()
|
||||
_fail("This server does not support selectable world layouts.")
|
||||
return
|
||||
var local_peer_id: int = multiplayer.get_unique_id()
|
||||
_registry.clear()
|
||||
_registry.add_peer(
|
||||
|
|
@ -1407,6 +1455,7 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
|
||||
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
|
||||
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
|
||||
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
|
||||
]),
|
||||
)
|
||||
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ func setup(
|
|||
_session.state_changed.connect(_on_session_state_changed)
|
||||
|
||||
|
||||
func set_shop_interaction(interaction: FishingShopInteraction) -> void:
|
||||
_interaction = interaction
|
||||
|
||||
|
||||
func can_request_purchase() -> bool:
|
||||
return (
|
||||
_session != null
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ func activate_process_root(path: String) -> bool:
|
|||
|
||||
func path_for(store_owner: StringName) -> String:
|
||||
var relative: String = {
|
||||
&"player_save": "player/player_save.json",
|
||||
&"player_save": "player/player_save.nfsave",
|
||||
&"network_profile": "player/network_profile.json",
|
||||
&"player_appearance": "player/player_appearance.json",
|
||||
&"saved_servers": "social/saved_servers.json",
|
||||
|
|
@ -208,6 +208,10 @@ func identity_backup_directory() -> String:
|
|||
return root_path.path_join("identity-backups")
|
||||
|
||||
|
||||
func progression_backup_directory() -> String:
|
||||
return root_path.path_join("progression-backups")
|
||||
|
||||
|
||||
func migration_backup_directory() -> String:
|
||||
return root_path.path_join("backups/migrations")
|
||||
|
||||
|
|
@ -347,7 +351,7 @@ func _test_writable(path: String) -> bool:
|
|||
func _create_layout(path: String, id: String) -> bool:
|
||||
for relative: String in [
|
||||
"player", "social", "backups/saves", "backups/migrations",
|
||||
"backups/conflicts", "identity-backups",
|
||||
"backups/conflicts", "identity-backups", "progression-backups",
|
||||
]:
|
||||
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
|
||||
return false
|
||||
|
|
@ -369,6 +373,7 @@ func _create_layout(path: String, id: String) -> bool:
|
|||
+ "This folder is safe to synchronize with tools such as Syncthing.\n"
|
||||
+ "Active private identity keys remain device-local.\n"
|
||||
+ "Encrypted identity backups require their passphrase.\n"
|
||||
+ "Progression exports do not contain identity keys or social data.\n"
|
||||
+ "Chat and Session Mail are not stored here.\n"
|
||||
+ "Do not play the same profile on two devices at the same time.\n"
|
||||
+ "Conflicting edits are preserved under backups/conflicts; they are not merged.\n"
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ static func migrate_active_to(
|
|||
_remove_tree(staging)
|
||||
return {"ok": false, "message": str(created.get("message", ""))}
|
||||
for relative: String in [
|
||||
"player/player_save.nfsave",
|
||||
"player/player_save.json",
|
||||
"player/network_profile.json",
|
||||
"player/player_appearance.json",
|
||||
|
|
@ -134,7 +135,11 @@ static func migrate_active_to(
|
|||
var source: String = data_root.root_path.path_join(relative)
|
||||
if not FileAccess.file_exists(source):
|
||||
continue
|
||||
if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
|
||||
if not bool(
|
||||
_copy_verified_owned_file(
|
||||
source, staging.path_join(relative)
|
||||
).get("ok", false)
|
||||
):
|
||||
_remove_tree(staging)
|
||||
return {"ok": false, "message": "Migration validation failed for %s." % relative}
|
||||
if DirAccess.dir_exists_absolute(normalized):
|
||||
|
|
@ -248,6 +253,30 @@ static func _copy_verified_json(source: String, destination: String) -> Dictiona
|
|||
}
|
||||
|
||||
|
||||
static func _copy_verified_owned_file(
|
||||
source: String,
|
||||
destination: String,
|
||||
) -> Dictionary:
|
||||
if source.get_file() != "player_save.nfsave":
|
||||
return _copy_verified_json(source, destination)
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(source)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
return {"ok": false}
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
var version: int = int(save_data.get("save_version", -1))
|
||||
if version < 1 or version > PlayerSaveManager.SAVE_VERSION:
|
||||
return {"ok": false}
|
||||
var bytes: PackedByteArray = PortableFileGuard.read_bytes(source)
|
||||
if bytes.is_empty() or not _write_bytes(destination, bytes):
|
||||
return {"ok": false}
|
||||
return {
|
||||
"ok": (
|
||||
PortableFileGuard.hash_file(destination)
|
||||
== PortableFileGuard.hash_bytes(bytes)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
|
||||
if filename == "player_save.json":
|
||||
var version: int = int(data.get("save_version", -1))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ config_version=5
|
|||
[application]
|
||||
|
||||
config/name="NETFISHING"
|
||||
config/version="0.13.1-alpha"
|
||||
config/version="0.14.0-alpha"
|
||||
run/main_scene="res://main/main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
config/icon="res://art/exported/system_icons/netfishing_256.png"
|
||||
|
|
@ -107,6 +107,11 @@ fish_primary={
|
|||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
reel_alternate={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":96,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
camera_drag={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":32,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||
|
|
|
|||
|
|
@ -31,9 +31,13 @@ const WorldTimeServiceType = preload("res://world/world_time_service.gd")
|
|||
const WorldWeatherServiceType = preload(
|
||||
"res://world/world_weather_service.gd"
|
||||
)
|
||||
const WorldLayoutType = preload("res://world/world_layout.gd")
|
||||
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
|
||||
const PlayerType = preload("res://player/player.gd")
|
||||
|
||||
const SAVE_VERSION: int = 9
|
||||
const SAVE_VERSION: int = 10
|
||||
const LEGACY_SAVE_FILENAME := "player_save.json"
|
||||
const ARCHIVE_EXTENSION := ".nfsave"
|
||||
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
|
||||
const MAX_SAFE_BALANCE: int = 1000000000000
|
||||
const DEFAULT_WORLD_SEED: int = 13001
|
||||
|
|
@ -49,6 +53,8 @@ class LoadSnapshot:
|
|||
var next_catch_sequence: int = 1
|
||||
var bag_items: Array[OwnedItemType] = []
|
||||
var unlocked_bait_ids: Array[StringName] = []
|
||||
var active_bait_id: StringName
|
||||
var active_lure_id: StringName
|
||||
var hotbar_slots: Array[StringName] = []
|
||||
var fish_hotbar_slots: Array[StringName] = []
|
||||
var selected_hotbar_slot: int = 0
|
||||
|
|
@ -58,6 +64,7 @@ class LoadSnapshot:
|
|||
var cooler_capacity_level: int = 0
|
||||
var art_unlock_mask: int = 0
|
||||
var total_experience: int = 0
|
||||
var world_layout: StringName = WorldLayoutType.GENERATED
|
||||
var world_seed: int = DEFAULT_WORLD_SEED
|
||||
var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR
|
||||
var has_world_weather_state: bool = false
|
||||
|
|
@ -87,6 +94,7 @@ var _experience: PlayerExperienceType
|
|||
var _world_time: WorldTimeServiceType
|
||||
var _world_weather: WorldWeatherServiceType
|
||||
var _jobs: PlayerJobServiceType
|
||||
var _player: PlayerType
|
||||
var _autosave_timer: Timer
|
||||
var _is_configured: bool = false
|
||||
var _is_restoring: bool = false
|
||||
|
|
@ -96,6 +104,7 @@ var _autosave_enabled: bool = false
|
|||
var _save_path := ""
|
||||
var _expected_hash := ""
|
||||
var _data_root: PlayerDataRoot
|
||||
var _world_layout: StringName = WorldLayoutType.GENERATED
|
||||
var _world_seed: int = DEFAULT_WORLD_SEED
|
||||
|
||||
|
||||
|
|
@ -112,6 +121,21 @@ func _backup_path() -> String:
|
|||
return _save_path + ".backup"
|
||||
|
||||
|
||||
func _codec_scratch_path() -> String:
|
||||
return _save_path + ".codec.tmp"
|
||||
|
||||
|
||||
func _legacy_save_path() -> String:
|
||||
return _save_path.get_base_dir().path_join(LEGACY_SAVE_FILENAME)
|
||||
|
||||
|
||||
func _read_path() -> String:
|
||||
if FileAccess.file_exists(_save_path):
|
||||
return _save_path
|
||||
var legacy: String = _legacy_save_path()
|
||||
return legacy if FileAccess.file_exists(legacy) else ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_autosave_timer = Timer.new()
|
||||
_autosave_timer.one_shot = true
|
||||
|
|
@ -135,6 +159,7 @@ func setup(
|
|||
world_time: WorldTimeServiceType,
|
||||
world_weather: WorldWeatherServiceType,
|
||||
jobs: PlayerJobServiceType,
|
||||
player: PlayerType,
|
||||
) -> void:
|
||||
_inventory = inventory
|
||||
_collection_log = collection_log
|
||||
|
|
@ -151,6 +176,7 @@ func setup(
|
|||
_world_time = world_time
|
||||
_world_weather = world_weather
|
||||
_jobs = jobs
|
||||
_player = player
|
||||
_is_configured = (
|
||||
_inventory != null
|
||||
and _collection_log != null
|
||||
|
|
@ -167,6 +193,7 @@ func setup(
|
|||
and _world_time != null
|
||||
and _world_weather != null
|
||||
and _jobs != null
|
||||
and _player != null
|
||||
)
|
||||
if not _is_configured:
|
||||
push_error("PlayerSaveManager setup is missing required references.")
|
||||
|
|
@ -209,6 +236,10 @@ func setup(
|
|||
_experience.experience_changed.connect(_on_experience_changed)
|
||||
if not _jobs.changed.is_connected(_mark_dirty):
|
||||
_jobs.changed.connect(_mark_dirty)
|
||||
if not _player.active_bait_changed.is_connected(_on_active_tackle_changed):
|
||||
_player.active_bait_changed.connect(_on_active_tackle_changed)
|
||||
if not _player.active_lure_changed.is_connected(_on_active_tackle_changed):
|
||||
_player.active_lure_changed.connect(_on_active_tackle_changed)
|
||||
|
||||
|
||||
func load_player_data() -> bool:
|
||||
|
|
@ -216,24 +247,22 @@ func load_player_data() -> bool:
|
|||
return false
|
||||
_automatic_saving_blocked = false
|
||||
_recover_interrupted_write()
|
||||
if _save_path.is_empty() or not FileAccess.file_exists(_save_path):
|
||||
var read_path: String = _read_path()
|
||||
if _save_path.is_empty() or read_path.is_empty():
|
||||
_is_dirty = false
|
||||
return true
|
||||
|
||||
var save_file := FileAccess.open(_save_path, FileAccess.READ)
|
||||
if save_file == null:
|
||||
_handle_corrupt_save("Unable to open player save.")
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
read_path,
|
||||
read_path == _legacy_save_path(),
|
||||
)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
_handle_corrupt_save("Player save could not be decoded.", read_path)
|
||||
return false
|
||||
var json_text: String = save_file.get_as_text()
|
||||
save_file.close()
|
||||
_expected_hash = PortableFileGuard.hash_file(_save_path)
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_error: Error = json.parse(json_text)
|
||||
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
|
||||
_handle_corrupt_save("Player save contains malformed JSON.")
|
||||
return false
|
||||
var save_data: Dictionary = json.data
|
||||
_expected_hash = (
|
||||
PortableFileGuard.hash_file(_save_path)
|
||||
if read_path == _save_path else ""
|
||||
)
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
var version: int = _read_integer(save_data.get("save_version"), -1)
|
||||
if version > SAVE_VERSION:
|
||||
_automatic_saving_blocked = true
|
||||
|
|
@ -250,13 +279,16 @@ func load_player_data() -> bool:
|
|||
save_data = _migrate_save(save_data, version)
|
||||
if save_data.is_empty():
|
||||
_handle_corrupt_save(
|
||||
"Player save version %d is unsupported." % version
|
||||
"Player save version %d is unsupported." % version,
|
||||
read_path,
|
||||
)
|
||||
return false
|
||||
|
||||
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
|
||||
if snapshot == null:
|
||||
_handle_corrupt_save("Player save failed structural validation.")
|
||||
_handle_corrupt_save(
|
||||
"Player save failed structural validation.", read_path
|
||||
)
|
||||
return false
|
||||
|
||||
_is_restoring = true
|
||||
|
|
@ -277,6 +309,7 @@ func load_player_data() -> bool:
|
|||
_bag.replace_all_items(snapshot.bag_items)
|
||||
and _bag.replace_unlocked_bait_ids(snapshot.unlocked_bait_ids)
|
||||
)
|
||||
var tackle_restored: bool = _restore_tackle_selection(snapshot)
|
||||
var upgrades_restored: bool = _fishing_upgrades.restore_levels(
|
||||
snapshot.reel_speed_level,
|
||||
snapshot.barrier_power_level
|
||||
|
|
@ -302,6 +335,7 @@ func load_player_data() -> bool:
|
|||
var world_time_restored: bool = (
|
||||
_world_time.restore_persistent_time_hours(snapshot.world_time_hours)
|
||||
)
|
||||
_world_layout = snapshot.world_layout
|
||||
_world_seed = snapshot.world_seed
|
||||
var world_weather_restored: bool = true
|
||||
if snapshot.has_world_weather_state:
|
||||
|
|
@ -316,6 +350,7 @@ func load_player_data() -> bool:
|
|||
or not collection_restored
|
||||
or not wallet_restored
|
||||
or not bag_restored
|
||||
or not tackle_restored
|
||||
or not hotbar_restored
|
||||
or not upgrades_restored
|
||||
or not cooler_restored
|
||||
|
|
@ -329,7 +364,7 @@ func load_player_data() -> bool:
|
|||
push_error(
|
||||
(
|
||||
"Validated player save could not be restored: "
|
||||
+ "inventory=%s collection=%s wallet=%s bag=%s hotbar=%s "
|
||||
+ "inventory=%s collection=%s wallet=%s bag=%s tackle=%s hotbar=%s "
|
||||
+ "upgrades=%s cooler=%s layout=%s art=%s experience=%s "
|
||||
+ "time=%s weather=%s jobs=%s"
|
||||
)
|
||||
|
|
@ -338,6 +373,7 @@ func load_player_data() -> bool:
|
|||
collection_restored,
|
||||
wallet_restored,
|
||||
bag_restored,
|
||||
tackle_restored,
|
||||
hotbar_restored,
|
||||
upgrades_restored,
|
||||
cooler_restored,
|
||||
|
|
@ -352,6 +388,8 @@ func load_player_data() -> bool:
|
|||
return false
|
||||
|
||||
_is_dirty = false
|
||||
if read_path == _legacy_save_path():
|
||||
_migrate_legacy_plaintext_save(save_data, read_path)
|
||||
print(
|
||||
"Loaded player save version %d with %d catches."
|
||||
% [SAVE_VERSION, snapshot.catches.size()]
|
||||
|
|
@ -362,24 +400,21 @@ func load_player_data() -> bool:
|
|||
func inspect_save() -> SaveInspectionType:
|
||||
var result := SaveInspectionType.new()
|
||||
_recover_interrupted_write()
|
||||
result.has_primary_file = FileAccess.file_exists(_save_path)
|
||||
var read_path: String = _read_path()
|
||||
result.has_primary_file = not read_path.is_empty()
|
||||
if not result.has_primary_file:
|
||||
result.status = SaveInspectionType.Status.MISSING
|
||||
result.message = "no save found."
|
||||
return result
|
||||
var save_file := FileAccess.open(_save_path, FileAccess.READ)
|
||||
if save_file == null:
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
read_path,
|
||||
read_path == _legacy_save_path(),
|
||||
)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
result.status = SaveInspectionType.Status.IO_ERROR
|
||||
result.message = "the save could not be read."
|
||||
return result
|
||||
var json := JSON.new()
|
||||
var parse_error: Error = json.parse(save_file.get_as_text())
|
||||
save_file.close()
|
||||
if parse_error != OK or typeof(json.data) != TYPE_DICTIONARY:
|
||||
result.status = SaveInspectionType.Status.MALFORMED
|
||||
result.message = "the save is corrupt and was preserved."
|
||||
return result
|
||||
var save_data: Dictionary = json.data
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
result.detected_version = _read_integer(save_data.get("save_version"), -1)
|
||||
if result.detected_version > SAVE_VERSION:
|
||||
result.status = SaveInspectionType.Status.UNSUPPORTED_VERSION
|
||||
|
|
@ -408,18 +443,112 @@ func inspect_save() -> SaveInspectionType:
|
|||
return result
|
||||
|
||||
|
||||
func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool:
|
||||
func export_progression_archive(path: String) -> Dictionary:
|
||||
if not _is_configured or path.is_empty():
|
||||
return {"ok": false, "message": "progression export is unavailable."}
|
||||
if _is_dirty and not save_if_dirty():
|
||||
return {"ok": false, "message": "progression could not be saved first."}
|
||||
var source_path: String = _read_path()
|
||||
if source_path.is_empty():
|
||||
return {"ok": false, "message": "there is no progression to export."}
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
source_path,
|
||||
source_path == _legacy_save_path(),
|
||||
)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
return {"ok": false, "message": "the active progression could not be read."}
|
||||
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
|
||||
if not bool(prepared.get("ok", false)):
|
||||
return prepared
|
||||
var bytes: PackedByteArray = ProgressionSaveCodec.encode_archive(
|
||||
prepared["data"],
|
||||
path + ".codec.tmp",
|
||||
)
|
||||
if bytes.is_empty():
|
||||
return {"ok": false, "message": "the progression archive could not be encoded."}
|
||||
var result: Dictionary = PortableFileGuard.write_guarded(
|
||||
path,
|
||||
bytes,
|
||||
PortableFileGuard.hash_file(path),
|
||||
_data_root.conflict_directory(),
|
||||
_data_root.device_id,
|
||||
)
|
||||
if not bool(result.get("ok", false)):
|
||||
return {"ok": false, "message": "the progression archive could not be written."}
|
||||
var verified: Dictionary = inspect_progression_archive(path)
|
||||
if not bool(verified.get("ok", false)):
|
||||
return {"ok": false, "message": "the progression archive could not be verified."}
|
||||
verified["message"] = "progression archive created."
|
||||
return verified
|
||||
|
||||
|
||||
func inspect_progression_archive(path: String) -> Dictionary:
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_archive(path)
|
||||
if not bool(decoded.get("ok", false)):
|
||||
return {
|
||||
"ok": false,
|
||||
"message": str(decoded.get("message", "could not open progression archive.")),
|
||||
}
|
||||
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
|
||||
if not bool(prepared.get("ok", false)):
|
||||
return prepared
|
||||
prepared["game_version"] = str(decoded.get("game_version", ""))
|
||||
prepared["created_at_unix"] = int(decoded.get("created_at_unix", 0))
|
||||
return prepared
|
||||
|
||||
|
||||
func import_progression_archive(path: String) -> Dictionary:
|
||||
if not _is_configured:
|
||||
return {"ok": false, "message": "progression import is unavailable."}
|
||||
var inspected: Dictionary = inspect_progression_archive(path)
|
||||
if not bool(inspected.get("ok", false)):
|
||||
return inspected
|
||||
var current_path: String = _read_path()
|
||||
if not current_path.is_empty() and not _archive_replaced_save(current_path):
|
||||
return {"ok": false, "message": "the current progression could not be backed up."}
|
||||
var expected_hash: String = PortableFileGuard.hash_file(_save_path)
|
||||
var result: Dictionary = _write_current_save_data(
|
||||
inspected["data"], expected_hash
|
||||
)
|
||||
if not bool(result.get("ok", false)):
|
||||
return {"ok": false, "message": "the imported progression could not be installed."}
|
||||
_expected_hash = str(result.get("hash", ""))
|
||||
_remove_legacy_save_files()
|
||||
_is_dirty = false
|
||||
_automatic_saving_blocked = false
|
||||
return {
|
||||
"ok": true,
|
||||
"message": "progression imported.",
|
||||
"catch_count": inspected["catch_count"],
|
||||
"wallet_balance": inspected["wallet_balance"],
|
||||
"discovered_species_count": inspected["discovered_species_count"],
|
||||
}
|
||||
|
||||
|
||||
func initialize_new_game(
|
||||
world_seed: int = DEFAULT_WORLD_SEED,
|
||||
world_layout: StringName = WorldLayoutType.GENERATED,
|
||||
) -> bool:
|
||||
if not _is_configured:
|
||||
return false
|
||||
if world_seed <= 0 or world_seed > MAX_WORLD_SEED:
|
||||
if (
|
||||
world_seed <= 0
|
||||
or world_seed > MAX_WORLD_SEED
|
||||
or not WorldLayoutType.is_valid(world_layout)
|
||||
):
|
||||
return false
|
||||
_automatic_saving_blocked = false
|
||||
_restore_defaults()
|
||||
_world_layout = world_layout
|
||||
_world_seed = world_seed
|
||||
_is_dirty = true
|
||||
return true
|
||||
|
||||
|
||||
func get_world_layout() -> StringName:
|
||||
return _world_layout
|
||||
|
||||
|
||||
func get_world_seed() -> int:
|
||||
return _world_seed
|
||||
|
||||
|
|
@ -431,9 +560,19 @@ static func roll_world_seed() -> int:
|
|||
|
||||
|
||||
func delete_progression_save() -> bool:
|
||||
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
|
||||
return false
|
||||
for path: String in [_temp_path(), _backup_path()]:
|
||||
for primary_path: String in [_save_path, _legacy_save_path()]:
|
||||
if (
|
||||
FileAccess.file_exists(primary_path)
|
||||
and not _remove_if_present(primary_path)
|
||||
):
|
||||
return false
|
||||
for path: String in [
|
||||
_temp_path(),
|
||||
_backup_path(),
|
||||
_codec_scratch_path(),
|
||||
_legacy_save_path() + ".tmp",
|
||||
_legacy_save_path() + ".backup",
|
||||
]:
|
||||
if FileAccess.file_exists(path) and not _remove_if_present(path):
|
||||
push_warning("Unable to remove stale player-save auxiliary file.")
|
||||
if _autosave_timer != null:
|
||||
|
|
@ -485,16 +624,8 @@ func save_now() -> bool:
|
|||
var save_data: Dictionary = _build_save_dictionary()
|
||||
if save_data.is_empty():
|
||||
return false
|
||||
var json_text: String = JSON.stringify(save_data, "\t")
|
||||
if json_text.is_empty():
|
||||
return false
|
||||
|
||||
var result := PortableFileGuard.write_guarded(
|
||||
_save_path,
|
||||
json_text.to_utf8_buffer(),
|
||||
_expected_hash,
|
||||
_data_root.conflict_directory(),
|
||||
_data_root.device_id,
|
||||
var result: Dictionary = _write_current_save_data(
|
||||
save_data, _expected_hash
|
||||
)
|
||||
if bool(result.get("conflict", false)):
|
||||
_data_root.report_conflict(
|
||||
|
|
@ -574,6 +705,17 @@ func _build_save_dictionary() -> Dictionary:
|
|||
)
|
||||
):
|
||||
return {}
|
||||
var active_bait_id: StringName = _player.active_bait_id
|
||||
var active_lure_id: StringName = _player.active_lure_id
|
||||
if (
|
||||
(not active_bait_id.is_empty() and not _valid_active_tackle(
|
||||
active_bait_id, true
|
||||
))
|
||||
or (not active_lure_id.is_empty() and not _valid_active_tackle(
|
||||
active_lure_id, false
|
||||
))
|
||||
):
|
||||
return {}
|
||||
|
||||
return {
|
||||
"save_version": SAVE_VERSION,
|
||||
|
|
@ -592,6 +734,10 @@ func _build_save_dictionary() -> Dictionary:
|
|||
"items": serialized_items,
|
||||
"unlocked_bait_ids": serialized_unlocked_baits,
|
||||
},
|
||||
"tackle": {
|
||||
"active_bait_id": String(active_bait_id),
|
||||
"active_lure_id": String(active_lure_id),
|
||||
},
|
||||
"hotbar": {
|
||||
"selected_slot": _hotbar.get_selected_slot(),
|
||||
"slots": serialized_slots,
|
||||
|
|
@ -603,6 +749,7 @@ func _build_save_dictionary() -> Dictionary:
|
|||
"art": _art_unlocks.to_save_data(),
|
||||
"experience": _experience.to_save_data(),
|
||||
"world": {
|
||||
"layout": String(_world_layout),
|
||||
"seed": _world_seed,
|
||||
"time_hours": _world_time.get_persistent_time_hours(),
|
||||
"weather": int(_world_weather.get_persistent_weather()),
|
||||
|
|
@ -620,6 +767,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
or typeof(save_data.get("collection")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("inventory")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("bag")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("tackle")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("hotbar")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("inventory_layout")) != TYPE_DICTIONARY
|
||||
or typeof(save_data.get("experience")) != TYPE_DICTIONARY
|
||||
|
|
@ -630,6 +778,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
var collection_data: Dictionary = save_data["collection"]
|
||||
var inventory_data: Dictionary = save_data["inventory"]
|
||||
var bag_data: Dictionary = save_data["bag"]
|
||||
var tackle_data: Dictionary = save_data["tackle"]
|
||||
var hotbar_data: Dictionary = save_data["hotbar"]
|
||||
var inventory_layout_data: Dictionary = save_data["inventory_layout"]
|
||||
var experience_data: Dictionary = save_data["experience"]
|
||||
|
|
@ -665,6 +814,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
and typeof(bag_data.get("unlocked_bait_ids")) != TYPE_ARRAY
|
||||
)
|
||||
or typeof(hotbar_data.get("slots")) != TYPE_ARRAY
|
||||
or typeof(tackle_data.get("active_bait_id")) not in [
|
||||
TYPE_STRING, TYPE_STRING_NAME,
|
||||
]
|
||||
or typeof(tackle_data.get("active_lure_id")) not in [
|
||||
TYPE_STRING, TYPE_STRING_NAME,
|
||||
]
|
||||
or not hotbar_data.has("selected_slot")
|
||||
or not experience_data.has("total_experience")
|
||||
):
|
||||
|
|
@ -684,6 +839,12 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
return null
|
||||
|
||||
var snapshot := LoadSnapshot.new()
|
||||
if world_data.has("layout"):
|
||||
if not WorldLayoutType.is_valid(world_data["layout"]):
|
||||
return null
|
||||
snapshot.world_layout = WorldLayoutType.normalized(
|
||||
world_data["layout"]
|
||||
)
|
||||
if world_data.has("seed"):
|
||||
snapshot.world_seed = _read_integer(
|
||||
world_data["seed"],
|
||||
|
|
@ -897,6 +1058,16 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
|
|||
):
|
||||
seen_unlocked_baits[owned.item_id] = true
|
||||
snapshot.unlocked_bait_ids.append(owned.item_id)
|
||||
snapshot.active_bait_id = _validated_tackle_id(
|
||||
StringName(str(tackle_data.get("active_bait_id", ""))),
|
||||
true,
|
||||
seen_items,
|
||||
)
|
||||
snapshot.active_lure_id = _validated_tackle_id(
|
||||
StringName(str(tackle_data.get("active_lure_id", ""))),
|
||||
false,
|
||||
seen_items,
|
||||
)
|
||||
|
||||
var slot_values: Array = hotbar_data["slots"]
|
||||
snapshot.hotbar_slots.resize(PlayerHotbarType.SLOT_COUNT)
|
||||
|
|
@ -995,6 +1166,8 @@ func _migrate_save(
|
|||
migrated = _migrate_version_7_to_8(migrated)
|
||||
8:
|
||||
migrated = _migrate_version_8_to_9(migrated)
|
||||
9:
|
||||
migrated = _migrate_version_9_to_10(migrated)
|
||||
_:
|
||||
return {}
|
||||
if migrated.is_empty():
|
||||
|
|
@ -1242,6 +1415,16 @@ func _migrate_version_8_to_9(data: Dictionary) -> Dictionary:
|
|||
return migrated
|
||||
|
||||
|
||||
func _migrate_version_9_to_10(data: Dictionary) -> Dictionary:
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
migrated["tackle"] = {
|
||||
"active_bait_id": "",
|
||||
"active_lure_id": "",
|
||||
}
|
||||
migrated["save_version"] = 10
|
||||
return migrated
|
||||
|
||||
|
||||
func _mark_dirty() -> void:
|
||||
if (
|
||||
_is_restoring
|
||||
|
|
@ -1291,33 +1474,169 @@ func _on_experience_changed(_total_experience: int, _level: int) -> void:
|
|||
_mark_dirty()
|
||||
|
||||
|
||||
func _on_active_tackle_changed(_item_id: StringName) -> void:
|
||||
_mark_dirty()
|
||||
|
||||
|
||||
func _on_autosave_timeout() -> void:
|
||||
if _is_dirty:
|
||||
save_now()
|
||||
|
||||
|
||||
func _recover_interrupted_write() -> void:
|
||||
if FileAccess.file_exists(_save_path):
|
||||
_remove_if_present(_temp_path())
|
||||
_remove_if_present(_backup_path())
|
||||
_remove_if_present(_codec_scratch_path())
|
||||
_recover_path_write(_save_path)
|
||||
_recover_path_write(_legacy_save_path())
|
||||
|
||||
|
||||
func _recover_path_write(path: String) -> void:
|
||||
var temporary: String = path + ".tmp"
|
||||
var backup: String = path + ".backup"
|
||||
if FileAccess.file_exists(path):
|
||||
_remove_if_present(temporary)
|
||||
_remove_if_present(backup)
|
||||
return
|
||||
if FileAccess.file_exists(_backup_path()):
|
||||
_rename_file(_backup_path(), _save_path)
|
||||
_remove_if_present(_temp_path())
|
||||
if FileAccess.file_exists(backup):
|
||||
_rename_file(backup, path)
|
||||
_remove_if_present(temporary)
|
||||
|
||||
|
||||
func _handle_corrupt_save(message: String) -> void:
|
||||
func _write_current_save_data(
|
||||
save_data: Dictionary,
|
||||
expected_hash: String,
|
||||
) -> Dictionary:
|
||||
var bytes: PackedByteArray = ProgressionSaveCodec.encode_local_save(
|
||||
save_data,
|
||||
_codec_scratch_path(),
|
||||
)
|
||||
if bytes.is_empty():
|
||||
return {"ok": false}
|
||||
return PortableFileGuard.write_guarded(
|
||||
_save_path,
|
||||
bytes,
|
||||
expected_hash,
|
||||
_data_root.conflict_directory(),
|
||||
_data_root.device_id,
|
||||
)
|
||||
|
||||
|
||||
func _prepare_external_save_data(value: Variant) -> Dictionary:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return {"ok": false, "message": "progression data is malformed."}
|
||||
var save_data: Dictionary = (value as Dictionary).duplicate(true)
|
||||
var version: int = _read_integer(save_data.get("save_version"), -1)
|
||||
if version > SAVE_VERSION:
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "this progression belongs to a newer game version.",
|
||||
}
|
||||
if version < 1:
|
||||
return {"ok": false, "message": "the progression version is unsupported."}
|
||||
if version != SAVE_VERSION:
|
||||
save_data = _migrate_save(save_data, version)
|
||||
if save_data.is_empty():
|
||||
return {
|
||||
"ok": false,
|
||||
"message": "the progression version is unsupported.",
|
||||
}
|
||||
var snapshot: LoadSnapshot = _build_load_snapshot(save_data)
|
||||
if snapshot == null:
|
||||
return {"ok": false, "message": "the progression is structurally invalid."}
|
||||
return {
|
||||
"ok": true,
|
||||
"data": save_data,
|
||||
"catch_count": snapshot.catches.size(),
|
||||
"wallet_balance": snapshot.wallet_balance,
|
||||
"discovered_species_count": snapshot.discovered_ids.size(),
|
||||
"world_layout": String(snapshot.world_layout),
|
||||
"world_seed": snapshot.world_seed,
|
||||
}
|
||||
|
||||
|
||||
func _migrate_legacy_plaintext_save(
|
||||
save_data: Dictionary,
|
||||
legacy_path: String,
|
||||
) -> void:
|
||||
var result: Dictionary = _write_current_save_data(save_data, "")
|
||||
if not bool(result.get("ok", false)):
|
||||
push_warning("Legacy progression loaded but could not be encrypted yet.")
|
||||
return
|
||||
_expected_hash = str(result.get("hash", ""))
|
||||
if not _archive_replaced_save(legacy_path, "plaintext-migration"):
|
||||
push_warning(
|
||||
"Legacy progression was encrypted, but its migration backup failed."
|
||||
)
|
||||
return
|
||||
_remove_legacy_save_files()
|
||||
print("Upgraded legacy plaintext progression to the opaque save format.")
|
||||
|
||||
|
||||
func _archive_replaced_save(
|
||||
source_path: String,
|
||||
reason: String = "before-import",
|
||||
) -> bool:
|
||||
if source_path.is_empty() or not FileAccess.file_exists(source_path):
|
||||
return true
|
||||
var timestamp: String = Time.get_datetime_string_from_system().replace(
|
||||
":", "-"
|
||||
)
|
||||
var destination: String = _data_root.root_path.path_join(
|
||||
"backups/saves/player-save-%s-%s%s"
|
||||
% [reason, timestamp, ARCHIVE_EXTENSION]
|
||||
)
|
||||
if FileAccess.file_exists(destination):
|
||||
destination = destination.trim_suffix(ARCHIVE_EXTENSION) + (
|
||||
"-%s%s" % [Time.get_ticks_usec(), ARCHIVE_EXTENSION]
|
||||
)
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
source_path,
|
||||
source_path == _legacy_save_path(),
|
||||
)
|
||||
var bytes: PackedByteArray
|
||||
if bool(decoded.get("ok", false)):
|
||||
var prepared: Dictionary = _prepare_external_save_data(decoded["data"])
|
||||
if bool(prepared.get("ok", false)):
|
||||
bytes = ProgressionSaveCodec.encode_archive(
|
||||
prepared["data"], destination + ".codec.tmp"
|
||||
)
|
||||
if bytes.is_empty():
|
||||
bytes = PortableFileGuard.read_bytes(source_path)
|
||||
destination = (
|
||||
destination.trim_suffix(ARCHIVE_EXTENSION) + ".preserved"
|
||||
)
|
||||
if bytes.is_empty():
|
||||
return false
|
||||
var result: Dictionary = PortableFileGuard.write_guarded(
|
||||
destination,
|
||||
bytes,
|
||||
"",
|
||||
_data_root.conflict_directory(),
|
||||
_data_root.device_id,
|
||||
)
|
||||
return bool(result.get("ok", false))
|
||||
|
||||
|
||||
func _remove_legacy_save_files() -> void:
|
||||
for path: String in [
|
||||
_legacy_save_path(),
|
||||
_legacy_save_path() + ".tmp",
|
||||
_legacy_save_path() + ".backup",
|
||||
]:
|
||||
_remove_if_present(path)
|
||||
|
||||
|
||||
func _handle_corrupt_save(message: String, source_path: String) -> void:
|
||||
push_warning(message)
|
||||
_restore_defaults()
|
||||
if not FileAccess.file_exists(_save_path):
|
||||
if not FileAccess.file_exists(source_path):
|
||||
return
|
||||
var timestamp: int = int(Time.get_unix_time_from_system())
|
||||
var corrupt_path: String = (
|
||||
"%s.corrupt-%d" % [_save_path, timestamp]
|
||||
"%s.corrupt-%d" % [source_path, timestamp]
|
||||
)
|
||||
if FileAccess.file_exists(corrupt_path):
|
||||
corrupt_path += "-%d" % Time.get_ticks_usec()
|
||||
if not _rename_file(_save_path, corrupt_path):
|
||||
if not _rename_file(source_path, corrupt_path):
|
||||
_automatic_saving_blocked = true
|
||||
push_warning(
|
||||
"Corrupt player save was left in place; automatic saving is "
|
||||
|
|
@ -1350,6 +1669,8 @@ func _restore_defaults() -> void:
|
|||
_bag.replace_all_items(default_items)
|
||||
var default_unlocked_baits: Array[StringName] = []
|
||||
_bag.replace_unlocked_bait_ids(default_unlocked_baits)
|
||||
_player.unequip_bait()
|
||||
_player.unequip_lure()
|
||||
_fishing_upgrades.reset_to_defaults()
|
||||
_cooler_capacity.reset_to_defaults()
|
||||
_inventory_layout.reset_to_defaults()
|
||||
|
|
@ -1360,12 +1681,60 @@ func _restore_defaults() -> void:
|
|||
WorldTimeServiceType.DEFAULT_START_HOUR
|
||||
)
|
||||
_world_weather.reset_persistent_state()
|
||||
_world_layout = WorldLayoutType.GENERATED
|
||||
_world_seed = DEFAULT_WORLD_SEED
|
||||
_jobs.reset_to_defaults()
|
||||
_is_restoring = false
|
||||
_is_dirty = false
|
||||
|
||||
|
||||
func _valid_active_tackle(item_id: StringName, bait: bool) -> bool:
|
||||
var item = _item_catalog.get_item_by_id(item_id)
|
||||
return (
|
||||
item != null
|
||||
and item.is_available()
|
||||
and (item.is_bait() if bait else item.is_lure())
|
||||
and _bag.owns_item(item_id)
|
||||
)
|
||||
|
||||
|
||||
func _validated_tackle_id(
|
||||
item_id: StringName,
|
||||
bait: bool,
|
||||
seen_items: Dictionary[StringName, bool],
|
||||
) -> StringName:
|
||||
if item_id.is_empty():
|
||||
return StringName()
|
||||
var item = _item_catalog.get_item_by_id(item_id)
|
||||
if (
|
||||
item == null
|
||||
or not item.is_available()
|
||||
or not seen_items.has(item_id)
|
||||
or not (item.is_bait() if bait else item.is_lure())
|
||||
):
|
||||
push_warning("Skipped invalid saved active tackle '%s'." % String(item_id))
|
||||
return StringName()
|
||||
return item_id
|
||||
|
||||
|
||||
func _restore_tackle_selection(snapshot: LoadSnapshot) -> bool:
|
||||
_player.unequip_bait()
|
||||
_player.unequip_lure()
|
||||
var bait_restored: bool = (
|
||||
snapshot.active_bait_id.is_empty()
|
||||
or _player.equip_bait(
|
||||
_item_catalog.get_item_by_id(snapshot.active_bait_id)
|
||||
)
|
||||
)
|
||||
var lure_restored: bool = (
|
||||
snapshot.active_lure_id.is_empty()
|
||||
or _player.equip_lure(
|
||||
_item_catalog.get_item_by_id(snapshot.active_lure_id)
|
||||
)
|
||||
)
|
||||
return bait_restored and lure_restored
|
||||
|
||||
|
||||
func _read_integer(
|
||||
value: Variant,
|
||||
invalid_value: int,
|
||||
|
|
|
|||
179
save/progression_save_codec.gd
Normal file
179
save/progression_save_codec.gd
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
class_name ProgressionSaveCodec
|
||||
extends RefCounted
|
||||
|
||||
const MAGIC := "NETFISHING_PROGRESSION_CONTAINER"
|
||||
const FORMAT_VERSION := 1
|
||||
const LOCAL_KIND := "local_save"
|
||||
const ARCHIVE_KIND := "portable_archive"
|
||||
const MAX_CONTAINER_BYTES := 16 * 1024 * 1024
|
||||
|
||||
# This key keeps routine progression files opaque to casual editing. Since the
|
||||
# client is open source, it is intentionally not treated as a security secret.
|
||||
const CONTAINER_PASSPHRASE := (
|
||||
"NETfishing progression container v1 / not an identity credential"
|
||||
)
|
||||
|
||||
|
||||
static func encode_local_save(
|
||||
save_data: Dictionary,
|
||||
scratch_path: String,
|
||||
) -> PackedByteArray:
|
||||
return _encode_container(LOCAL_KIND, save_data, scratch_path)
|
||||
|
||||
|
||||
static func encode_archive(
|
||||
save_data: Dictionary,
|
||||
scratch_path: String,
|
||||
) -> PackedByteArray:
|
||||
return _encode_container(ARCHIVE_KIND, save_data, scratch_path)
|
||||
|
||||
|
||||
static func read_local_save(
|
||||
path: String,
|
||||
allow_legacy_plaintext: bool = false,
|
||||
) -> Dictionary:
|
||||
if allow_legacy_plaintext and not _looks_encrypted(path):
|
||||
var legacy: Dictionary = _read_plaintext_dictionary(path)
|
||||
if bool(legacy.get("ok", false)):
|
||||
legacy["legacy_plaintext"] = true
|
||||
return legacy
|
||||
return _read_container(path, LOCAL_KIND)
|
||||
|
||||
|
||||
static func read_archive(path: String) -> Dictionary:
|
||||
if not _looks_encrypted(path):
|
||||
return {"ok": false, "message": "progression archive is not recognized."}
|
||||
return _read_container(path, ARCHIVE_KIND)
|
||||
|
||||
|
||||
static func _encode_container(
|
||||
kind: String,
|
||||
save_data: Dictionary,
|
||||
scratch_path: String,
|
||||
) -> PackedByteArray:
|
||||
if save_data.is_empty() or scratch_path.is_empty():
|
||||
return PackedByteArray()
|
||||
var payload_json: String = JSON.stringify(save_data)
|
||||
if payload_json.is_empty():
|
||||
return PackedByteArray()
|
||||
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
|
||||
var envelope: Dictionary = {
|
||||
"magic": MAGIC,
|
||||
"format_version": FORMAT_VERSION,
|
||||
"kind": kind,
|
||||
"game_version": str(
|
||||
ProjectSettings.get_setting("application/config/version", "")
|
||||
),
|
||||
"created_at_unix": int(Time.get_unix_time_from_system()),
|
||||
"payload_sha256": PortableFileGuard.hash_bytes(payload_bytes),
|
||||
"payload_json": payload_json,
|
||||
}
|
||||
if DirAccess.make_dir_recursive_absolute(
|
||||
scratch_path.get_base_dir()
|
||||
) != OK:
|
||||
return PackedByteArray()
|
||||
_remove_if_present(scratch_path)
|
||||
var file: FileAccess = FileAccess.open_encrypted_with_pass(
|
||||
scratch_path,
|
||||
FileAccess.WRITE,
|
||||
CONTAINER_PASSPHRASE,
|
||||
)
|
||||
if file == null:
|
||||
return PackedByteArray()
|
||||
file.store_string(JSON.stringify(envelope))
|
||||
file.flush()
|
||||
var write_ok: bool = file.get_error() == OK
|
||||
file.close()
|
||||
if not write_ok:
|
||||
_remove_if_present(scratch_path)
|
||||
return PackedByteArray()
|
||||
var encoded: PackedByteArray = PortableFileGuard.read_bytes(
|
||||
scratch_path,
|
||||
MAX_CONTAINER_BYTES,
|
||||
)
|
||||
_remove_if_present(scratch_path)
|
||||
return encoded
|
||||
|
||||
|
||||
static func _read_container(path: String, expected_kind: String) -> Dictionary:
|
||||
if (
|
||||
path.is_empty()
|
||||
or not FileAccess.file_exists(path)
|
||||
or FileAccess.get_size(path) > MAX_CONTAINER_BYTES
|
||||
):
|
||||
return {"ok": false, "message": "progression file is unavailable."}
|
||||
var file: FileAccess = FileAccess.open_encrypted_with_pass(
|
||||
path,
|
||||
FileAccess.READ,
|
||||
CONTAINER_PASSPHRASE,
|
||||
)
|
||||
if file == null:
|
||||
return {"ok": false, "message": "progression file could not be opened."}
|
||||
var envelope_text: String = file.get_as_text()
|
||||
file.close()
|
||||
var envelope_json := JSON.new()
|
||||
if (
|
||||
envelope_json.parse(envelope_text) != OK
|
||||
or typeof(envelope_json.data) != TYPE_DICTIONARY
|
||||
):
|
||||
return {"ok": false, "message": "progression container is malformed."}
|
||||
var envelope: Dictionary = envelope_json.data
|
||||
if (
|
||||
envelope.get("magic") != MAGIC
|
||||
or int(envelope.get("format_version", -1)) != FORMAT_VERSION
|
||||
or str(envelope.get("kind", "")) != expected_kind
|
||||
):
|
||||
return {"ok": false, "message": "progression container is unsupported."}
|
||||
var payload_json: String = str(envelope.get("payload_json", ""))
|
||||
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
|
||||
if (
|
||||
payload_json.is_empty()
|
||||
or str(envelope.get("payload_sha256", ""))
|
||||
!= PortableFileGuard.hash_bytes(payload_bytes)
|
||||
):
|
||||
return {"ok": false, "message": "progression container failed validation."}
|
||||
var payload_parser := JSON.new()
|
||||
if (
|
||||
payload_parser.parse(payload_json) != OK
|
||||
or typeof(payload_parser.data) != TYPE_DICTIONARY
|
||||
):
|
||||
return {"ok": false, "message": "progression payload is malformed."}
|
||||
return {
|
||||
"ok": true,
|
||||
"data": payload_parser.data,
|
||||
"game_version": str(envelope.get("game_version", "")),
|
||||
"created_at_unix": int(envelope.get("created_at_unix", 0)),
|
||||
"legacy_plaintext": false,
|
||||
}
|
||||
|
||||
|
||||
static func _read_plaintext_dictionary(path: String) -> Dictionary:
|
||||
var bytes: PackedByteArray = PortableFileGuard.read_bytes(
|
||||
path,
|
||||
MAX_CONTAINER_BYTES,
|
||||
)
|
||||
if bytes.is_empty():
|
||||
return {"ok": false}
|
||||
var text: String = bytes.get_string_from_utf8()
|
||||
if not text.strip_edges().begins_with("{"):
|
||||
return {"ok": false}
|
||||
var parser := JSON.new()
|
||||
if parser.parse(text) != OK or typeof(parser.data) != TYPE_DICTIONARY:
|
||||
return {"ok": false}
|
||||
return {"ok": true, "data": parser.data}
|
||||
|
||||
|
||||
static func _looks_encrypted(path: String) -> bool:
|
||||
if not FileAccess.file_exists(path) or FileAccess.get_size(path) < 4:
|
||||
return false
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return false
|
||||
var header: PackedByteArray = file.get_buffer(4)
|
||||
file.close()
|
||||
return header == PackedByteArray([0x47, 0x44, 0x45, 0x43])
|
||||
|
||||
|
||||
static func _remove_if_present(path: String) -> void:
|
||||
if FileAccess.file_exists(path):
|
||||
DirAccess.remove_absolute(path)
|
||||
1
save/progression_save_codec.gd.uid
Normal file
1
save/progression_save_codec.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://lad8mvedktww
|
||||
|
|
@ -4,14 +4,14 @@ set -euo pipefail
|
|||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.13.1-alpha"
|
||||
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.14.0-alpha"
|
||||
readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
|
||||
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
|
||||
readonly README_SOURCE="${PROJECT_ROOT}/docs/README-PLAYTEST.txt"
|
||||
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
|
||||
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
|
||||
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-windows-x86_64.zip"
|
||||
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.13.1-alpha-linux-x86_64.zip"
|
||||
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-windows-x86_64.zip"
|
||||
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.14.0-alpha-linux-x86_64.zip"
|
||||
readonly GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
|
||||
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ music while omitting rain and shoreline ambience in both profiles. See
|
|||
|
||||
## Controls
|
||||
|
||||
NETfishing reads the handheld controller directly. PortMaster's system
|
||||
NETfishing reads the handheld controller directly. GPTOKEYB's device-specific
|
||||
force-quit chord remains available; this is Start+Select on most devices.
|
||||
EOF
|
||||
|
||||
|
|
@ -249,8 +249,11 @@ if unzip -Z1 "${ARCHIVE}" | grep -qx 'port.json'; then
|
|||
echo "port.json must be inside netfishing/, not at the archive root." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q '^# PORTMASTER: netfishing.zip, NETfishing.sh$' \
|
||||
"${STAGE_ROOT}/NETfishing.sh"
|
||||
if grep -Eq '^[[:space:]]*#[[:space:]]*PORTMASTER:' \
|
||||
"${STAGE_ROOT}/NETfishing.sh"; then
|
||||
echo "The distributable launcher contains an installed PortMaster signature." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'PROFILE_PATH="$CONFDIR/performance_profile"' \
|
||||
"${STAGE_ROOT}/NETfishing.sh"
|
||||
grep -Fq 'NETFISHING_PERFORMANCE_PROFILE=light' \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/bin/bash
|
||||
# PORTMASTER: netfishing.zip, NETfishing.sh
|
||||
|
||||
XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
|
||||
|
||||
|
|
@ -13,19 +12,11 @@ else
|
|||
controlfolder="/roms/ports/PortMaster"
|
||||
fi
|
||||
|
||||
if [ ! -f "$controlfolder/control.txt" ]; then
|
||||
echo "PortMaster control.txt was not found at $controlfolder" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$controlfolder/control.txt"
|
||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
get_controls
|
||||
|
||||
GAMEDIR="/${directory}/ports/netfishing"
|
||||
if [ ! -d "$GAMEDIR" ] && [ -d "/mnt/mmc/ports/netfishing" ]; then
|
||||
GAMEDIR="/mnt/mmc/ports/netfishing"
|
||||
fi
|
||||
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
PORTS_ROOT="${GAMEDIR%/netfishing}"
|
||||
|
|
@ -38,16 +29,13 @@ CONTROLLER_MAPPING_FILE="$CONFDIR/cache/controller_mapping.txt"
|
|||
WESTON_DIR="/tmp/netfishing-weston"
|
||||
WESTON_RUNTIME="weston_pkg_0.2"
|
||||
HARBOURMASTER="$controlfolder/harbourmaster"
|
||||
if [ ! -x "$HARBOURMASTER" ] && [ -x "/mnt/mmc/MUOS/PortMaster/harbourmaster" ]; then
|
||||
HARBOURMASTER="/mnt/mmc/MUOS/PortMaster/harbourmaster"
|
||||
fi
|
||||
|
||||
mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR"
|
||||
chmod +x "$GAME_EXECUTABLE"
|
||||
|
||||
# New PortMaster installs use a predictable save directory beside the port
|
||||
# instead of presenting a folder picker on a small screen. Preserve an
|
||||
# established data-root choice when upgrading an existing installation.
|
||||
# NETfishing uses a predictable save directory beside the port instead of
|
||||
# presenting a folder picker on a small screen. Preserve an established
|
||||
# data-root choice when upgrading an existing installation.
|
||||
NETFISHING_DATA_ENVIRONMENT=()
|
||||
if [ -f "$SAVEDIR/netfishing_data.json" ] || {
|
||||
[ ! -f "$DATA_BOOTSTRAP_DIR/data_root_bootstrap.json" ] &&
|
||||
|
|
@ -139,10 +127,9 @@ case "$PERFORMANCE_PROFILE" in
|
|||
;;
|
||||
esac
|
||||
|
||||
# Keep NETfishing's native controller input separate from PortMaster's exit
|
||||
# handling. Legacy GPTOKEYB builds can load their default keyboard map when no
|
||||
# config is supplied, so the explicit no-op map preserves only the
|
||||
# device-specific force-quit chord.
|
||||
# NETfishing reads gameplay input natively. The explicit no-op map prevents
|
||||
# GPTOKEYB from injecting duplicate keyboard or mouse input while retaining
|
||||
# its device-specific force-quit chord, including on ROCKNIX.
|
||||
$GPTOKEYB "NETfishing.aarch64" -c "$GPTOKEYB_CONFIG" &
|
||||
pm_platform_helper "$GAME_EXECUTABLE"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# NETfishing reads the controller directly. GPTOKEYB is retained only for the
|
||||
# PortMaster force-quit chord, so every keyboard and mouse binding is disabled.
|
||||
# NETfishing reads the controller directly. GPTOKEYB is retained only for its
|
||||
# device-specific force-quit chord, so every keyboard and mouse binding is
|
||||
# disabled to prevent duplicate input.
|
||||
|
||||
back = \"
|
||||
guide = \"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ readonly -a QUICK_TESTS=(
|
|||
"tests/android_readiness_validation.gd"
|
||||
"tests/camera_drag_validation.gd"
|
||||
"tests/character_rig_validation.gd"
|
||||
"tests/chat_validation.gd"
|
||||
"tests/controller_focus_presentation_validation.gd"
|
||||
"tests/controller_menu_accessibility_validation.gd"
|
||||
"tests/controller_mapping_validation.gd"
|
||||
|
|
@ -22,6 +23,7 @@ readonly -a QUICK_TESTS=(
|
|||
"tests/controller_ui_navigation_validation.gd"
|
||||
"tests/dedicated_server_config_validation.gd"
|
||||
"tests/digging_prototype_validation.gd"
|
||||
"tests/equipment_catalog_validation.gd"
|
||||
"tests/exported_decal_hotfix_validation.gd"
|
||||
"tests/file_dialog_controller_navigation_validation.gd"
|
||||
"tests/fish_catalog_content_validation.gd"
|
||||
|
|
@ -50,6 +52,7 @@ readonly -a QUICK_TESTS=(
|
|||
"tests/tree_gathering_prototype_validation.gd"
|
||||
"tests/unified_inventory_validation.gd"
|
||||
"tests/world_time_validation.gd"
|
||||
"tests/world_layout_validation.gd"
|
||||
"tests/world_spawn_protocol_validation.gd"
|
||||
"tests/world_weather_validation.gd"
|
||||
)
|
||||
|
|
@ -72,10 +75,12 @@ readonly -a HOST_TESTS=(
|
|||
"tests/gathering_showcase_validation.gd"
|
||||
"tests/inventory_storage_persistence_validation.gd"
|
||||
"tests/job_system_validation.gd"
|
||||
"tests/progression_archive_validation.gd"
|
||||
"tests/surface_drawing_runtime_validation.gd"
|
||||
)
|
||||
|
||||
readonly -a NETWORK_TESTS=(
|
||||
"tests/chat_privacy_multiplayer_validation.gd"
|
||||
"tests/economy_regression_validation.gd"
|
||||
"tests/fish_showcase_multiplayer_validation.gd"
|
||||
"tests/fishing_multiplayer_validation.gd"
|
||||
|
|
@ -85,6 +90,7 @@ readonly -a NETWORK_TESTS=(
|
|||
"tests/profile_multiplayer_validation.gd"
|
||||
"tests/surface_drawing_multiplayer_validation.gd"
|
||||
"tests/world_time_multiplayer_validation.gd"
|
||||
"tests/world_layout_multiplayer_validation.gd"
|
||||
"tests/world_spawn_multiplayer_validation.gd"
|
||||
)
|
||||
|
||||
|
|
@ -140,7 +146,10 @@ wait_for_network_host() {
|
|||
sleep 2
|
||||
return 0
|
||||
fi
|
||||
for _attempt in {1..100}; do
|
||||
# A cold generated-world startup can take longer than ten seconds before
|
||||
# NetworkSession is ready to bind. Keep the client launch tied to the actual
|
||||
# listener instead of treating that normal startup work as a host failure.
|
||||
for _attempt in {1..600}; do
|
||||
if ! kill -0 "${host_pid}" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -226,7 +235,7 @@ run_session_switch_network_test() {
|
|||
--headless --path "${PROJECT_ROOT}" --script "${script}" -- second_host \
|
||||
>"${second_root}/output.log" 2>&1 &
|
||||
second_pid=$!
|
||||
for _attempt in {1..100}; do
|
||||
for _attempt in {1..600}; do
|
||||
if ! kill -0 "${first_pid}" 2>/dev/null \
|
||||
|| ! kill -0 "${second_pid}" 2>/dev/null; then
|
||||
break
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ var world_seed: int = DEFAULT_WORLD_SEED
|
|||
var public_listing: bool = false
|
||||
var discovery_url: String = ""
|
||||
var data_directory: String = ""
|
||||
var chat_logging: bool = false
|
||||
var chat_log_path: String = ""
|
||||
var operator_fingerprints: PackedStringArray = PackedStringArray()
|
||||
var error_message: String = ""
|
||||
|
||||
|
|
@ -71,6 +73,12 @@ func _load_file(path: String) -> bool:
|
|||
discovery_url = str(file.get_value(
|
||||
"discovery", "url", discovery_url
|
||||
))
|
||||
chat_logging = bool(file.get_value(
|
||||
"privacy", "chat_logging", chat_logging
|
||||
))
|
||||
chat_log_path = str(file.get_value(
|
||||
"privacy", "chat_log_path", chat_log_path
|
||||
))
|
||||
operator_fingerprints = _parse_fingerprint_list(file.get_value(
|
||||
"moderation", "operators", operator_fingerprints
|
||||
))
|
||||
|
|
@ -100,6 +108,12 @@ func _apply_environment() -> void:
|
|||
data_directory = _environment_string(
|
||||
"NETFISHING_DATA_DIR", data_directory
|
||||
)
|
||||
chat_logging = _environment_bool(
|
||||
"NETFISHING_CHAT_LOGGING", chat_logging
|
||||
)
|
||||
chat_log_path = _environment_string(
|
||||
"NETFISHING_CHAT_LOG_PATH", chat_log_path
|
||||
)
|
||||
if OS.has_environment("NETFISHING_SERVER_OPERATORS"):
|
||||
operator_fingerprints = _parse_fingerprint_list(
|
||||
OS.get_environment("NETFISHING_SERVER_OPERATORS")
|
||||
|
|
@ -130,10 +144,16 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
|
|||
operator_fingerprints = _parse_fingerprint_list(
|
||||
argument.trim_prefix("--operators=")
|
||||
)
|
||||
elif argument.begins_with("--chat-log-path="):
|
||||
chat_log_path = argument.trim_prefix("--chat-log-path=")
|
||||
elif argument == "--public":
|
||||
public_listing = true
|
||||
elif argument == "--private":
|
||||
public_listing = false
|
||||
elif argument == "--chat-logging":
|
||||
chat_logging = true
|
||||
elif argument == "--no-chat-logging":
|
||||
chat_logging = false
|
||||
|
||||
|
||||
func _validate() -> void:
|
||||
|
|
@ -141,6 +161,9 @@ func _validate() -> void:
|
|||
bind_address = bind_address.strip_edges()
|
||||
discovery_url = discovery_url.strip_edges().trim_suffix("/")
|
||||
data_directory = data_directory.strip_edges()
|
||||
chat_log_path = chat_log_path.strip_edges()
|
||||
if chat_logging and chat_log_path.is_empty() and not data_directory.is_empty():
|
||||
chat_log_path = data_directory.path_join("logs/chat.jsonl")
|
||||
operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
|
||||
if server_name.is_empty():
|
||||
error_message = "Server name cannot be empty."
|
||||
|
|
@ -156,6 +179,12 @@ func _validate() -> void:
|
|||
error_message = "World seed must be between 1 and %d." % MAX_WORLD_SEED
|
||||
elif not data_directory.is_empty() and not data_directory.is_absolute_path():
|
||||
error_message = "Server data directory must be an absolute path."
|
||||
elif chat_logging and (
|
||||
chat_log_path.is_empty() or not chat_log_path.is_absolute_path()
|
||||
):
|
||||
error_message = (
|
||||
"Chat logging requires an absolute chat log path."
|
||||
)
|
||||
elif public_listing and not (
|
||||
discovery_url.begins_with("https://")
|
||||
or discovery_url.begins_with("http://")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ extends Node
|
|||
|
||||
signal mapping_changed
|
||||
|
||||
const FORMAT_VERSION: int = 1
|
||||
const FORMAT_VERSION: int = 2
|
||||
const LEGACY_FORMAT_VERSION: int = 1
|
||||
const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json"
|
||||
const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp"
|
||||
const MAPPING_BACKUP_PATH: String = (
|
||||
|
|
@ -21,6 +22,7 @@ const ROLE_SNEAK: StringName = &"sneak"
|
|||
const ROLE_SLOW_WALK: StringName = &"slow_walk"
|
||||
const ROLE_INTERACT: StringName = &"interact"
|
||||
const ROLE_PRIMARY_ACTION: StringName = &"fish_primary"
|
||||
const ROLE_ALTERNATE_REEL: StringName = &"reel_alternate"
|
||||
const ROLE_CAMERA_DRAG: StringName = &"camera_drag"
|
||||
const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in"
|
||||
const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out"
|
||||
|
|
@ -46,6 +48,7 @@ const ROLE_ORDER: Array[StringName] = [
|
|||
ROLE_SLOW_WALK,
|
||||
ROLE_INTERACT,
|
||||
ROLE_PRIMARY_ACTION,
|
||||
ROLE_ALTERNATE_REEL,
|
||||
ROLE_CAMERA_DRAG,
|
||||
ROLE_CAMERA_ZOOM_IN,
|
||||
ROLE_CAMERA_ZOOM_OUT,
|
||||
|
|
@ -81,6 +84,7 @@ const ROLE_LABELS: Dictionary = {
|
|||
ROLE_SLOW_WALK: "slow walk",
|
||||
ROLE_INTERACT: "interact",
|
||||
ROLE_PRIMARY_ACTION: "primary action",
|
||||
ROLE_ALTERNATE_REEL: "alternate reel",
|
||||
ROLE_CAMERA_DRAG: "rotate camera",
|
||||
ROLE_CAMERA_ZOOM_IN: "camera zoom in",
|
||||
ROLE_CAMERA_ZOOM_OUT: "camera zoom out",
|
||||
|
|
@ -138,19 +142,28 @@ func load_mapping() -> bool:
|
|||
_bindings = {}
|
||||
return false
|
||||
var data := json.data as Dictionary
|
||||
if int(data.get("format_version", -1)) != FORMAT_VERSION:
|
||||
var format_version: int = int(data.get("format_version", -1))
|
||||
if format_version not in [LEGACY_FORMAT_VERSION, FORMAT_VERSION]:
|
||||
push_warning("Keyboard binding version is unsupported; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
var raw_bindings: Variant = data.get("bindings", {})
|
||||
if (
|
||||
typeof(raw_bindings) != TYPE_DICTIONARY
|
||||
or not _validate_complete_bindings(raw_bindings as Dictionary)
|
||||
):
|
||||
push_warning("Keyboard bindings are incomplete; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
_bindings = (raw_bindings as Dictionary).duplicate(true)
|
||||
var loaded_bindings := (raw_bindings as Dictionary).duplicate(true)
|
||||
if format_version == LEGACY_FORMAT_VERSION:
|
||||
loaded_bindings[str(ROLE_ALTERNATE_REEL)] = (
|
||||
_default_bindings[str(ROLE_ALTERNATE_REEL)].duplicate(true)
|
||||
)
|
||||
if not _validate_complete_bindings(loaded_bindings):
|
||||
push_warning("Keyboard bindings are incomplete; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
_bindings = loaded_bindings
|
||||
return true
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,11 @@ func _run() -> void:
|
|||
var chat_ui := game_ui.get_node("%ChatUI") as ChatUI
|
||||
assert(player != null and service != null and toolbar != null)
|
||||
assert(chat_ui != null)
|
||||
assert(toolbar.get_parent() == chat_ui.get_parent())
|
||||
assert(
|
||||
toolbar.get_index() > chat_ui.get_index(),
|
||||
"The art-kit toolbar must receive overlapping pointer input before Chat.",
|
||||
)
|
||||
assert(bool(game_ui.call("_can_start_virtual_mouse")))
|
||||
var virtual_pointer_states: Array[bool] = []
|
||||
game_ui.virtual_pointer_mode_changed.connect(
|
||||
|
|
@ -317,6 +322,22 @@ func _run() -> void:
|
|||
assert(not (
|
||||
game_ui.get_node("%ExperiencePresentation") as Control
|
||||
).visible)
|
||||
var hud_fishing_spot := main.get_node("%FishingSpot") as FishingSpot
|
||||
assert(hud_fishing_spot != null)
|
||||
hud_fishing_spot.state = FishingSpot.FishingState.SHOWING_CATCH
|
||||
game_ui.call("_refresh_gameplay_hud_visibility")
|
||||
assert((
|
||||
game_ui.get_node("%GameplayTransientHUD") as Control
|
||||
).visible)
|
||||
assert(not (
|
||||
game_ui.get_node("%ExperiencePresentation") as Control
|
||||
).visible)
|
||||
assert(chat_ui.is_hud_hidden())
|
||||
hud_fishing_spot.state = FishingSpot.FishingState.READY
|
||||
game_ui.call("_refresh_gameplay_hud_visibility")
|
||||
assert(not (
|
||||
game_ui.get_node("%GameplayTransientHUD") as Control
|
||||
).visible)
|
||||
quick_menu.open_menu(true)
|
||||
assert(quick_menu.visible and quick_menu.is_open())
|
||||
quick_menu.close_menu()
|
||||
|
|
@ -365,6 +386,12 @@ func _run() -> void:
|
|||
await process_frame
|
||||
assert(service.is_active() and not service.is_placement_mode())
|
||||
assert(toolbar.visible)
|
||||
chat_ui.open_chat()
|
||||
assert(chat_ui.is_open())
|
||||
assert(not service.is_active() and not toolbar.visible)
|
||||
chat_ui.close_chat()
|
||||
assert(not chat_ui.is_open())
|
||||
assert(service.is_active() and toolbar.visible)
|
||||
var ui_root := game_ui.get_node("%UIRoot") as Control
|
||||
assert(toolbar.get_parent() == ui_root)
|
||||
assert(is_zero_approx(toolbar.position.y))
|
||||
|
|
|
|||
120
tests/chat_privacy_multiplayer_validation.gd
Normal file
120
tests/chat_privacy_multiplayer_validation.gd
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene: PackedScene = preload("res://main/main.tscn")
|
||||
const TEST_PORT: int = 18152
|
||||
const PREJOIN_MESSAGE: String = "private prejoin message"
|
||||
const LIVE_MESSAGE: String = "live message after join"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var arguments: PackedStringArray = OS.get_cmdline_user_args()
|
||||
if arguments.has("host"):
|
||||
await _run_host()
|
||||
return
|
||||
if arguments.has("client"):
|
||||
await _run_client()
|
||||
return
|
||||
push_error("Chat privacy validation needs host or client mode.")
|
||||
quit(1)
|
||||
|
||||
|
||||
func _run_host() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
assert(session.start_private_host(TEST_PORT))
|
||||
assert(save_manager.initialize_new_game())
|
||||
main.call("_enter_gameplay")
|
||||
for _frame: int in 4:
|
||||
await physics_frame
|
||||
assert(session.set_host_open(true))
|
||||
var chat_service := main.get_node(
|
||||
"%NetworkChatService"
|
||||
) as NetworkChatService
|
||||
assert(chat_service.send_local_message(PREJOIN_MESSAGE))
|
||||
var remote_peer_id: int = await _wait_for_remote_peer(session)
|
||||
assert(remote_peer_id > 1)
|
||||
await create_timer(1.0).timeout
|
||||
assert(chat_service.send_local_message(LIVE_MESSAGE))
|
||||
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < disconnect_deadline
|
||||
and session.is_authenticated_peer(remote_peer_id)
|
||||
):
|
||||
await process_frame
|
||||
assert(not session.is_authenticated_peer(remote_peer_id))
|
||||
print("Chat privacy multiplayer host validation: PASS")
|
||||
await _session_cleanup(main, session)
|
||||
|
||||
|
||||
func _run_client() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
main.call("_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT)
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var join_deadline: int = Time.get_ticks_msec() + 20000
|
||||
while Time.get_ticks_msec() < join_deadline:
|
||||
await process_frame
|
||||
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
|
||||
main.call("_confirm_server_trust")
|
||||
if session.is_joined_client() and bool(main.get("_gameplay_started")):
|
||||
break
|
||||
assert(session.is_joined_client())
|
||||
var chat_service := main.get_node(
|
||||
"%NetworkChatService"
|
||||
) as NetworkChatService
|
||||
await create_timer(0.5).timeout
|
||||
assert(not _history_contains(chat_service, PREJOIN_MESSAGE))
|
||||
var live_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < live_deadline
|
||||
and not _history_contains(chat_service, LIVE_MESSAGE)
|
||||
):
|
||||
await process_frame
|
||||
assert(_history_contains(chat_service, LIVE_MESSAGE))
|
||||
assert(not _history_contains(chat_service, PREJOIN_MESSAGE))
|
||||
print("Chat privacy multiplayer client validation: PASS")
|
||||
await _session_cleanup(main, session)
|
||||
|
||||
|
||||
func _history_contains(service: NetworkChatService, body: String) -> bool:
|
||||
return service.get_history().any(
|
||||
func(message: Dictionary) -> bool:
|
||||
return str(message.get("body", "")) == body
|
||||
)
|
||||
|
||||
|
||||
func _create_initialized_main() -> Node:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
root.add_child(main)
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
if not bool(main.get("_application_initialized")):
|
||||
main.call("_activate_selected_data_path", "", true)
|
||||
for _frame: int in 8:
|
||||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
return main
|
||||
|
||||
|
||||
func _wait_for_remote_peer(session: NetworkSession) -> int:
|
||||
var deadline: int = Time.get_ticks_msec() + 20000
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
await process_frame
|
||||
for peer_id: int in session.get_authenticated_peer_ids():
|
||||
if peer_id != session.get_local_peer_id():
|
||||
return peer_id
|
||||
return 0
|
||||
|
||||
|
||||
func _session_cleanup(main: Node, session: NetworkSession) -> void:
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
1
tests/chat_privacy_multiplayer_validation.gd.uid
Normal file
1
tests/chat_privacy_multiplayer_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ivtqrnppnmli
|
||||
38
tests/chat_validation.gd
Normal file
38
tests/chat_validation.gd
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
extends SceneTree
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
assert(NetworkChatProtocol.sanitize_body(42).is_empty())
|
||||
assert(NetworkChatProtocol.sanitize_body(" a ") == "a")
|
||||
assert(NetworkChatProtocol.sanitize_body("a b") == "a b")
|
||||
assert(NetworkChatProtocol.sanitize_body("a\tb\nc") == "a b c")
|
||||
assert(NetworkChatProtocol.sanitize_body(" !hello!? ") == "!hello!?")
|
||||
assert(NetworkChatProtocol.sanitize_body("!!!") == "!!!")
|
||||
|
||||
const em_space := " " # U+2003 EM SPACE
|
||||
const zero_width_space := "" # U+200B ZERO WIDTH SPACE
|
||||
const braille_blank := "⠀" # U+2800 BRAILLE PATTERN BLANK
|
||||
const invisible_padding := em_space + zero_width_space + braille_blank
|
||||
assert(
|
||||
NetworkChatProtocol.sanitize_body(invisible_padding + "a" + invisible_padding)
|
||||
== "a"
|
||||
)
|
||||
assert(NetworkChatProtocol.sanitize_body(invisible_padding).is_empty())
|
||||
assert(NetworkChatProtocol.sanitize_body(" \t\n" + invisible_padding).is_empty())
|
||||
|
||||
assert(
|
||||
NetworkChatProtocol.sanitize_body("a".repeat(
|
||||
NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
|
||||
)).length() == NetworkChatProtocol.MAX_VISIBLE_CHARACTERS
|
||||
)
|
||||
assert(
|
||||
NetworkChatProtocol.sanitize_body("a".repeat(
|
||||
NetworkChatProtocol.MAX_VISIBLE_CHARACTERS + 1
|
||||
)).is_empty()
|
||||
)
|
||||
|
||||
print("Chat validation: PASS")
|
||||
quit(0)
|
||||
1
tests/chat_validation.gd.uid
Normal file
1
tests/chat_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ohemhv2sf5g4
|
||||
|
|
@ -353,6 +353,8 @@ func _validate_data_settings_navigation() -> void:
|
|||
"Open Data Folder left a controller-activatable dialog behind.",
|
||||
)
|
||||
var change_data_folder := panel.get_node("%ChangeDataFolder") as Button
|
||||
var export_progression := panel.get_node("%ExportProgression") as Button
|
||||
var import_progression := panel.get_node("%ImportProgression") as Button
|
||||
var copy_fingerprint := panel.get_node("%CopyPlayerFingerprint") as Button
|
||||
var export_player := panel.get_node("%ExportPlayerIdentity") as Button
|
||||
var import_player := panel.get_node("%ImportPlayerIdentity") as Button
|
||||
|
|
@ -362,6 +364,8 @@ func _validate_data_settings_navigation() -> void:
|
|||
data_tab,
|
||||
open_data_folder,
|
||||
change_data_folder,
|
||||
export_progression,
|
||||
import_progression,
|
||||
copy_fingerprint,
|
||||
export_player,
|
||||
import_player,
|
||||
|
|
@ -385,7 +389,7 @@ func _validate_data_settings_navigation() -> void:
|
|||
_assert_neighbor(
|
||||
open_data_folder,
|
||||
&"focus_neighbor_bottom",
|
||||
copy_fingerprint,
|
||||
export_progression,
|
||||
)
|
||||
_assert_neighbor(
|
||||
change_data_folder,
|
||||
|
|
@ -395,12 +399,42 @@ func _validate_data_settings_navigation() -> void:
|
|||
_assert_neighbor(
|
||||
change_data_folder,
|
||||
&"focus_neighbor_bottom",
|
||||
import_progression,
|
||||
)
|
||||
_assert_neighbor(
|
||||
export_progression,
|
||||
&"focus_neighbor_top",
|
||||
open_data_folder,
|
||||
)
|
||||
_assert_neighbor(
|
||||
export_progression,
|
||||
&"focus_neighbor_right",
|
||||
import_progression,
|
||||
)
|
||||
_assert_neighbor(
|
||||
export_progression,
|
||||
&"focus_neighbor_bottom",
|
||||
copy_fingerprint,
|
||||
)
|
||||
_assert_neighbor(
|
||||
import_progression,
|
||||
&"focus_neighbor_top",
|
||||
change_data_folder,
|
||||
)
|
||||
_assert_neighbor(
|
||||
import_progression,
|
||||
&"focus_neighbor_left",
|
||||
export_progression,
|
||||
)
|
||||
_assert_neighbor(
|
||||
import_progression,
|
||||
&"focus_neighbor_bottom",
|
||||
copy_fingerprint,
|
||||
)
|
||||
_assert_neighbor(
|
||||
copy_fingerprint,
|
||||
&"focus_neighbor_top",
|
||||
open_data_folder,
|
||||
export_progression,
|
||||
)
|
||||
_assert_neighbor(
|
||||
copy_fingerprint,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ func _run() -> void:
|
|||
defaults.call("_validate")
|
||||
assert(defaults.is_valid())
|
||||
assert(not bool(defaults.get("public_listing")))
|
||||
assert(not bool(defaults.get("chat_logging")))
|
||||
assert(str(defaults.get("chat_log_path")).is_empty())
|
||||
assert(NetworkChatService.BURST_COUNT == 3)
|
||||
assert(NetworkChatService.WINDOW_COUNT == 5)
|
||||
assert(is_equal_approx(NetworkChatService.WINDOW_SECONDS, 10.0))
|
||||
assert(NetworkChatService.CALL_COOLDOWN_MILLISECONDS == 90)
|
||||
|
||||
var path: String = ProjectSettings.globalize_path(
|
||||
"user://dedicated-server-validation.cfg"
|
||||
|
|
@ -32,6 +38,12 @@ func _run() -> void:
|
|||
file.set_value("server", "public", true)
|
||||
file.set_value("server", "data_directory", "/tmp/configured-server")
|
||||
file.set_value("discovery", "url", "https://discovery.netfishing.org/")
|
||||
file.set_value("privacy", "chat_logging", true)
|
||||
file.set_value(
|
||||
"privacy",
|
||||
"chat_log_path",
|
||||
"/tmp/configured-server/logs/chat.jsonl",
|
||||
)
|
||||
file.set_value(
|
||||
"moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
|
||||
)
|
||||
|
|
@ -48,6 +60,11 @@ func _run() -> void:
|
|||
assert(int(configured.get("world_seed")) == 928374)
|
||||
assert(bool(configured.get("public_listing")))
|
||||
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org")
|
||||
assert(bool(configured.get("chat_logging")))
|
||||
assert(
|
||||
str(configured.get("chat_log_path"))
|
||||
== "/tmp/configured-server/logs/chat.jsonl"
|
||||
)
|
||||
assert(
|
||||
configured.get("operator_fingerprints")
|
||||
== PackedStringArray([OPERATOR_A, OPERATOR_B])
|
||||
|
|
@ -89,6 +106,51 @@ func _run() -> void:
|
|||
invalid_seed.call("_validate")
|
||||
assert(not invalid_seed.is_valid())
|
||||
|
||||
var default_chat_path := ConfigType.new()
|
||||
default_chat_path.set("data_directory", "/tmp/default-chat-path-server")
|
||||
default_chat_path.set("chat_logging", true)
|
||||
default_chat_path.call("_validate")
|
||||
assert(default_chat_path.is_valid())
|
||||
assert(
|
||||
str(default_chat_path.get("chat_log_path"))
|
||||
== "/tmp/default-chat-path-server/logs/chat.jsonl"
|
||||
)
|
||||
|
||||
var invalid_chat_path := ConfigType.new()
|
||||
invalid_chat_path.set("data_directory", "/tmp/invalid-chat-path-server")
|
||||
invalid_chat_path.set("chat_logging", true)
|
||||
invalid_chat_path.set("chat_log_path", "relative/chat.jsonl")
|
||||
invalid_chat_path.call("_validate")
|
||||
assert(not invalid_chat_path.is_valid())
|
||||
|
||||
var chat_log_validation_path: String = ProjectSettings.globalize_path(
|
||||
"user://dedicated-chat-log-validation.jsonl"
|
||||
)
|
||||
var chat_service := NetworkChatService.new()
|
||||
assert(not chat_service.configure_dedicated_history(
|
||||
true,
|
||||
"relative/chat.jsonl",
|
||||
))
|
||||
assert(chat_service.configure_dedicated_history(
|
||||
true,
|
||||
chat_log_validation_path,
|
||||
))
|
||||
assert(FileAccess.file_exists(chat_log_validation_path))
|
||||
chat_service.call("_append_chat_log", {
|
||||
"sender_display_name": "Privacy Tester",
|
||||
"body": "explicitly retained message",
|
||||
})
|
||||
var logged_message: Variant = JSON.parse_string(
|
||||
FileAccess.get_file_as_string(chat_log_validation_path).strip_edges()
|
||||
)
|
||||
assert(typeof(logged_message) == TYPE_DICTIONARY)
|
||||
assert(str(logged_message.get("recorded_at_utc", "")).ends_with("Z"))
|
||||
assert(logged_message.get("sender_display_name") == "Privacy Tester")
|
||||
assert(logged_message.get("body") == "explicitly retained message")
|
||||
assert(chat_service.configure_dedicated_history(false, ""))
|
||||
assert(DirAccess.remove_absolute(chat_log_validation_path) == OK)
|
||||
chat_service.free()
|
||||
|
||||
var discovery := DiscoveryClient.new()
|
||||
assert(
|
||||
str(discovery.call("_default_room_name", "River"))
|
||||
|
|
|
|||
|
|
@ -54,6 +54,21 @@ func _initialize() -> void:
|
|||
assert(FishingShopStockType.get_price(&"magnet") == 250)
|
||||
assert(FishingShopStockType.get_stock_item_ids().has(&"magnet"))
|
||||
assert(FishingShopStockType.is_permanent_unlock(&"magnet", magnet))
|
||||
var fishing_net: ItemDataType = (
|
||||
ItemCatalogResource.get_available_item_by_id(&"fishing_net")
|
||||
)
|
||||
assert(fishing_net != null)
|
||||
assert(fishing_net.category == ItemDataType.Category.TOOL)
|
||||
assert(fishing_net.icon == null)
|
||||
assert(not fishing_net.usable)
|
||||
assert(not fishing_net.equippable)
|
||||
assert(not fishing_net.hotbar_allowed)
|
||||
assert(FishingShopStockType.get_price(&"fishing_net") == 250)
|
||||
assert(FishingShopStockType.get_stock_item_ids().has(&"fishing_net"))
|
||||
assert(FishingShopStockType.is_permanent_unlock(
|
||||
&"fishing_net", fishing_net
|
||||
))
|
||||
assert(not FishingShopStockType.is_gathering_tool(&"fishing_net"))
|
||||
var crab_net: ItemDataType = ItemCatalogResource.get_available_item_by_id(
|
||||
&"crab_net"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -98,29 +98,27 @@ func _run() -> void:
|
|||
|
||||
assert(save_manager.save_now())
|
||||
var save_path: String = str(save_manager.get("_save_path"))
|
||||
var save_file := FileAccess.open(save_path, FileAccess.READ)
|
||||
assert(save_file != null)
|
||||
var parsed: Variant = JSON.parse_string(save_file.get_as_text())
|
||||
save_file.close()
|
||||
assert(typeof(parsed) == TYPE_DICTIONARY)
|
||||
var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"]
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
var parsed: Dictionary = decoded["data"]
|
||||
var hotbar_data: Dictionary = parsed["hotbar"]
|
||||
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
|
||||
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
|
||||
assert(int((parsed as Dictionary)["save_version"]) == 9)
|
||||
assert(int(parsed["save_version"]) == 10)
|
||||
assert(
|
||||
int((parsed as Dictionary)["experience"]["total_experience"])
|
||||
int(parsed["experience"]["total_experience"])
|
||||
== 125
|
||||
)
|
||||
assert(absf(
|
||||
float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours
|
||||
float(parsed["world"]["time_hours"]) - saved_time_hours
|
||||
) < 0.01)
|
||||
assert(
|
||||
int((parsed as Dictionary)["world"]["weather"])
|
||||
int(parsed["world"]["weather"])
|
||||
== int(saved_weather)
|
||||
)
|
||||
assert(absf(
|
||||
float(
|
||||
(parsed as Dictionary)["world"][
|
||||
parsed["world"][
|
||||
"weather_seconds_remaining"
|
||||
]
|
||||
) - saved_weather_seconds
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void:
|
|||
version_four,
|
||||
4,
|
||||
)
|
||||
assert(int(migrated.get("save_version", -1)) == 9)
|
||||
assert(int(migrated.get("save_version", -1)) == 10)
|
||||
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
|
||||
assert(
|
||||
is_equal_approx(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ extends SceneTree
|
|||
|
||||
const MainScene = preload("res://main/main.tscn")
|
||||
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
|
||||
const TEST_SEED: int = 864209
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -22,9 +23,23 @@ func _run() -> void:
|
|||
|
||||
func _run_host() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
assert(bool(main.call(
|
||||
"_apply_world",
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
TEST_SEED,
|
||||
true,
|
||||
)))
|
||||
assert(bool(main.call(
|
||||
"_prepare_host_world",
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
TEST_SEED,
|
||||
)))
|
||||
assert(bool(main.call("_prepare_private_host")))
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
assert(save_manager.initialize_new_game())
|
||||
assert(save_manager.initialize_new_game(
|
||||
TEST_SEED,
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
))
|
||||
main.call("_enter_gameplay")
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
assert(session.set_host_open(true))
|
||||
|
|
@ -217,7 +232,9 @@ func _run_client() -> void:
|
|||
int(player.get("_fishing_visual_phase"))
|
||||
== Player.FishingVisualPhase.CASTING
|
||||
)
|
||||
for _frame: int in 30:
|
||||
# Keep the transient pull-back pose visible long enough for the separate host
|
||||
# process to observe it even when a cold validation run is CPU-bound.
|
||||
for _frame: int in 120:
|
||||
await physics_frame
|
||||
player.set_fishing_visual(false)
|
||||
player.call("_set_sitting", true, true)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ func _run() -> void:
|
|||
var fishing_spot := main.get_node("%FishingSpot") as FishingSpot
|
||||
var catalog := main.get("fish_catalog") as FishPool
|
||||
assert(player != null and fishing_spot != null and catalog != null)
|
||||
fishing_spot.minimum_showcase_duration = 0.15
|
||||
assert(player.bag.add_item(&"crab_net"))
|
||||
assert(player.hotbar.assign_item(0, &"crab_net"))
|
||||
assert(player.hotbar.select_slot(0))
|
||||
|
|
@ -60,13 +61,20 @@ func _run() -> void:
|
|||
assert(not player.is_movement_enabled())
|
||||
Input.action_release("fish_primary")
|
||||
await process_frame
|
||||
assert(bool(fishing_spot.get("_put_away_press_armed")))
|
||||
assert(not bool(fishing_spot.get("_put_away_press_armed")))
|
||||
|
||||
var pocket_event := InputEventAction.new()
|
||||
pocket_event.action = &"fish_primary"
|
||||
pocket_event.pressed = true
|
||||
Input.parse_input_event(pocket_event)
|
||||
await process_frame
|
||||
assert(fishing_spot.get("_pending_catch") == crab_catch)
|
||||
Input.action_release("fish_primary")
|
||||
await create_timer(0.2).timeout
|
||||
await process_frame
|
||||
assert(bool(fishing_spot.get("_put_away_press_armed")))
|
||||
Input.parse_input_event(pocket_event)
|
||||
await process_frame
|
||||
assert(fishing_spot.get("_pending_catch") == null)
|
||||
|
||||
var pocket_deadline: int = Time.get_ticks_msec() + 6000
|
||||
|
|
|
|||
|
|
@ -82,18 +82,42 @@ func _validate_generated_region(
|
|||
generator: TerrainChunkGenerator,
|
||||
) -> void:
|
||||
var placements := generator.placement_keys()
|
||||
assert(placements.size() == 49)
|
||||
assert(placements[24].begins_with("chunk_spawn@"))
|
||||
var expected_chunk_count := generator.grid_size.x * generator.grid_size.y
|
||||
var center := Vector2i(
|
||||
generator.grid_size.x / 2,
|
||||
generator.grid_size.y / 2,
|
||||
)
|
||||
var center_index := center.y * generator.grid_size.x + center.x
|
||||
assert(placements.size() == expected_chunk_count)
|
||||
assert(placements[center_index].begins_with("chunk_spawn@"))
|
||||
assert(_placement_count(placements, "chunk_spawn") == 1)
|
||||
assert(_placement_count(placements, "chunk_0001") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0002") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0003") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0016") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0004") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0005") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0006") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0007") >= 1)
|
||||
assert(generator.get_generated_chunks_root().get_child_count() == 49)
|
||||
assert(generator.get_primary_terrain_meshes().size() == 49)
|
||||
assert(_placement_count(placements, "chunk_0008") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0013") >= 1)
|
||||
assert(_placement_count(placements, "chunk_0009") == 1)
|
||||
assert(_placement_count(placements, "chunk_0010") == 4)
|
||||
assert(
|
||||
_placement_count(placements, "chunk_0011")
|
||||
+ _placement_count(placements, "chunk_0012")
|
||||
== 4
|
||||
)
|
||||
assert(_placement_count(placements, "chunk_0012") <= 1)
|
||||
assert(
|
||||
generator.get_generated_chunks_root().get_child_count()
|
||||
== expected_chunk_count
|
||||
)
|
||||
assert(
|
||||
generator.get_primary_terrain_meshes().size()
|
||||
== expected_chunk_count
|
||||
)
|
||||
_validate_elevated_cliff_feature(generator)
|
||||
|
||||
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
|
||||
var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D
|
||||
|
|
@ -111,6 +135,7 @@ func _validate_generated_region(
|
|||
var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D
|
||||
assert(ocean != null and fresh_root != null)
|
||||
assert(ocean.water_type == WaterType.Type.SALT_WATER)
|
||||
assert(is_equal_approx(ocean.position.y, GeneratedWorldRegion.WATER_HEIGHT))
|
||||
var fresh_placement_count := 0
|
||||
for record: Dictionary in generator.placement_records():
|
||||
var tags: PackedStringArray = record.get("tags", PackedStringArray())
|
||||
|
|
@ -123,6 +148,7 @@ func _validate_generated_region(
|
|||
var fresh := child as WaterBodyAuthoring
|
||||
assert(fresh != null)
|
||||
assert(fresh.water_type == WaterType.Type.FRESH_WATER)
|
||||
assert(is_equal_approx(fresh.position.y, GeneratedWorldRegion.WATER_HEIGHT))
|
||||
assert(fresh.visible)
|
||||
assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0)
|
||||
assert(not fresh.visual_surface_enabled)
|
||||
|
|
@ -155,13 +181,13 @@ func _validate_generated_region(
|
|||
&"terrain_chunk_coordinate",
|
||||
Vector2i.ZERO,
|
||||
)
|
||||
var center := Vector2i(
|
||||
var spawn_coordinate := Vector2i(
|
||||
generator.grid_size.x / 2,
|
||||
generator.grid_size.y / 2,
|
||||
)
|
||||
var spawn_distance := (
|
||||
absi(coordinate.x - center.x)
|
||||
+ absi(coordinate.y - center.y)
|
||||
absi(coordinate.x - spawn_coordinate.x)
|
||||
+ absi(coordinate.y - spawn_coordinate.y)
|
||||
)
|
||||
assert(spawn_distance >= definition.minimum_spawn_chunk_distance)
|
||||
_validate_decoration_transform(
|
||||
|
|
@ -169,6 +195,13 @@ func _validate_generated_region(
|
|||
child as Node3D,
|
||||
definition,
|
||||
)
|
||||
if prop_id in [&"prop_tree_1", &"prop_tree_2", &"prop_tree_3"]:
|
||||
assert(
|
||||
_has_material_variant_override(
|
||||
child.get_node_or_null("Visual"),
|
||||
definition.material_variants,
|
||||
)
|
||||
)
|
||||
assert(_decoration_group_count(region, &"grass_tree") > 0)
|
||||
assert(_decoration_group_count(region, &"sand_tree") > 0)
|
||||
assert(
|
||||
|
|
@ -187,6 +220,69 @@ func _validate_generated_region(
|
|||
)
|
||||
|
||||
|
||||
func _validate_elevated_cliff_feature(
|
||||
generator: TerrainChunkGenerator,
|
||||
) -> void:
|
||||
var elevated_coordinates: Dictionary[Vector2i, StringName] = {}
|
||||
var top_coordinate := Vector2i(-1, -1)
|
||||
for record: Dictionary in generator.placement_records():
|
||||
var stable_id: StringName = record.get("stable_id", &"")
|
||||
if stable_id not in [
|
||||
&"chunk_0009",
|
||||
&"chunk_0010",
|
||||
&"chunk_0011",
|
||||
&"chunk_0012",
|
||||
]:
|
||||
continue
|
||||
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
|
||||
assert(coordinate.x > 0 and coordinate.x < generator.grid_size.x - 1)
|
||||
assert(coordinate.y > 0 and coordinate.y < generator.grid_size.y - 1)
|
||||
elevated_coordinates[coordinate] = stable_id
|
||||
if stable_id == &"chunk_0009":
|
||||
top_coordinate = coordinate
|
||||
assert(elevated_coordinates.size() == 9)
|
||||
assert(top_coordinate != Vector2i(-1, -1))
|
||||
for row_offset: int in range(-1, 2):
|
||||
for column_offset: int in range(-1, 2):
|
||||
assert(
|
||||
elevated_coordinates.has(
|
||||
top_coordinate + Vector2i(column_offset, row_offset)
|
||||
)
|
||||
)
|
||||
|
||||
var generated := generator.get_generated_chunks_root()
|
||||
assert(generated != null)
|
||||
var layered_count := 0
|
||||
for chunk_root: Node in generated.get_children():
|
||||
var stable_id := StringName(
|
||||
chunk_root.get_meta(&"terrain_chunk_id", &"")
|
||||
)
|
||||
if stable_id not in [
|
||||
&"chunk_0009",
|
||||
&"chunk_0010",
|
||||
&"chunk_0011",
|
||||
&"chunk_0012",
|
||||
]:
|
||||
continue
|
||||
layered_count += 1
|
||||
var base_layer := chunk_root.get_node_or_null("TerrainBaseLayer")
|
||||
var overlay := chunk_root.get_node_or_null("TerrainOverlay")
|
||||
assert(base_layer != null and overlay != null)
|
||||
var base_mesh := TerrainChunkAnalyzer.find_primary_mesh(
|
||||
base_layer,
|
||||
&"chunk_0000",
|
||||
)
|
||||
var overlay_mesh := TerrainChunkAnalyzer.find_primary_mesh(
|
||||
overlay,
|
||||
stable_id,
|
||||
)
|
||||
assert(base_mesh != null and base_mesh.mesh != null)
|
||||
assert(overlay_mesh != null and overlay_mesh.mesh != null)
|
||||
assert(base_mesh.has_node("TerrainBaseLayerCollision"))
|
||||
assert(overlay_mesh.has_node("TerrainCollision"))
|
||||
assert(layered_count == 9)
|
||||
|
||||
|
||||
func _validate_ocean_facing_record(
|
||||
record: Dictionary,
|
||||
grid_size: Vector2i,
|
||||
|
|
@ -224,6 +320,23 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void:
|
|||
assert(instance.position.is_zero_approx())
|
||||
assert(_find_mesh_instance(instance) != null)
|
||||
instance.free()
|
||||
var mushroom := catalog.definition_for_id(&"prop_mushroom")
|
||||
assert(mushroom != null and mushroom.has_collision())
|
||||
assert(mushroom.visual_offset.y < 0.0)
|
||||
for tree_id: StringName in [
|
||||
&"prop_tree_1",
|
||||
&"prop_tree_2",
|
||||
&"prop_tree_3",
|
||||
]:
|
||||
var tree := catalog.definition_for_id(tree_id)
|
||||
assert(tree != null)
|
||||
assert(tree.minimum_visual_scale < tree.maximum_visual_scale)
|
||||
assert(tree.material_variants.size() >= 3)
|
||||
assert(not tree.variant_material_slot_names.is_empty())
|
||||
var pine := catalog.definition_for_id(&"prop_pine")
|
||||
assert(pine != null)
|
||||
assert(pine.minimum_visual_scale < pine.maximum_visual_scale)
|
||||
assert(pine.material_variants.is_empty())
|
||||
|
||||
|
||||
func _validate_biome_catalog(
|
||||
|
|
@ -239,7 +352,9 @@ func _validate_biome_catalog(
|
|||
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
|
||||
var biome_id := region.get_biome_at(coordinate)
|
||||
var tags: PackedStringArray = record.get("tags", PackedStringArray())
|
||||
if "grass" in tags or "sand" in tags:
|
||||
if "mixed_surface" in tags:
|
||||
assert(biome_id == &"")
|
||||
elif "grass" in tags or "sand" in tags:
|
||||
assert(biome_id != &"")
|
||||
var biome := catalog.definition_for_id(biome_id)
|
||||
assert(biome != null and biome.supports_chunk_tags(tags))
|
||||
|
|
@ -276,7 +391,16 @@ func _validate_decoration_transform(
|
|||
var collision := prop.get_node_or_null(
|
||||
"TrunkCollision/CollisionShape"
|
||||
) as CollisionShape3D
|
||||
assert(visual_root != null and visual_root.position.is_zero_approx())
|
||||
assert(visual_root != null)
|
||||
assert(visual_root.position.is_equal_approx(definition.visual_offset))
|
||||
var visual_scale := float(
|
||||
prop.get_meta(&"terrain_prop_visual_scale", 1.0)
|
||||
)
|
||||
assert(visual_scale >= definition.minimum_visual_scale - 0.001)
|
||||
assert(visual_scale <= definition.maximum_visual_scale + 0.001)
|
||||
assert(
|
||||
visual_root.scale.is_equal_approx(Vector3.ONE * visual_scale)
|
||||
)
|
||||
assert(visual != null and visual.mesh != null)
|
||||
if definition.has_collision():
|
||||
assert(collision != null)
|
||||
|
|
@ -319,16 +443,40 @@ func _find_mesh_instance(root_node: Node) -> MeshInstance3D:
|
|||
return null
|
||||
|
||||
|
||||
func _has_material_variant_override(
|
||||
root_node: Node,
|
||||
variants: Array[Material],
|
||||
) -> bool:
|
||||
if root_node == null:
|
||||
return false
|
||||
var mesh_instance := root_node as MeshInstance3D
|
||||
if mesh_instance != null and mesh_instance.mesh != null:
|
||||
for surface_index: int in mesh_instance.mesh.get_surface_count():
|
||||
var override := mesh_instance.get_surface_override_material(
|
||||
surface_index
|
||||
)
|
||||
if override != null and override in variants:
|
||||
return true
|
||||
for child: Node in root_node.get_children():
|
||||
if _has_material_variant_override(child, variants):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _validate_authored_chunk_surfaces(
|
||||
region: GeneratedWorldRegion,
|
||||
generator: TerrainChunkGenerator,
|
||||
) -> void:
|
||||
var generated := generator.get_generated_chunks_root()
|
||||
var found_beach := false
|
||||
var found_stream := false
|
||||
var found_alternate_stream := false
|
||||
var found_pond := false
|
||||
var found_grass_ocean_edge := false
|
||||
var found_grass_beach_transition := false
|
||||
var found_grass_ocean_corner := false
|
||||
var found_beach_ocean_corner := false
|
||||
var found_grass_sand_diagonal := false
|
||||
for chunk_root: Node in generated.get_children():
|
||||
if chunk_root.name.begins_with("chunk_0002r"):
|
||||
var beach := chunk_root.find_child(
|
||||
|
|
@ -338,6 +486,27 @@ func _validate_authored_chunk_surfaces(
|
|||
var material := beach.get_active_material(0)
|
||||
assert(material != null and material.resource_name == "sand")
|
||||
found_beach = true
|
||||
elif (
|
||||
chunk_root.name.begins_with("chunk_0003r")
|
||||
or chunk_root.name.begins_with("chunk_0016r")
|
||||
):
|
||||
var stream_id := (
|
||||
&"chunk_0016"
|
||||
if chunk_root.name.begins_with("chunk_0016r")
|
||||
else &"chunk_0003"
|
||||
)
|
||||
var stream := chunk_root.find_child(
|
||||
String(stream_id), true, false
|
||||
) as MeshInstance3D
|
||||
assert(stream != null and stream.mesh != null)
|
||||
var stream_materials := _material_names(stream)
|
||||
assert("dirt" in stream_materials)
|
||||
assert("grass_lite" in stream_materials)
|
||||
assert("dirt_wall" in stream_materials)
|
||||
if stream_id == &"chunk_0016":
|
||||
found_alternate_stream = true
|
||||
else:
|
||||
found_stream = true
|
||||
elif chunk_root.name.begins_with("chunk_0004r"):
|
||||
var pond := chunk_root.find_child(
|
||||
"chunk_0004", true, false
|
||||
|
|
@ -383,11 +552,32 @@ func _validate_authored_chunk_surfaces(
|
|||
assert("grass_lite" in corner_materials)
|
||||
assert("cliff_wall" in corner_materials)
|
||||
found_grass_ocean_corner = true
|
||||
elif chunk_root.name.begins_with("chunk_0008r"):
|
||||
var beach_ocean_corner := chunk_root.find_child(
|
||||
"chunk_0008", true, false
|
||||
) as MeshInstance3D
|
||||
assert(beach_ocean_corner != null and beach_ocean_corner.mesh != null)
|
||||
var beach_corner_materials := _material_names(beach_ocean_corner)
|
||||
assert("sand" in beach_corner_materials)
|
||||
found_beach_ocean_corner = true
|
||||
elif chunk_root.name.begins_with("chunk_0013r"):
|
||||
var grass_sand_diagonal := chunk_root.find_child(
|
||||
"chunk_0013", true, false
|
||||
) as MeshInstance3D
|
||||
assert(grass_sand_diagonal != null and grass_sand_diagonal.mesh != null)
|
||||
var diagonal_materials := _material_names(grass_sand_diagonal)
|
||||
assert("grass_lite" in diagonal_materials)
|
||||
assert("sand" in diagonal_materials)
|
||||
found_grass_sand_diagonal = true
|
||||
assert(found_beach)
|
||||
assert(found_stream)
|
||||
assert(found_alternate_stream)
|
||||
assert(found_pond)
|
||||
assert(found_grass_ocean_edge)
|
||||
assert(found_grass_beach_transition)
|
||||
assert(found_grass_ocean_corner)
|
||||
assert(found_beach_ocean_corner)
|
||||
assert(found_grass_sand_diagonal)
|
||||
|
||||
|
||||
func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene: PackedScene = preload("res://main/main.tscn")
|
||||
const ItemCatalogResource: ItemCatalog = preload(
|
||||
"res://items/catalog/item_catalog.tres"
|
||||
)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -23,33 +26,43 @@ func _run() -> void:
|
|||
var player := main.get("_player") as Player
|
||||
assert(save_manager != null and player != null)
|
||||
const TEST_WORLD_SEED := 918273
|
||||
assert(save_manager.initialize_new_game(TEST_WORLD_SEED))
|
||||
assert(save_manager.initialize_new_game(
|
||||
TEST_WORLD_SEED,
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
))
|
||||
save_manager.set_autosave_enabled(true)
|
||||
assert(player.bag.add_item(&"art_kit"))
|
||||
assert(player.bag.add_item(&"coffee"))
|
||||
assert(player.bag.add_item(&"worms"))
|
||||
assert(player.bag.add_item(&"the_standby"))
|
||||
assert(player.equip_bait(ItemCatalogResource.get_item_by_id(&"worms")))
|
||||
assert(player.equip_lure(ItemCatalogResource.get_item_by_id(&"the_standby")))
|
||||
assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 14))
|
||||
assert(player.bag.move_item_to_storage_slot(&"coffee", 12))
|
||||
assert(save_manager.save_now())
|
||||
|
||||
var save_file := FileAccess.open(
|
||||
str(save_manager.get("_save_path")),
|
||||
FileAccess.READ,
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
str(save_manager.get("_save_path"))
|
||||
)
|
||||
assert(save_file != null)
|
||||
var parsed: Variant = JSON.parse_string(save_file.get_as_text())
|
||||
save_file.close()
|
||||
assert(typeof(parsed) == TYPE_DICTIONARY)
|
||||
var records: Array = (parsed as Dictionary)["bag"]["items"]
|
||||
assert(int((parsed as Dictionary)["world"]["seed"]) == TEST_WORLD_SEED)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
var parsed: Dictionary = decoded["data"]
|
||||
var records: Array = parsed["bag"]["items"]
|
||||
assert(str(parsed["world"]["layout"]) == String(WorldLayout.STARTER_ISLAND))
|
||||
assert(int(parsed["world"]["seed"]) == TEST_WORLD_SEED)
|
||||
assert(_saved_slot(records, &"basic_fishing_rod") == 14)
|
||||
assert(_saved_slot(records, &"coffee") == 12)
|
||||
|
||||
assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0))
|
||||
assert(player.bag.move_item_to_storage_slot(&"coffee", 0))
|
||||
player.unequip_bait()
|
||||
player.unequip_lure()
|
||||
assert(save_manager.load_player_data())
|
||||
assert(save_manager.get_world_layout() == WorldLayout.STARTER_ISLAND)
|
||||
assert(save_manager.get_world_seed() == TEST_WORLD_SEED)
|
||||
assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14)
|
||||
assert(player.bag.get_storage_slot(&"coffee") == 12)
|
||||
assert(player.active_bait_id == &"worms")
|
||||
assert(player.active_lure_id == &"the_standby")
|
||||
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ func _run() -> void:
|
|||
_validate_weather_guarantees(board)
|
||||
_validate_late_board_weather_fallback()
|
||||
_validate_remote_tamper_rejection(jobs, board)
|
||||
_validate_creature_jobs(jobs)
|
||||
|
||||
var sell_job: Dictionary = _find_job(
|
||||
jobs.get_daily_jobs(), JobCatalog.Kind.SELL_TOTAL
|
||||
|
|
@ -195,15 +196,12 @@ func _run() -> void:
|
|||
assert(session.set_host_open(false))
|
||||
|
||||
assert(save_manager.save_now())
|
||||
var save_file := FileAccess.open(
|
||||
str(save_manager.get("_save_path")), FileAccess.READ
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(
|
||||
str(save_manager.get("_save_path"))
|
||||
)
|
||||
assert(save_file != null)
|
||||
var parsed: Variant = JSON.parse_string(save_file.get_as_text())
|
||||
save_file.close()
|
||||
assert(typeof(parsed) == TYPE_DICTIONARY)
|
||||
var save_data: Dictionary = parsed
|
||||
assert(int(save_data.get("save_version", -1)) == 9)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
var save_data: Dictionary = decoded["data"]
|
||||
assert(int(save_data.get("save_version", -1)) == 10)
|
||||
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
|
||||
|
||||
_validate_pause_session_switch(main, session)
|
||||
|
|
@ -263,6 +261,100 @@ func _validate_unavailable_fishnet_layout() -> void:
|
|||
await process_frame
|
||||
|
||||
|
||||
func _validate_creature_jobs(jobs: PlayerJobService) -> void:
|
||||
var fish_count: int = 0
|
||||
var insect_count: int = 0
|
||||
var shellfish_count: int = 0
|
||||
var candidates: Array[FishData] = []
|
||||
for fish: FishData in Catalog.candidates:
|
||||
if fish == null or not fish.is_selectable():
|
||||
continue
|
||||
candidates.append(fish)
|
||||
match fish.get_creature_group():
|
||||
FishData.CreatureGroup.FISH:
|
||||
fish_count += 1
|
||||
FishData.CreatureGroup.INSECT:
|
||||
insect_count += 1
|
||||
FishData.CreatureGroup.SHELLFISH:
|
||||
shellfish_count += 1
|
||||
assert(fish_count > 0 and insect_count > 0 and shellfish_count > 0)
|
||||
|
||||
var lifetime: Array[Dictionary] = jobs.get_lifetime_jobs()
|
||||
var fish_discovery: Dictionary = _find_job(
|
||||
lifetime, JobCatalog.Kind.DISCOVER_SPECIES
|
||||
)
|
||||
assert(int(fish_discovery.get("target", -1)) == fish_count)
|
||||
for creature_group: int in [
|
||||
FishData.CreatureGroup.INSECT,
|
||||
FishData.CreatureGroup.SHELLFISH,
|
||||
]:
|
||||
assert(not _find_creature_job(
|
||||
lifetime,
|
||||
JobCatalog.Kind.CATCH_CREATURE_GROUP,
|
||||
creature_group,
|
||||
).is_empty())
|
||||
assert(not _find_creature_job(
|
||||
lifetime,
|
||||
JobCatalog.Kind.DISCOVER_CREATURE_GROUP,
|
||||
creature_group,
|
||||
).is_empty())
|
||||
assert(not _find_creature_job(
|
||||
lifetime,
|
||||
JobCatalog.Kind.MASTER_CREATURE_GROUP,
|
||||
creature_group,
|
||||
).is_empty())
|
||||
|
||||
var daily_groups: Dictionary[int, bool] = {}
|
||||
for seed_index: int in 64:
|
||||
for job: Dictionary in JobCatalog.generate_daily_jobs(
|
||||
"creature-job-validation-%d" % seed_index,
|
||||
candidates,
|
||||
true,
|
||||
):
|
||||
if int(job.get("kind", -1)) == JobCatalog.Kind.CATCH_CREATURE_GROUP:
|
||||
daily_groups[int(job.get("creature_group", -1))] = true
|
||||
assert(daily_groups.has(FishData.CreatureGroup.INSECT))
|
||||
assert(daily_groups.has(FishData.CreatureGroup.SHELLFISH))
|
||||
|
||||
var beetle: FishData = Catalog.get_fish_by_id(&"beetle_stag_common")
|
||||
assert(beetle != null)
|
||||
var beetle_catch := _make_test_catch(beetle, &"job-beetle")
|
||||
jobs.call("_on_authoritative_catch", beetle_catch)
|
||||
var statistics: Dictionary = jobs.to_save_data().get("statistics", {})
|
||||
assert(int(statistics.get("fish_caught", -1)) == 0)
|
||||
assert(int(statistics.get("insects_caught", -1)) == 1)
|
||||
|
||||
|
||||
func _find_creature_job(
|
||||
jobs: Array[Dictionary],
|
||||
kind: JobCatalog.Kind,
|
||||
creature_group: int,
|
||||
) -> Dictionary:
|
||||
for job: Dictionary in jobs:
|
||||
if (
|
||||
int(job.get("kind", -1)) == kind
|
||||
and int(job.get("creature_group", -1)) == creature_group
|
||||
):
|
||||
return job
|
||||
return {}
|
||||
|
||||
|
||||
func _make_test_catch(fish: FishData, catch_id: StringName) -> FishCatch:
|
||||
var fish_catch := FishCatch.new()
|
||||
fish_catch.fish = fish
|
||||
fish_catch.fish_id = fish.id
|
||||
fish_catch.catch_id = catch_id
|
||||
fish_catch.catch_sequence = 1
|
||||
fish_catch.weight_lb = fish.get_minimum_weight()
|
||||
fish_catch.display_scale = fish.get_display_scale_for_weight(
|
||||
fish_catch.weight_lb
|
||||
)
|
||||
fish_catch.quality = FishQuality.Tier.BORING
|
||||
fish_catch.sale_value = fish.get_sale_value_for_weight(fish_catch.weight_lb)
|
||||
assert(fish_catch.is_valid())
|
||||
return fish_catch
|
||||
|
||||
|
||||
func _validate_weather_guarantees(board: Dictionary) -> void:
|
||||
var jobs: Array = board.get("jobs", [])
|
||||
var schedule: Array = board.get("weather_schedule", [])
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ const KeyboardMouseMappingPanelType = preload(
|
|||
"res://ui/keyboard_mouse_mapping_panel.gd"
|
||||
)
|
||||
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
|
||||
const FishingSpotScene = preload("res://fishing/fishing_spot.tscn")
|
||||
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
|
||||
const CatchDifficultyProfileType = preload(
|
||||
"res://fishing/catch_difficulty_profile.gd"
|
||||
)
|
||||
const PlayerType = preload("res://player/player.gd")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -27,6 +33,13 @@ func _run() -> void:
|
|||
str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION)
|
||||
].get("kind", "")) == "mouse_button"
|
||||
)
|
||||
assert(
|
||||
str(defaults[
|
||||
str(KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL)
|
||||
].get("kind", "")) == "key"
|
||||
)
|
||||
assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT))
|
||||
assert(_event_count(&"reel_alternate", true) == 0)
|
||||
assert(
|
||||
str(defaults[
|
||||
str(KeyboardMouseMappingManagerType.ROLE_CHAT)
|
||||
|
|
@ -60,6 +73,15 @@ func _run() -> void:
|
|||
))
|
||||
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1))
|
||||
assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
|
||||
var alternate_reel_key := InputEventKey.new()
|
||||
alternate_reel_key.physical_keycode = KEY_G
|
||||
alternate_reel_key.pressed = true
|
||||
assert(manager.set_binding(
|
||||
KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL,
|
||||
manager.binding_from_event(alternate_reel_key),
|
||||
))
|
||||
assert(_has_physical_key(&"reel_alternate", KEY_G))
|
||||
assert(_event_count(&"reel_alternate", true) == 0)
|
||||
|
||||
var joypad_event := InputEventJoypadButton.new()
|
||||
joypad_event.button_index = JOY_BUTTON_A
|
||||
|
|
@ -117,11 +139,64 @@ func _run() -> void:
|
|||
))
|
||||
assert(_has_physical_key(&"jump", KEY_SPACE))
|
||||
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
|
||||
assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT))
|
||||
assert(_event_count(&"reel_alternate", true) == 0)
|
||||
assert(_has_physical_key(&"open_chat", KEY_T))
|
||||
assert(_event_count(&"jump", true) == joypad_events_before)
|
||||
|
||||
var legacy_bindings: Dictionary = manager.get_active_bindings()
|
||||
legacy_bindings.erase(str(
|
||||
KeyboardMouseMappingManagerType.ROLE_ALTERNATE_REEL
|
||||
))
|
||||
legacy_bindings[str(KeyboardMouseMappingManagerType.ROLE_JUMP)] = (
|
||||
manager.binding_from_event(rebind_key)
|
||||
)
|
||||
var legacy_file := FileAccess.open(
|
||||
KeyboardMouseMappingManagerType.MAPPING_PATH,
|
||||
FileAccess.WRITE,
|
||||
)
|
||||
assert(legacy_file != null)
|
||||
legacy_file.store_string(JSON.stringify({
|
||||
"format_version": KeyboardMouseMappingManagerType.LEGACY_FORMAT_VERSION,
|
||||
"bindings": legacy_bindings,
|
||||
}))
|
||||
legacy_file.close()
|
||||
var migrated_manager := KeyboardMouseMappingManagerType.new()
|
||||
root.add_child(migrated_manager)
|
||||
await process_frame
|
||||
assert(migrated_manager.has_custom_mapping())
|
||||
assert(_has_physical_key(&"jump", KEY_R))
|
||||
assert(_has_physical_key(&"reel_alternate", KEY_QUOTELEFT))
|
||||
assert(migrated_manager.reset_mapping())
|
||||
|
||||
var fishing_spot := FishingSpotScene.instantiate() as FishingSpotType
|
||||
root.add_child(fishing_spot)
|
||||
await process_frame
|
||||
var local_player := PlayerType.new()
|
||||
fishing_spot.set("_local_player", local_player)
|
||||
fishing_spot.set("_gameplay_input_enabled", true)
|
||||
fishing_spot.state = FishingSpotType.FishingState.FIGHTING
|
||||
var catch_controller := fishing_spot.get_node("%CatchController")
|
||||
var catch_profile := CatchDifficultyProfileType.new()
|
||||
catch_profile.barrier_count_min = 0
|
||||
catch_profile.barrier_count_max = 0
|
||||
catch_controller.start_encounter(catch_profile, 1.0, 1)
|
||||
var alternate_press := InputEventAction.new()
|
||||
alternate_press.action = &"reel_alternate"
|
||||
alternate_press.pressed = true
|
||||
fishing_spot._unhandled_input(alternate_press)
|
||||
assert(bool(catch_controller.get("_reel_input_held")))
|
||||
var alternate_release := InputEventAction.new()
|
||||
alternate_release.action = &"reel_alternate"
|
||||
alternate_release.pressed = false
|
||||
fishing_spot._unhandled_input(alternate_release)
|
||||
assert(not bool(catch_controller.get("_reel_input_held")))
|
||||
fishing_spot.free()
|
||||
local_player.free()
|
||||
|
||||
settings_panel.queue_free()
|
||||
panel.queue_free()
|
||||
migrated_manager.queue_free()
|
||||
manager.queue_free()
|
||||
print("Keyboard and mouse mapping validation: PASS")
|
||||
quit(0)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func _run() -> void:
|
|||
await process_frame
|
||||
assert(bool(normal_main.get("_application_initialized")))
|
||||
_validate_normal_profile(normal_main)
|
||||
_validate_new_game_music_transition(normal_main)
|
||||
_stop_audio_players(normal_main)
|
||||
normal_main.queue_free()
|
||||
for _frame: int in 8:
|
||||
|
|
@ -115,6 +116,7 @@ func _validate_main_profile(main: Node) -> void:
|
|||
assert(player_menu != null)
|
||||
assert(not player_menu.is_cooler_water_effect_enabled())
|
||||
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null)
|
||||
assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null)
|
||||
assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null)
|
||||
assert(
|
||||
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream == null
|
||||
|
|
@ -183,6 +185,7 @@ func _validate_normal_profile(main: Node) -> void:
|
|||
assert(clouds.get_node_or_null("CloudField") is MultiMeshInstance3D)
|
||||
assert(clouds.get_node_or_null("CloudCeiling") == null)
|
||||
assert((main.get_node("%TitleMusic") as AudioStreamPlayer).stream != null)
|
||||
assert((main.get_node("%NewGameMusic") as AudioStreamPlayer).stream != null)
|
||||
assert((main.get_node("%DuskMusic") as AudioStreamPlayer).stream != null)
|
||||
assert(
|
||||
(main.get_node("%WavesAudio") as AudioStreamPlayer).stream != null
|
||||
|
|
@ -192,6 +195,18 @@ func _validate_normal_profile(main: Node) -> void:
|
|||
)
|
||||
|
||||
|
||||
func _validate_new_game_music_transition(main: Node) -> void:
|
||||
var title_music := main.get_node("%TitleMusic") as AudioStreamPlayer
|
||||
var new_game_music := main.get_node("%NewGameMusic") as AudioStreamPlayer
|
||||
assert(title_music.playing)
|
||||
main.call("_start_new_game_music")
|
||||
assert(not title_music.playing)
|
||||
assert(new_game_music.playing)
|
||||
main.call("_show_title_music", true)
|
||||
assert(not new_game_music.playing)
|
||||
assert(title_music.playing)
|
||||
|
||||
|
||||
func _first_fresh_water_visual(main: Node) -> MeshInstance3D:
|
||||
var root := main.get_node(
|
||||
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ func _make_generator() -> TerrainChunkGenerator:
|
|||
"chunk_0005",
|
||||
"chunk_0006",
|
||||
"chunk_0007",
|
||||
"chunk_0008",
|
||||
]
|
||||
)
|
||||
generator.grass_sand_smoothing_enabled = true
|
||||
return generator
|
||||
|
|
|
|||
|
|
@ -21,12 +21,14 @@ shadow_enabled = true
|
|||
[node name="TerrainChunkGenerator" type="Node3D" parent="."]
|
||||
script = ExtResource("2_generator")
|
||||
catalog = ExtResource("3_catalog")
|
||||
grid_size = Vector2i(7, 7)
|
||||
grid_size = Vector2i(11, 9)
|
||||
generation_seed = 13001
|
||||
build_collision = true
|
||||
show_chunk_labels = false
|
||||
force_center_chunk_id = &"chunk_spawn"
|
||||
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007")
|
||||
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004", "chunk_0005", "chunk_0006", "chunk_0007", "chunk_0008")
|
||||
grass_sand_smoothing_enabled = true
|
||||
elevated_cliff_feature_enabled = true
|
||||
|
||||
[node name="Player" parent="." instance=ExtResource("4_player")]
|
||||
position = Vector3(0, 0.3, 0)
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ func _validate_save_migration() -> void:
|
|||
version_five,
|
||||
5,
|
||||
)
|
||||
assert(int(migrated.get("save_version", -1)) == 9)
|
||||
assert(int(migrated.get("save_version", -1)) == 10)
|
||||
var experience_data: Dictionary = migrated.get("experience", {})
|
||||
assert(int(experience_data.get("total_experience", -1)) == 0)
|
||||
var world_data: Dictionary = migrated.get("world", {})
|
||||
|
|
|
|||
|
|
@ -152,15 +152,10 @@ func _run_client() -> void:
|
|||
var chat_service := main.get_node(
|
||||
"%NetworkChatService"
|
||||
) as NetworkChatService
|
||||
var history_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while Time.get_ticks_msec() < history_deadline:
|
||||
if chat_service.get_history().any(
|
||||
func(message: Dictionary) -> bool:
|
||||
return str(message.get("body", "")) == HOST_HISTORY_MESSAGE
|
||||
):
|
||||
break
|
||||
await process_frame
|
||||
assert(chat_service.get_history().any(
|
||||
# Player-hosted rooms deliberately send no prior scrollback to joining or
|
||||
# reconnecting clients. Live messages sent after authentication still work.
|
||||
await create_timer(0.5).timeout
|
||||
assert(not chat_service.get_history().any(
|
||||
func(message: Dictionary) -> bool:
|
||||
return str(message.get("body", "")) == HOST_HISTORY_MESSAGE
|
||||
))
|
||||
|
|
|
|||
135
tests/progression_archive_validation.gd
Normal file
135
tests/progression_archive_validation.gd
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene: PackedScene = preload("res://main/main.tscn")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
root.add_child(main)
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
if not bool(main.get("_application_initialized")):
|
||||
main.call("_activate_selected_data_path", "", true)
|
||||
for _frame: int in 8:
|
||||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
var player := main.get("_player") as Player
|
||||
var data_root := main.get("_data_root") as PlayerDataRoot
|
||||
assert(save_manager != null and player != null and data_root != null)
|
||||
assert(save_manager.initialize_new_game(864209))
|
||||
save_manager.set_autosave_enabled(true)
|
||||
assert(player.wallet.restore_balance(4321))
|
||||
assert(player.bag.add_item(&"coffee"))
|
||||
assert(save_manager.save_now())
|
||||
|
||||
var save_path: String = str(save_manager.get("_save_path"))
|
||||
assert(save_path.ends_with("player/player_save.nfsave"))
|
||||
assert(FileAccess.file_exists(save_path))
|
||||
assert(not FileAccess.file_exists(save_path.get_base_dir().path_join(
|
||||
"player_save.json"
|
||||
)))
|
||||
_assert_opaque(save_path)
|
||||
var decoded: Dictionary = ProgressionSaveCodec.read_local_save(save_path)
|
||||
assert(bool(decoded.get("ok", false)))
|
||||
assert(int((decoded["data"] as Dictionary)["save_version"]) == 10)
|
||||
|
||||
var archive_path: String = data_root.progression_backup_directory().path_join(
|
||||
"progression-test.nfsave"
|
||||
)
|
||||
var exported: Dictionary = save_manager.export_progression_archive(
|
||||
archive_path
|
||||
)
|
||||
assert(bool(exported.get("ok", false)))
|
||||
assert(FileAccess.file_exists(archive_path))
|
||||
_assert_opaque(archive_path)
|
||||
var inspected: Dictionary = save_manager.inspect_progression_archive(
|
||||
archive_path
|
||||
)
|
||||
assert(bool(inspected.get("ok", false)))
|
||||
assert(int(inspected.get("wallet_balance", -1)) == 4321)
|
||||
assert(int(inspected.get("world_seed", -1)) == 864209)
|
||||
|
||||
assert(player.wallet.restore_balance(7))
|
||||
assert(save_manager.save_now())
|
||||
var imported: Dictionary = save_manager.import_progression_archive(
|
||||
archive_path
|
||||
)
|
||||
assert(bool(imported.get("ok", false)))
|
||||
assert(save_manager.load_player_data())
|
||||
assert(player.wallet.get_balance() == 4321)
|
||||
assert(save_manager.get_world_seed() == 864209)
|
||||
|
||||
var legacy_data: Dictionary = (
|
||||
ProgressionSaveCodec.read_local_save(save_path)["data"]
|
||||
)
|
||||
assert(save_manager.delete_progression_save())
|
||||
var legacy_path: String = save_path.get_base_dir().path_join(
|
||||
"player_save.json"
|
||||
)
|
||||
var legacy_file := FileAccess.open(legacy_path, FileAccess.WRITE)
|
||||
assert(legacy_file != null)
|
||||
legacy_file.store_string(JSON.stringify(legacy_data, "\t"))
|
||||
legacy_file.close()
|
||||
assert(save_manager.load_player_data())
|
||||
assert(FileAccess.file_exists(save_path))
|
||||
assert(not FileAccess.file_exists(legacy_path))
|
||||
_assert_opaque(save_path)
|
||||
|
||||
var settings_panels: Array[Node] = main.find_children(
|
||||
"*", "SettingsPanel", true, false
|
||||
)
|
||||
assert(settings_panels.size() == 2)
|
||||
for settings_panel: SettingsPanel in settings_panels:
|
||||
var export_button := settings_panel.get_node(
|
||||
"%ExportProgression"
|
||||
) as Button
|
||||
var import_button := settings_panel.get_node(
|
||||
"%ImportProgression"
|
||||
) as Button
|
||||
assert(export_button != null and import_button != null)
|
||||
assert(not export_button.disabled and not import_button.disabled)
|
||||
assert(
|
||||
export_button.get_node(export_button.focus_neighbor_right)
|
||||
== import_button
|
||||
)
|
||||
assert(
|
||||
import_button.get_node(import_button.focus_neighbor_left)
|
||||
== export_button
|
||||
)
|
||||
|
||||
var migrated_root: String = data_root.root_path.get_base_dir().path_join(
|
||||
"progression-migrated-data"
|
||||
)
|
||||
var data_migration: Dictionary = PortableDataMigration.migrate_active_to(
|
||||
data_root, migrated_root
|
||||
)
|
||||
assert(bool(data_migration.get("ok", false)))
|
||||
var migrated_save: String = migrated_root.path_join(
|
||||
"player/player_save.nfsave"
|
||||
)
|
||||
assert(FileAccess.file_exists(migrated_save))
|
||||
assert(bool(
|
||||
ProgressionSaveCodec.read_local_save(migrated_save).get("ok", false)
|
||||
))
|
||||
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
print("Progression archive validation: PASS")
|
||||
quit()
|
||||
|
||||
|
||||
func _assert_opaque(path: String) -> void:
|
||||
var bytes: PackedByteArray = PortableFileGuard.read_bytes(path)
|
||||
assert(not bytes.is_empty())
|
||||
var encoded_hex: String = bytes.hex_encode()
|
||||
assert("save_version".to_utf8_buffer().hex_encode() not in encoded_hex)
|
||||
assert("wallet".to_utf8_buffer().hex_encode() not in encoded_hex)
|
||||
1
tests/progression_archive_validation.gd.uid
Normal file
1
tests/progression_archive_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ccl47ftc1cu5l
|
||||
|
|
@ -12,6 +12,15 @@ const EXPECTED_IDS: Array[String] = [
|
|||
"chunk_0005",
|
||||
"chunk_0006",
|
||||
"chunk_0007",
|
||||
"chunk_0008",
|
||||
"chunk_0009",
|
||||
"chunk_0010",
|
||||
"chunk_0011",
|
||||
"chunk_0012",
|
||||
"chunk_0013",
|
||||
"chunk_0014",
|
||||
"chunk_0015",
|
||||
"chunk_0016",
|
||||
"chunk_spawn",
|
||||
]
|
||||
const REQUIRED_IDS: Array[String] = [
|
||||
|
|
@ -23,10 +32,11 @@ const REQUIRED_IDS: Array[String] = [
|
|||
"chunk_0005",
|
||||
"chunk_0006",
|
||||
"chunk_0007",
|
||||
"chunk_0008",
|
||||
]
|
||||
const TEST_SEED := 13001
|
||||
const CONNECTOR_STRESS_SEED := 287
|
||||
const EXPECTED_SOLVER_VARIANT_COUNT := 27
|
||||
const EXPECTED_SOLVER_VARIANT_COUNT := 35
|
||||
const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
|
||||
&"chunk_0000": 4,
|
||||
&"chunk_0001": 4,
|
||||
|
|
@ -36,6 +46,15 @@ const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = {
|
|||
&"chunk_0005": 4,
|
||||
&"chunk_0006": 4,
|
||||
&"chunk_0007": 4,
|
||||
&"chunk_0008": 4,
|
||||
&"chunk_0009": 4,
|
||||
&"chunk_0010": 4,
|
||||
&"chunk_0011": 4,
|
||||
&"chunk_0012": 4,
|
||||
&"chunk_0013": 4,
|
||||
&"chunk_0014": 4,
|
||||
&"chunk_0015": 4,
|
||||
&"chunk_0016": 4,
|
||||
&"chunk_spawn": 1,
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +68,7 @@ func _initialize() -> void:
|
|||
func _run() -> void:
|
||||
_validate_catalog()
|
||||
_validate_profiles()
|
||||
_validate_second_tier_coastal_layout()
|
||||
_validate_generation()
|
||||
_finish()
|
||||
|
||||
|
|
@ -75,6 +95,15 @@ func _validate_catalog() -> void:
|
|||
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
|
||||
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
|
||||
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
|
||||
var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008")
|
||||
var grass_sand_diagonal := CATALOG.definition_for_id(&"chunk_0013")
|
||||
var cliff_sea_edge := CATALOG.definition_for_id(&"chunk_0014")
|
||||
var cliff_sea_corner := CATALOG.definition_for_id(&"chunk_0015")
|
||||
var stream_variant := CATALOG.definition_for_id(&"chunk_0016")
|
||||
var cliff_top := CATALOG.definition_for_id(&"chunk_0009")
|
||||
var cliff_corner := CATALOG.definition_for_id(&"chunk_0010")
|
||||
var cliff_edge := CATALOG.definition_for_id(&"chunk_0011")
|
||||
var cliff_ramp := CATALOG.definition_for_id(&"chunk_0012")
|
||||
var spawn := CATALOG.definition_for_id(&"chunk_spawn")
|
||||
_check(
|
||||
(
|
||||
|
|
@ -84,6 +113,15 @@ func _validate_catalog() -> void:
|
|||
and grass_ocean_edge != null
|
||||
and grass_beach_transition != null
|
||||
and grass_ocean_corner != null
|
||||
and beach_ocean_corner != null
|
||||
and grass_sand_diagonal != null
|
||||
and cliff_sea_edge != null
|
||||
and cliff_sea_corner != null
|
||||
and stream_variant != null
|
||||
and cliff_top != null
|
||||
and cliff_corner != null
|
||||
and cliff_edge != null
|
||||
and cliff_ramp != null
|
||||
and spawn != null
|
||||
),
|
||||
"The authored biome-rule definitions must be available.",
|
||||
|
|
@ -95,6 +133,15 @@ func _validate_catalog() -> void:
|
|||
and grass_ocean_edge != null
|
||||
and grass_beach_transition != null
|
||||
and grass_ocean_corner != null
|
||||
and beach_ocean_corner != null
|
||||
and grass_sand_diagonal != null
|
||||
and cliff_sea_edge != null
|
||||
and cliff_sea_corner != null
|
||||
and stream_variant != null
|
||||
and cliff_top != null
|
||||
and cliff_corner != null
|
||||
and cliff_edge != null
|
||||
and cliff_ramp != null
|
||||
and spawn != null
|
||||
):
|
||||
_check(
|
||||
|
|
@ -193,6 +240,124 @@ func _validate_catalog() -> void:
|
|||
),
|
||||
"Both corner land seams must accept straight grass ocean edges.",
|
||||
)
|
||||
_check(
|
||||
beach_ocean_corner.ocean_facing_edges
|
||||
== (
|
||||
(1 << int(TerrainChunkTopology.Edge.EAST))
|
||||
| (1 << int(TerrainChunkTopology.Edge.SOUTH))
|
||||
),
|
||||
"The authored beach corner must expose east and south to ocean.",
|
||||
)
|
||||
_check(
|
||||
beach_ocean_corner.minimum_required_neighbors == 2
|
||||
and (
|
||||
"beach_ocean_edge"
|
||||
in beach_ocean_corner.required_neighbor_tags
|
||||
),
|
||||
"The beach corner must join two straight beach ocean edges.",
|
||||
)
|
||||
_check(
|
||||
grass_sand_diagonal.must_be_interior
|
||||
and not grass_sand_diagonal.participates_in_base_solver
|
||||
and "mixed_surface" in grass_sand_diagonal.tags,
|
||||
(
|
||||
"The grass-sand diagonal must remain an inland post-process "
|
||||
+ "mixed-surface chunk."
|
||||
),
|
||||
)
|
||||
_check(
|
||||
grass_sand_diagonal.surface_tags_for_edge(
|
||||
TerrainChunkTopology.Edge.NORTH
|
||||
) == PackedStringArray(["sand"])
|
||||
and grass_sand_diagonal.surface_tags_for_edge(
|
||||
TerrainChunkTopology.Edge.WEST
|
||||
) == PackedStringArray(["sand"])
|
||||
and grass_sand_diagonal.surface_tags_for_edge(
|
||||
TerrainChunkTopology.Edge.EAST
|
||||
) == PackedStringArray(["grass"])
|
||||
and grass_sand_diagonal.surface_tags_for_edge(
|
||||
TerrainChunkTopology.Edge.SOUTH
|
||||
) == PackedStringArray(["grass"]),
|
||||
"The diagonal's authored edges must retain two sand and two grass sides.",
|
||||
)
|
||||
var stream := CATALOG.definition_for_id(&"chunk_0003")
|
||||
_check(
|
||||
stream != null
|
||||
and is_equal_approx(stream.selection_weight, 0.3)
|
||||
and is_equal_approx(stream_variant.selection_weight, 0.3)
|
||||
and stream_variant.tags == stream.tags
|
||||
and stream_variant.allowed_neighbor_tags
|
||||
== stream.allowed_neighbor_tags
|
||||
and stream_variant.water_inlet_edges == stream.water_inlet_edges
|
||||
and stream_variant.water_outlet_edges == stream.water_outlet_edges
|
||||
and stream_variant.water_surface_size == stream.water_surface_size
|
||||
and stream_variant.water_surface_offset
|
||||
== stream.water_surface_offset,
|
||||
(
|
||||
"Both stream visuals must share one combined selection weight "
|
||||
+ "and identical terrain/water behavior."
|
||||
),
|
||||
)
|
||||
for cliff_definition: TerrainChunkDefinition in [
|
||||
cliff_top,
|
||||
cliff_corner,
|
||||
cliff_edge,
|
||||
cliff_ramp,
|
||||
]:
|
||||
_check(
|
||||
cliff_definition.overlay_only
|
||||
and cliff_definition.must_be_interior
|
||||
and cliff_definition.base_layer_scene != null
|
||||
and cliff_definition.base_layer_mesh_name == &"chunk_0000",
|
||||
"Every second-tier piece must overlay level-one grass inland.",
|
||||
)
|
||||
for coastal_cliff: TerrainChunkDefinition in [
|
||||
cliff_sea_edge,
|
||||
cliff_sea_corner,
|
||||
]:
|
||||
_check(
|
||||
coastal_cliff.overlay_only
|
||||
and coastal_cliff.base_layer_scene == null
|
||||
and not coastal_cliff.must_be_interior
|
||||
and coastal_cliff.prefers_map_boundary
|
||||
and "coast" in coastal_cliff.tags
|
||||
and "elevation_2_sea_edge" in coastal_cliff.tags,
|
||||
(
|
||||
"Each second-tier sea piece must be a complete coastal "
|
||||
+ "overlay without an inland grass base."
|
||||
),
|
||||
)
|
||||
_check(
|
||||
cliff_sea_edge.ocean_facing_edges
|
||||
== (1 << int(TerrainChunkTopology.Edge.EAST)),
|
||||
"The second-tier sea edge must face east before rotation.",
|
||||
)
|
||||
_check(
|
||||
cliff_sea_corner.ocean_facing_edges
|
||||
== (
|
||||
(1 << int(TerrainChunkTopology.Edge.EAST))
|
||||
| (1 << int(TerrainChunkTopology.Edge.SOUTH))
|
||||
),
|
||||
"The second-tier sea corner must face east and south before rotation.",
|
||||
)
|
||||
_check(
|
||||
cliff_sea_corner.minimum_required_neighbors == 2
|
||||
and (
|
||||
"elevation_2_sea_edge"
|
||||
in cliff_sea_corner.required_neighbor_tags
|
||||
),
|
||||
"The second-tier sea corner must join two straight sea edges.",
|
||||
)
|
||||
_check(
|
||||
cliff_top.minimum_required_neighbors == 4
|
||||
and "elevation_2" in cliff_top.required_neighbor_tags,
|
||||
"The second-tier top must be enclosed on all four sides.",
|
||||
)
|
||||
_check(
|
||||
"ramp" in cliff_ramp.tags
|
||||
and cliff_ramp.maximum_placements > 0,
|
||||
"The second-tier ramp must remain available without being required.",
|
||||
)
|
||||
|
||||
|
||||
func _validate_profiles() -> void:
|
||||
|
|
@ -228,6 +393,91 @@ func _validate_profiles() -> void:
|
|||
)
|
||||
|
||||
|
||||
func _validate_second_tier_coastal_layout() -> void:
|
||||
var generator := TerrainChunkGenerator.new()
|
||||
generator.catalog = CATALOG
|
||||
generator.grid_size = Vector2i(4, 4)
|
||||
generator.generate_on_ready = false
|
||||
generator.build_collision = false
|
||||
generator.force_center_chunk_id = &""
|
||||
root.add_child(generator)
|
||||
var keys := PackedStringArray()
|
||||
for row: int in generator.grid_size.y:
|
||||
for column: int in generator.grid_size.x:
|
||||
var at_north := row == 0
|
||||
var at_east := column == generator.grid_size.x - 1
|
||||
var at_south := row == generator.grid_size.y - 1
|
||||
var at_west := column == 0
|
||||
if at_north and at_west:
|
||||
keys.append("chunk_0015@2")
|
||||
elif at_north and at_east:
|
||||
keys.append("chunk_0015@1")
|
||||
elif at_south and at_east:
|
||||
keys.append("chunk_0015@0")
|
||||
elif at_south and at_west:
|
||||
keys.append("chunk_0015@3")
|
||||
elif at_north:
|
||||
keys.append("chunk_0014@1")
|
||||
elif at_east:
|
||||
keys.append("chunk_0014@0")
|
||||
elif at_south:
|
||||
keys.append("chunk_0014@3")
|
||||
elif at_west:
|
||||
keys.append("chunk_0014@2")
|
||||
else:
|
||||
keys.append("chunk_0009@0")
|
||||
_check(
|
||||
generator.generate_from_placement_keys(keys),
|
||||
(
|
||||
"The second-tier sea edge and corner must form a complete "
|
||||
+ "raised coastal perimeter."
|
||||
),
|
||||
)
|
||||
if generator.placement_keys().size() == keys.size():
|
||||
_check(
|
||||
_all_neighbor_edges_match(generator),
|
||||
"Every raised coastal-perimeter seam must match.",
|
||||
)
|
||||
_check(
|
||||
_count_placements(generator.placement_keys(), "chunk_0014") == 8
|
||||
and _count_placements(
|
||||
generator.placement_keys(), "chunk_0015"
|
||||
) == 4,
|
||||
"The raised coastal perimeter must use its straight and corner pieces.",
|
||||
)
|
||||
for record: Dictionary in generator.placement_records():
|
||||
var stable_id: StringName = record.get("stable_id", &"")
|
||||
if stable_id not in [&"chunk_0014", &"chunk_0015"]:
|
||||
continue
|
||||
var coordinate: Vector2i = record.get(
|
||||
"coordinate", Vector2i.ZERO
|
||||
)
|
||||
var variant := _find_variant(
|
||||
generator,
|
||||
stable_id,
|
||||
int(record.get("rotation_quarters", 0)),
|
||||
)
|
||||
_check(
|
||||
variant != null
|
||||
and generator._variant_respects_ocean_boundary(
|
||||
variant, coordinate
|
||||
),
|
||||
"Every second-tier sea opening must face outside the terrain grid.",
|
||||
)
|
||||
var generated := generator.get_generated_chunks_root()
|
||||
if generated != null:
|
||||
for child: Node in generated.get_children():
|
||||
var stable_id := StringName(
|
||||
child.get_meta(&"terrain_chunk_id", &"")
|
||||
)
|
||||
if stable_id in [&"chunk_0014", &"chunk_0015"]:
|
||||
_check(
|
||||
child.get_node_or_null("TerrainBaseLayer") == null,
|
||||
"Second-tier sea pieces must not render inland grass over ocean.",
|
||||
)
|
||||
generator.free()
|
||||
|
||||
|
||||
func _validate_generation() -> void:
|
||||
var first := _make_generator()
|
||||
root.add_child(first)
|
||||
|
|
@ -243,6 +493,10 @@ func _validate_generation() -> void:
|
|||
_placements_contain(first_keys, stable_id),
|
||||
"Generated diagnostic must contain %s." % stable_id,
|
||||
)
|
||||
_check(
|
||||
_placements_contain(first_keys, "chunk_0013"),
|
||||
"Generated terrain must replace sharp grass-sand corners with diagonals.",
|
||||
)
|
||||
_check(
|
||||
_all_neighbor_edges_match(first),
|
||||
"Every generated neighboring edge must match.",
|
||||
|
|
@ -351,8 +605,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
)
|
||||
|
||||
var stream := CATALOG.definition_for_id(&"chunk_0003")
|
||||
_check(stream != null, "The stream definition must be available.")
|
||||
if stream == null:
|
||||
var alternate_stream := CATALOG.definition_for_id(&"chunk_0016")
|
||||
_check(
|
||||
stream != null and alternate_stream != null,
|
||||
"Both stream visual definitions must be available.",
|
||||
)
|
||||
if stream == null or alternate_stream == null:
|
||||
return
|
||||
for quarter_turns: int in 4:
|
||||
var stream_variant := _find_variant(
|
||||
|
|
@ -364,7 +622,16 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
stream_variant != null,
|
||||
"The stream must support rotation %d." % quarter_turns,
|
||||
)
|
||||
if stream_variant == null:
|
||||
var alternate_variant := _find_variant(
|
||||
generator,
|
||||
&"chunk_0016",
|
||||
quarter_turns,
|
||||
)
|
||||
_check(
|
||||
alternate_variant != null,
|
||||
"The alternate stream must support rotation %d." % quarter_turns,
|
||||
)
|
||||
if stream_variant == null or alternate_variant == null:
|
||||
continue
|
||||
var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge(
|
||||
TerrainChunkTopology.Edge.NORTH,
|
||||
|
|
@ -384,6 +651,25 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
== expected_outlet,
|
||||
"The rotated stream outlet must follow its authored orientation.",
|
||||
)
|
||||
_check(
|
||||
alternate_variant.rotated_edge_mask(
|
||||
alternate_stream.water_inlet_edges
|
||||
) == expected_inlet
|
||||
and alternate_variant.rotated_edge_mask(
|
||||
alternate_stream.water_outlet_edges
|
||||
) == expected_outlet,
|
||||
"The alternate stream connectors must rotate exactly like the original.",
|
||||
)
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var edge := edge_value as TerrainChunkTopology.Edge
|
||||
_check(
|
||||
stream_variant.profile(edge).signature(
|
||||
TerrainChunkGenerator.CONSTRAINT_PROFILE_QUANTIZATION
|
||||
) == alternate_variant.profile(edge).signature(
|
||||
TerrainChunkGenerator.CONSTRAINT_PROFILE_QUANTIZATION
|
||||
),
|
||||
"Both stream visuals must expose identical terrain edge profiles.",
|
||||
)
|
||||
|
||||
var beach := CATALOG.definition_for_id(&"chunk_0002")
|
||||
_check(beach != null, "The beach definition must be available.")
|
||||
|
|
@ -443,11 +729,13 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005")
|
||||
var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006")
|
||||
var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007")
|
||||
var beach_ocean_corner := CATALOG.definition_for_id(&"chunk_0008")
|
||||
_check(
|
||||
(
|
||||
grass_ocean_edge != null
|
||||
and grass_beach_transition != null
|
||||
and grass_ocean_corner != null
|
||||
and beach_ocean_corner != null
|
||||
),
|
||||
"The authored coastline additions must be available.",
|
||||
)
|
||||
|
|
@ -455,6 +743,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
grass_ocean_edge == null
|
||||
or grass_beach_transition == null
|
||||
or grass_ocean_corner == null
|
||||
or beach_ocean_corner == null
|
||||
):
|
||||
return
|
||||
var beach_zero := _find_variant(generator, &"chunk_0002", 0)
|
||||
|
|
@ -462,12 +751,14 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
var grass_edge_south := _find_variant(generator, &"chunk_0005", 3)
|
||||
var transition_zero := _find_variant(generator, &"chunk_0006", 0)
|
||||
var corner_zero := _find_variant(generator, &"chunk_0007", 0)
|
||||
var beach_corner_zero := _find_variant(generator, &"chunk_0008", 0)
|
||||
_check(
|
||||
beach_zero != null
|
||||
and grass_edge_zero != null
|
||||
and grass_edge_south != null
|
||||
and transition_zero != null
|
||||
and corner_zero != null,
|
||||
and corner_zero != null
|
||||
and beach_corner_zero != null,
|
||||
"The unrotated coastline variants must be available.",
|
||||
)
|
||||
if (
|
||||
|
|
@ -476,6 +767,7 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
and grass_edge_south != null
|
||||
and transition_zero != null
|
||||
and corner_zero != null
|
||||
and beach_corner_zero != null
|
||||
):
|
||||
_check(
|
||||
grass_edge_zero.profile(
|
||||
|
|
@ -522,6 +814,80 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
),
|
||||
"The corner's west side must join a straight grass ocean edge.",
|
||||
)
|
||||
_check(
|
||||
beach_corner_zero.profile(TerrainChunkTopology.Edge.NORTH).matches(
|
||||
beach_zero.profile(TerrainChunkTopology.Edge.SOUTH),
|
||||
generator.edge_match_tolerance,
|
||||
),
|
||||
"The beach corner's north side must join a straight beach.",
|
||||
)
|
||||
var beach_south := _find_variant(generator, &"chunk_0002", 3)
|
||||
_check(
|
||||
beach_south != null
|
||||
and beach_corner_zero.profile(
|
||||
TerrainChunkTopology.Edge.WEST
|
||||
).matches(
|
||||
beach_south.profile(TerrainChunkTopology.Edge.EAST),
|
||||
generator.edge_match_tolerance,
|
||||
),
|
||||
"The beach corner's west side must join a rotated straight beach.",
|
||||
)
|
||||
var cliff_top_zero := _find_variant(generator, &"chunk_0009", 0)
|
||||
var cliff_sea_edge_zero := _find_variant(generator, &"chunk_0014", 0)
|
||||
var cliff_sea_edge_south := _find_variant(generator, &"chunk_0014", 3)
|
||||
var cliff_sea_corner_zero := _find_variant(generator, &"chunk_0015", 0)
|
||||
_check(
|
||||
cliff_top_zero != null
|
||||
and cliff_sea_edge_zero != null
|
||||
and cliff_sea_edge_south != null
|
||||
and cliff_sea_corner_zero != null,
|
||||
"The second-tier sea edge variants must be available.",
|
||||
)
|
||||
if (
|
||||
cliff_top_zero != null
|
||||
and cliff_sea_edge_zero != null
|
||||
and cliff_sea_edge_south != null
|
||||
and cliff_sea_corner_zero != null
|
||||
):
|
||||
_check(
|
||||
cliff_sea_edge_zero.profile(
|
||||
TerrainChunkTopology.Edge.EAST
|
||||
).points.is_empty(),
|
||||
"The second-tier sea edge must remain open on its ocean side.",
|
||||
)
|
||||
_check(
|
||||
cliff_sea_corner_zero.profile(
|
||||
TerrainChunkTopology.Edge.EAST
|
||||
).points.is_empty()
|
||||
and cliff_sea_corner_zero.profile(
|
||||
TerrainChunkTopology.Edge.SOUTH
|
||||
).points.is_empty(),
|
||||
"The second-tier sea corner must retain both ocean openings.",
|
||||
)
|
||||
_check(
|
||||
generator._edges_are_compatible(
|
||||
cliff_sea_edge_zero,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
cliff_top_zero,
|
||||
TerrainChunkTopology.Edge.EAST,
|
||||
),
|
||||
"The second-tier sea edge must join the raised grass top inland.",
|
||||
)
|
||||
_check(
|
||||
generator._edges_are_compatible(
|
||||
cliff_sea_corner_zero,
|
||||
TerrainChunkTopology.Edge.NORTH,
|
||||
cliff_sea_edge_zero,
|
||||
TerrainChunkTopology.Edge.SOUTH,
|
||||
)
|
||||
and generator._edges_are_compatible(
|
||||
cliff_sea_corner_zero,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
cliff_sea_edge_south,
|
||||
TerrainChunkTopology.Edge.EAST,
|
||||
),
|
||||
"The second-tier sea corner must join two straight sea edges.",
|
||||
)
|
||||
var flat_grass := _find_variant(generator, &"chunk_0000", 0)
|
||||
var flat_sand := _find_variant(generator, &"chunk_0001", 0)
|
||||
_check(
|
||||
|
|
@ -560,7 +926,58 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
),
|
||||
"No transition rotation may accept sand behind it.",
|
||||
)
|
||||
for stable_id: StringName in [&"chunk_0005", &"chunk_0006", &"chunk_0007"]:
|
||||
for quarter_turns: int in 4:
|
||||
var diagonal := _find_variant(
|
||||
generator,
|
||||
&"chunk_0013",
|
||||
quarter_turns,
|
||||
)
|
||||
_check(
|
||||
diagonal != null,
|
||||
"The grass-sand diagonal must support rotation %d."
|
||||
% quarter_turns,
|
||||
)
|
||||
if diagonal == null:
|
||||
continue
|
||||
for source_edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var source_edge := source_edge_value as TerrainChunkTopology.Edge
|
||||
var edge := TerrainChunkTopology.rotated_edge(
|
||||
source_edge,
|
||||
quarter_turns,
|
||||
)
|
||||
var neighbor_edge := TerrainChunkTopology.opposite_edge(edge)
|
||||
var expects_sand := source_edge in [
|
||||
TerrainChunkTopology.Edge.NORTH,
|
||||
TerrainChunkTopology.Edge.WEST,
|
||||
]
|
||||
var expected := flat_sand if expects_sand else flat_grass
|
||||
var rejected := flat_grass if expects_sand else flat_sand
|
||||
_check(
|
||||
generator._edges_are_compatible(
|
||||
diagonal,
|
||||
edge,
|
||||
expected,
|
||||
neighbor_edge,
|
||||
),
|
||||
"Each rotated diagonal edge must accept its authored surface.",
|
||||
)
|
||||
_check(
|
||||
not generator._edges_are_compatible(
|
||||
diagonal,
|
||||
edge,
|
||||
rejected,
|
||||
neighbor_edge,
|
||||
),
|
||||
"No rotated diagonal edge may accept the opposite surface.",
|
||||
)
|
||||
for stable_id: StringName in [
|
||||
&"chunk_0005",
|
||||
&"chunk_0006",
|
||||
&"chunk_0007",
|
||||
&"chunk_0008",
|
||||
&"chunk_0014",
|
||||
&"chunk_0015",
|
||||
]:
|
||||
for quarter_turns: int in 4:
|
||||
var coast_variant := _find_variant(
|
||||
generator,
|
||||
|
|
@ -580,11 +997,12 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
),
|
||||
"%s must never place its ocean edge inside the map." % stable_id,
|
||||
)
|
||||
if stable_id != &"chunk_0007":
|
||||
var coast_definition := CATALOG.definition_for_id(stable_id)
|
||||
if coast_definition == null:
|
||||
continue
|
||||
var boundary_coordinate := Vector2i(3, 3)
|
||||
var ocean_edges := coast_variant.rotated_edge_mask(
|
||||
grass_ocean_corner.ocean_facing_edges
|
||||
coast_definition.ocean_facing_edges
|
||||
)
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
if (ocean_edges & (1 << edge_value)) == 0:
|
||||
|
|
@ -603,15 +1021,15 @@ func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void:
|
|||
coast_variant,
|
||||
boundary_coordinate,
|
||||
),
|
||||
"Each grass corner rotation must fit its matching map corner.",
|
||||
"Each coast rotation must fit its matching open map boundary.",
|
||||
)
|
||||
|
||||
|
||||
func _validate_generation_summary(generator: TerrainChunkGenerator) -> void:
|
||||
var summary := generator._build_summary()
|
||||
_check(
|
||||
int(summary.get("variant_count", 0)) == 33,
|
||||
"The current catalog must expose all 33 authored rotations.",
|
||||
int(summary.get("variant_count", 0)) == 69,
|
||||
"The current catalog must expose all 69 authored rotations.",
|
||||
)
|
||||
_check(
|
||||
int(summary.get("solver_variant_count", 0))
|
||||
|
|
@ -664,6 +1082,7 @@ func _make_generator() -> TerrainChunkGenerator:
|
|||
generator.build_collision = false
|
||||
generator.force_center_chunk_id = &"chunk_spawn"
|
||||
generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS)
|
||||
generator.grass_sand_smoothing_enabled = true
|
||||
generator.maximum_backtracks = 100000
|
||||
return generator
|
||||
|
||||
|
|
@ -811,6 +1230,27 @@ func _validate_layout_rules(generator: TerrainChunkGenerator) -> void:
|
|||
straight_edge_neighbors == 2,
|
||||
"Every grass corner must join two straight grass ocean edges.",
|
||||
)
|
||||
if placement.definition.stable_id == &"chunk_0008":
|
||||
var straight_beach_neighbors := 0
|
||||
for edge_value: int in TerrainChunkTopology.Edge.values():
|
||||
var neighbor_coordinate := (
|
||||
coordinate
|
||||
+ TerrainChunkTopology.grid_offset(
|
||||
edge_value as TerrainChunkTopology.Edge
|
||||
)
|
||||
)
|
||||
if not generator._coordinate_is_inside_grid(neighbor_coordinate):
|
||||
continue
|
||||
var neighbor := generator._placements[
|
||||
neighbor_coordinate.y * generator.grid_size.x
|
||||
+ neighbor_coordinate.x
|
||||
]
|
||||
if neighbor.definition.stable_id == &"chunk_0002":
|
||||
straight_beach_neighbors += 1
|
||||
_check(
|
||||
straight_beach_neighbors == 2,
|
||||
"Every beach corner must join two straight beach ocean edges.",
|
||||
)
|
||||
if placement.definition.stable_id != &"chunk_0001":
|
||||
continue
|
||||
var touches_coast := false
|
||||
|
|
|
|||
126
tests/world_layout_multiplayer_validation.gd
Normal file
126
tests/world_layout_multiplayer_validation.gd
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene: PackedScene = preload("res://main/main.tscn")
|
||||
const TEST_PORT: int = 18155
|
||||
const TEST_SEED: int = 86421357
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var arguments: PackedStringArray = OS.get_cmdline_user_args()
|
||||
if arguments.has("host"):
|
||||
await _run_host()
|
||||
return
|
||||
if arguments.has("client"):
|
||||
await _run_client()
|
||||
return
|
||||
push_error("World layout multiplayer validation needs host or client mode.")
|
||||
quit(1)
|
||||
|
||||
|
||||
func _run_host() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var save_manager := main.get("_save_manager") as PlayerSaveManager
|
||||
assert(bool(main.call(
|
||||
"_apply_world",
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
TEST_SEED,
|
||||
true,
|
||||
)))
|
||||
assert(bool(main.call(
|
||||
"_prepare_host_world",
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
TEST_SEED,
|
||||
)))
|
||||
assert(save_manager.initialize_new_game(
|
||||
TEST_SEED,
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
))
|
||||
assert(session.start_private_host(TEST_PORT))
|
||||
main.call("_enter_gameplay")
|
||||
assert(session.set_host_open(true))
|
||||
var remote_peer_id: int = await _wait_for_remote_peer(session)
|
||||
assert(remote_peer_id > 1)
|
||||
assert(session.peer_supports_capability(
|
||||
remote_peer_id,
|
||||
NetworkProtocol.WORLD_LAYOUT_CAPABILITY,
|
||||
))
|
||||
var disconnect_deadline: int = Time.get_ticks_msec() + 12000
|
||||
while (
|
||||
Time.get_ticks_msec() < disconnect_deadline
|
||||
and session.is_authenticated_peer(remote_peer_id)
|
||||
):
|
||||
await process_frame
|
||||
assert(not session.is_authenticated_peer(remote_peer_id))
|
||||
print("World layout multiplayer host validation: PASS")
|
||||
await _cleanup(main, session)
|
||||
|
||||
|
||||
func _run_client() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
main.call(
|
||||
"_on_title_join_game_requested",
|
||||
"127.0.0.1:%d" % TEST_PORT,
|
||||
)
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var deadline: int = Time.get_ticks_msec() + 20000
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
await process_frame
|
||||
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
|
||||
main.call("_confirm_server_trust")
|
||||
if session.is_joined_client() and bool(main.get("_gameplay_started")):
|
||||
break
|
||||
assert(session.is_joined_client())
|
||||
assert(session.supports_server_capability(
|
||||
NetworkProtocol.WORLD_LAYOUT_CAPABILITY
|
||||
))
|
||||
var metadata: Dictionary = session.get_last_server_metadata()
|
||||
assert(str(metadata.get("world_layout", "")) == String(
|
||||
WorldLayout.STARTER_ISLAND
|
||||
))
|
||||
assert(int(metadata.get("world_seed", 0)) == TEST_SEED)
|
||||
var world := main.get("_test_world") as TestWorld
|
||||
assert(world.get_world_layout() == WorldLayout.STARTER_ISLAND)
|
||||
assert(world.get_generation_seed() == TEST_SEED)
|
||||
assert(world.get_node_or_null("Regions/StarterIslandRegion") != null)
|
||||
assert(world.get_fishing_shop() != null)
|
||||
assert(world.get_player_storage() != null)
|
||||
print("World layout multiplayer client validation: PASS")
|
||||
await _cleanup(main, session)
|
||||
|
||||
|
||||
func _create_initialized_main() -> Node:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
root.add_child(main)
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
if not bool(main.get("_application_initialized")):
|
||||
main.call("_activate_selected_data_path", "", true)
|
||||
for _frame: int in 8:
|
||||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
return main
|
||||
|
||||
|
||||
func _wait_for_remote_peer(session: NetworkSession) -> int:
|
||||
var deadline: int = Time.get_ticks_msec() + 20000
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
await process_frame
|
||||
for peer_id: int in session.get_authenticated_peer_ids():
|
||||
if peer_id != session.get_local_peer_id():
|
||||
return peer_id
|
||||
return 0
|
||||
|
||||
|
||||
func _cleanup(main: Node, session: NetworkSession) -> void:
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
1
tests/world_layout_multiplayer_validation.gd.uid
Normal file
1
tests/world_layout_multiplayer_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bg52pbgv5ykjc
|
||||
125
tests/world_layout_validation.gd
Normal file
125
tests/world_layout_validation.gd
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
extends SceneTree
|
||||
|
||||
const TestWorldScene: PackedScene = preload("res://world/test_world.tscn")
|
||||
const SetupPageScene: PackedScene = preload(
|
||||
"res://ui/new_game_setup_page.tscn"
|
||||
)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
_validate_layout_values()
|
||||
await _validate_setup_page()
|
||||
await _validate_world_switching()
|
||||
_validate_network_metadata()
|
||||
print("World layout validation: PASS")
|
||||
quit()
|
||||
|
||||
|
||||
func _validate_layout_values() -> void:
|
||||
assert(WorldLayout.is_valid(WorldLayout.GENERATED))
|
||||
assert(WorldLayout.is_valid(String(WorldLayout.STARTER_ISLAND)))
|
||||
assert(not WorldLayout.is_valid(&"unknown"))
|
||||
assert(WorldLayout.normalized("unknown") == WorldLayout.GENERATED)
|
||||
assert(WorldLayout.display_name(WorldLayout.STARTER_ISLAND) == "starter island")
|
||||
|
||||
|
||||
func _validate_setup_page() -> void:
|
||||
assert(NewGameSetupPage.parse_seed_text(" 24680 ") == 24680)
|
||||
assert(NewGameSetupPage.parse_seed_text("0") == 0)
|
||||
assert(NewGameSetupPage.parse_seed_text("2147483647") == 0)
|
||||
assert(NewGameSetupPage.parse_seed_text("") == 0)
|
||||
var text_seed: int = NewGameSetupPage.parse_seed_text("voyager island")
|
||||
assert(text_seed > 0 and text_seed <= PlayerSaveManager.MAX_WORLD_SEED)
|
||||
assert(NewGameSetupPage.parse_seed_text(" voyager island ") == text_seed)
|
||||
assert(NewGameSetupPage.parse_seed_text("voyager island") == text_seed)
|
||||
assert(NewGameSetupPage.parse_seed_text("Voyager Island") != text_seed)
|
||||
var page := SetupPageScene.instantiate() as NewGameSetupPage
|
||||
root.add_child(page)
|
||||
await process_frame
|
||||
page.open_page(true)
|
||||
await process_frame
|
||||
assert(page.visible)
|
||||
var paper := page.get_node("%Paper") as PanelContainer
|
||||
assert(paper.get_combined_minimum_size().x <= paper.size.x)
|
||||
assert(paper.get_combined_minimum_size().y <= paper.size.y)
|
||||
assert(page.get_selected_world_layout() == WorldLayout.GENERATED)
|
||||
assert(page.get_selected_world_seed() > 0)
|
||||
assert((page.get_node("%OverwriteWarning") as Label).visible)
|
||||
var generated_button := page.get_node("%GeneratedButton") as Button
|
||||
var starter_button := page.get_node("%StarterButton") as Button
|
||||
var random_button := page.get_node("%RandomSeedButton") as Button
|
||||
var custom_button := page.get_node("%CustomSeedButton") as Button
|
||||
var seed_edit := page.get_node("%SeedEdit") as LineEdit
|
||||
var start_button := page.get_node("%StartButton") as Button
|
||||
var back_button := page.get_node("%BackButton") as Button
|
||||
assert(generated_button.get_node(generated_button.focus_neighbor_right) == starter_button)
|
||||
assert(generated_button.get_node(generated_button.focus_neighbor_bottom) == random_button)
|
||||
assert(random_button.get_node(random_button.focus_neighbor_right) == custom_button)
|
||||
assert(start_button.get_node(start_button.focus_neighbor_right) == back_button)
|
||||
page.call("_choose_custom_seed")
|
||||
assert(seed_edit.editable)
|
||||
assert(custom_button.get_node(custom_button.focus_neighbor_bottom) == seed_edit)
|
||||
assert(seed_edit.get_node(seed_edit.focus_neighbor_bottom) == start_button)
|
||||
page.call("_set_world_layout", WorldLayout.STARTER_ISLAND)
|
||||
assert(not (page.get_node("%SeedSection") as Control).visible)
|
||||
var request: Dictionary = {}
|
||||
page.start_requested.connect(func(layout: StringName, seed: int) -> void:
|
||||
request["layout"] = layout
|
||||
request["seed"] = seed
|
||||
)
|
||||
page.call("_request_start")
|
||||
assert(request.get("layout") == WorldLayout.STARTER_ISLAND)
|
||||
assert(int(request.get("seed", 0)) > 0)
|
||||
page.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_world_switching() -> void:
|
||||
var world := TestWorldScene.instantiate() as TestWorld
|
||||
root.add_child(world)
|
||||
await process_frame
|
||||
assert(world.get_world_layout() == WorldLayout.GENERATED)
|
||||
assert(world.get_fishing_shop() != null)
|
||||
assert(world.get_player_storage() != null)
|
||||
assert(world.activate_world(WorldLayout.STARTER_ISLAND, 13579))
|
||||
assert(world.get_world_layout() == WorldLayout.STARTER_ISLAND)
|
||||
assert(world.get_generation_seed() == 13579)
|
||||
assert(world.get_node_or_null("Regions/StarterIslandRegion") != null)
|
||||
assert(world.get_node_or_null("Regions/GeneratedWorldRegion") == null)
|
||||
assert(world.get_fishing_shop() != null)
|
||||
assert(world.get_player_storage() != null)
|
||||
assert(not world.get_fishable_water_regions().is_empty())
|
||||
assert(world.activate_world(WorldLayout.GENERATED, 24680))
|
||||
assert(world.get_world_layout() == WorldLayout.GENERATED)
|
||||
assert(world.get_generation_seed() == 24680)
|
||||
assert(world.get_node_or_null("Regions/GeneratedWorldRegion") != null)
|
||||
assert(world.get_node_or_null("Regions/StarterIslandRegion") == null)
|
||||
world.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_network_metadata() -> void:
|
||||
var hello: Dictionary = NetworkProtocol.make_server_hello(
|
||||
true,
|
||||
NetworkProtocol.RejectionCode.NONE,
|
||||
"session",
|
||||
2,
|
||||
1,
|
||||
16,
|
||||
"room",
|
||||
97531,
|
||||
WorldLayout.STARTER_ISLAND,
|
||||
)
|
||||
assert(str(hello.get("world_layout", "")) == String(
|
||||
WorldLayout.STARTER_ISLAND
|
||||
))
|
||||
assert(int(hello.get("world_seed", 0)) == 97531)
|
||||
assert(
|
||||
NetworkProtocol.WORLD_LAYOUT_CAPABILITY
|
||||
in PackedStringArray(hello.get("capability_flags", []))
|
||||
)
|
||||
1
tests/world_layout_validation.gd.uid
Normal file
1
tests/world_layout_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bgxgyqq73sdpi
|
||||
|
|
@ -282,10 +282,22 @@ def _discover_chunks(tolerance: float) -> list[ChunkSource]:
|
|||
# Coastline meshes may intentionally stop before the positive-X edge,
|
||||
# leaving the surrounding ocean visible. Positive X is the canonical
|
||||
# authored ocean direction; Godot supplies the quarter-turn variants.
|
||||
allow_open_east_edge = "ocean_edge" in label
|
||||
# Elevated inland cliff overlays also stop at their downhill edge,
|
||||
# while second-tier sea pieces carry that edge down to ocean depth.
|
||||
layered_cliff = "cliff_2" in label
|
||||
allow_open_east_edge = (
|
||||
"ocean_edge" in label
|
||||
or "sea_edge" in label
|
||||
or "sea_corner" in label
|
||||
or layered_cliff
|
||||
)
|
||||
# Corner pieces additionally leave canonical negative Y open. Their
|
||||
# two marked ocean edges rotate together at runtime.
|
||||
allow_open_south_edge = "ocean_edge_corner" in label
|
||||
# two marked outer edges rotate together at runtime.
|
||||
allow_open_south_edge = (
|
||||
"ocean_edge_corner" in label
|
||||
or "sea_corner" in label
|
||||
or (layered_cliff and "corner" in label)
|
||||
)
|
||||
bound_problems = _validate_bounds(
|
||||
bounds,
|
||||
tolerance,
|
||||
|
|
|
|||
|
|
@ -893,12 +893,14 @@ func _on_entry_text_submitted(_value: String) -> void:
|
|||
func _send() -> void:
|
||||
if _send_pending:
|
||||
return
|
||||
var body := _entry.text
|
||||
if body.strip_edges().is_empty():
|
||||
var body := NetworkChatProtocol.sanitize_body(_entry.text)
|
||||
if body.is_empty():
|
||||
close_chat()
|
||||
return
|
||||
if _handle_editor_world_command(body):
|
||||
return
|
||||
_send_pending = true
|
||||
_pending_send_body = NetworkChatProtocol.sanitize_body(body)
|
||||
_pending_send_body = body
|
||||
_entry.editable = false
|
||||
if not _service.send_local_message(
|
||||
body,
|
||||
|
|
@ -912,6 +914,82 @@ func _send() -> void:
|
|||
_set_status("Sending…")
|
||||
|
||||
|
||||
func _handle_editor_world_command(body: String) -> bool:
|
||||
# These commands are deliberately limited to sessions launched by the
|
||||
# Godot editor. Exported builds do not have the editor feature tag, and a
|
||||
# joined editor client must not be able to mutate its host's world.
|
||||
if not OS.has_feature("editor"):
|
||||
return false
|
||||
var command_text: String = body.strip_edges()
|
||||
if not (
|
||||
command_text.begins_with("/time")
|
||||
or command_text.begins_with("/weather")
|
||||
):
|
||||
return false
|
||||
var parts: PackedStringArray = command_text.split(" ", false)
|
||||
var command: String = String(parts[0]).trim_prefix("/").to_lower()
|
||||
var result: String = ""
|
||||
if _session == null or not _session.is_host():
|
||||
result = "Editor world commands require the authoritative host."
|
||||
elif command == "time":
|
||||
result = _apply_editor_time_command(parts)
|
||||
elif command == "weather":
|
||||
result = _apply_editor_weather_command(parts)
|
||||
else:
|
||||
return false
|
||||
_entry.clear()
|
||||
_flush_draft()
|
||||
_set_status(result)
|
||||
close_chat(true)
|
||||
return true
|
||||
|
||||
|
||||
func _apply_editor_time_command(parts: PackedStringArray) -> String:
|
||||
if parts.size() != 2 or _world_time == null:
|
||||
return "Usage: /time [dawn, day, dusk, night]"
|
||||
var phase_name: String = String(parts[1]).to_lower()
|
||||
var target_hour: float = -1.0
|
||||
match phase_name:
|
||||
"dawn":
|
||||
target_hour = WorldTimeServiceType.DAWN_START_HOUR
|
||||
"day":
|
||||
target_hour = WorldTimeServiceType.DAWN_END_HOUR
|
||||
"dusk":
|
||||
target_hour = WorldTimeServiceType.DUSK_START_HOUR
|
||||
"night":
|
||||
target_hour = WorldTimeServiceType.DUSK_END_HOUR
|
||||
_:
|
||||
return "Usage: /time [dawn, day, dusk, night]"
|
||||
if not _world_time.set_authoritative_time(target_hour):
|
||||
return "Editor world time could not be changed."
|
||||
return "Editor time: %s (%s)." % [
|
||||
phase_name,
|
||||
_world_time.get_clock_text(),
|
||||
]
|
||||
|
||||
|
||||
func _apply_editor_weather_command(parts: PackedStringArray) -> String:
|
||||
if parts.size() != 2 or _world_weather == null:
|
||||
return "Usage: /weather [sunny, cloudy, rainy, foggy]"
|
||||
var weather_name: String = String(parts[1]).to_lower()
|
||||
var target_weather: WorldWeatherServiceType.Weather
|
||||
match weather_name:
|
||||
"clear", "sunny":
|
||||
weather_name = "sunny"
|
||||
target_weather = WorldWeatherServiceType.Weather.SUNNY
|
||||
"cloudy":
|
||||
target_weather = WorldWeatherServiceType.Weather.CLOUDY
|
||||
"rainy":
|
||||
target_weather = WorldWeatherServiceType.Weather.RAINY
|
||||
"foggy":
|
||||
target_weather = WorldWeatherServiceType.Weather.FOGGY
|
||||
_:
|
||||
return "Usage: /weather [sunny, cloudy, rainy, foggy]"
|
||||
if not _world_weather.set_authoritative_weather(target_weather):
|
||||
return "Editor world weather could not be changed."
|
||||
return "Editor weather: %s." % weather_name
|
||||
|
||||
|
||||
func _on_local_message_confirmed(message: Dictionary) -> void:
|
||||
if (
|
||||
not _send_pending
|
||||
|
|
|
|||
|
|
@ -168,6 +168,12 @@ func _on_pressed() -> void:
|
|||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
if mouse_event != null and mouse_event.pressed:
|
||||
# Pointer clicks give Buttons keyboard focus after their GUI callback.
|
||||
# Release that pointer-created focus on the next frame so a tooltip does
|
||||
# not remain pinned after the pointer leaves. Controller navigation still
|
||||
# keeps focus normally because it does not arrive as a mouse event.
|
||||
call_deferred("_release_pointer_focus")
|
||||
if (
|
||||
mouse_event != null
|
||||
and mouse_event.button_index == MOUSE_BUTTON_RIGHT
|
||||
|
|
@ -179,6 +185,11 @@ func _on_gui_input(event: InputEvent) -> void:
|
|||
accept_event()
|
||||
|
||||
|
||||
func _release_pointer_focus() -> void:
|
||||
if has_focus():
|
||||
release_focus()
|
||||
|
||||
|
||||
func _set_context_hovered(active: bool) -> void:
|
||||
_context_hovered = active
|
||||
_update_context_presence()
|
||||
|
|
|
|||
|
|
@ -627,6 +627,10 @@ func setup(
|
|||
_refresh_all()
|
||||
|
||||
|
||||
func set_world_interaction(interaction: ShopInteractionType) -> void:
|
||||
_interaction = interaction
|
||||
|
||||
|
||||
func open_shop() -> bool:
|
||||
if (
|
||||
visible
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ var _controller_text_entry_is_open: Callable
|
|||
|
||||
|
||||
func _ready() -> void:
|
||||
_prioritize_surface_drawing_pointer_input()
|
||||
_bite_prompt_button.pressed.connect(_on_bite_prompt_pressed)
|
||||
_apply_active_bait_indicator_style()
|
||||
_refresh_active_bait_indicator()
|
||||
|
|
@ -487,6 +488,37 @@ func setup(
|
|||
)
|
||||
|
||||
|
||||
func set_world_interactions(
|
||||
shop_interaction: ShopInteractionType,
|
||||
storage_interaction: PlayerStorageInteractionType,
|
||||
) -> void:
|
||||
_shop_interaction = shop_interaction
|
||||
_storage_interaction = storage_interaction
|
||||
_fishing_shop.set_world_interaction(shop_interaction)
|
||||
_player_storage.set_world_interaction(storage_interaction)
|
||||
|
||||
|
||||
func _prioritize_surface_drawing_pointer_input() -> void:
|
||||
# Control input follows sibling order rather than CanvasItem.z_index. Chat is
|
||||
# a full-screen Control with interactive mobile children, so the toolbar must
|
||||
# follow it in the tree to own their overlap while the art kit is active.
|
||||
# Opening Chat deactivates surface drawing and hides the toolbar, returning
|
||||
# the same area to Chat without any special-case pointer forwarding.
|
||||
if (
|
||||
_surface_drawing_toolbar == null
|
||||
or _chat_ui == null
|
||||
or _surface_drawing_toolbar.get_parent() != _chat_ui.get_parent()
|
||||
):
|
||||
return
|
||||
var ui_root: Node = _surface_drawing_toolbar.get_parent()
|
||||
var toolbar_index: int = _surface_drawing_toolbar.get_index()
|
||||
var chat_index: int = _chat_ui.get_index()
|
||||
if toolbar_index > chat_index:
|
||||
return
|
||||
# Removing the earlier toolbar shifts Chat one position toward the start.
|
||||
ui_root.move_child(_surface_drawing_toolbar, chat_index)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
# The on-screen keyboard owns controller input while it is open. Its
|
||||
# overlay is processed before the UI beneath it and consumes the event.
|
||||
|
|
@ -1264,6 +1296,7 @@ func _drawing_pointer_window_position() -> Vector2:
|
|||
|
||||
func setup_data_and_identity(
|
||||
data_root: PlayerDataRoot,
|
||||
progression_saves: PlayerSaveManager,
|
||||
identity_backups: IdentityBackupService,
|
||||
player_identity: PlayerIdentityStore,
|
||||
host_identity: HostIdentityStore,
|
||||
|
|
@ -1275,6 +1308,7 @@ func setup_data_and_identity(
|
|||
]:
|
||||
panel.setup_data_and_identity(
|
||||
data_root,
|
||||
progression_saves,
|
||||
identity_backups,
|
||||
player_identity,
|
||||
host_identity,
|
||||
|
|
@ -1603,9 +1637,14 @@ func _can_toggle_gameplay_hud() -> bool:
|
|||
|
||||
|
||||
func _refresh_gameplay_hud_visibility() -> void:
|
||||
var fishing_override_active: bool = (
|
||||
_gameplay_hud_hidden
|
||||
and _fishing_spot != null
|
||||
and _fishing_spot.is_fishing_sequence_active()
|
||||
)
|
||||
var show_world_hud: bool = (
|
||||
_gameplay_ui_enabled
|
||||
and not _gameplay_hud_hidden
|
||||
and (not _gameplay_hud_hidden or fishing_override_active)
|
||||
and not _system_menu_open
|
||||
and not _player_menu_open
|
||||
and not _shop_open
|
||||
|
|
@ -1660,6 +1699,7 @@ func set_storage_prompt_visible(
|
|||
_storage_prompt.visible = (
|
||||
requested_visible
|
||||
and _gameplay_ui_enabled
|
||||
and not _gameplay_hud_hidden
|
||||
and not _system_menu_open
|
||||
and not _player_menu_open
|
||||
and not _shop_open
|
||||
|
|
@ -1697,6 +1737,7 @@ func set_shop_prompt_visible(
|
|||
_shop_prompt.visible = (
|
||||
requested_visible
|
||||
and _gameplay_ui_enabled
|
||||
and not _gameplay_hud_hidden
|
||||
and not _system_menu_open
|
||||
and not _player_menu_open
|
||||
and not _shop_open
|
||||
|
|
@ -1923,6 +1964,7 @@ func _refresh_fishing_panel_visibility() -> void:
|
|||
_gameplay_ui_enabled
|
||||
and has_content
|
||||
)
|
||||
_refresh_gameplay_hud_visibility()
|
||||
|
||||
|
||||
func _on_bite_prompt_changed(prompt_visible: bool) -> void:
|
||||
|
|
|
|||
309
ui/new_game_setup_page.gd
Normal file
309
ui/new_game_setup_page.gd
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
class_name NewGameSetupPage
|
||||
extends Control
|
||||
|
||||
signal start_requested(world_layout: StringName, world_seed: int)
|
||||
signal back_requested
|
||||
|
||||
const WorldLayoutType = preload("res://world/world_layout.gd")
|
||||
const SaveManagerType = preload("res://save/player_save_manager.gd")
|
||||
const ControllerFocusNavigationType = preload(
|
||||
"res://ui/controller_focus_navigation.gd"
|
||||
)
|
||||
|
||||
enum SeedMode {
|
||||
RANDOM,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
@onready var _paper: PanelContainer = %Paper
|
||||
@onready var _generated_button: Button = %GeneratedButton
|
||||
@onready var _starter_button: Button = %StarterButton
|
||||
@onready var _world_description: Label = %WorldDescription
|
||||
@onready var _seed_section: VBoxContainer = %SeedSection
|
||||
@onready var _random_seed_button: Button = %RandomSeedButton
|
||||
@onready var _custom_seed_button: Button = %CustomSeedButton
|
||||
@onready var _seed_edit: LineEdit = %SeedEdit
|
||||
@onready var _seed_help: Label = %SeedHelp
|
||||
@onready var _overwrite_warning: Label = %OverwriteWarning
|
||||
@onready var _status: Label = %Status
|
||||
@onready var _start_button: Button = %StartButton
|
||||
@onready var _back_button: Button = %BackButton
|
||||
|
||||
var _world_layout: StringName = WorldLayoutType.GENERATED
|
||||
var _seed_mode: SeedMode = SeedMode.RANDOM
|
||||
var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
UtilityPageStyle.apply_page(self)
|
||||
_paper.add_theme_stylebox_override(
|
||||
"panel", UtilityPageStyle.panel_style()
|
||||
)
|
||||
for button: Button in [
|
||||
_generated_button,
|
||||
_starter_button,
|
||||
_random_seed_button,
|
||||
_custom_seed_button,
|
||||
_start_button,
|
||||
_back_button,
|
||||
]:
|
||||
UtilityPageStyle.apply_ocean_button(button)
|
||||
UtilityPageStyle.apply_ocean_line_edit(_seed_edit)
|
||||
_generated_button.pressed.connect(
|
||||
_set_world_layout.bind(WorldLayoutType.GENERATED)
|
||||
)
|
||||
_starter_button.pressed.connect(
|
||||
_set_world_layout.bind(WorldLayoutType.STARTER_ISLAND)
|
||||
)
|
||||
_random_seed_button.pressed.connect(_choose_random_seed)
|
||||
_custom_seed_button.pressed.connect(_choose_custom_seed)
|
||||
_seed_edit.text_changed.connect(_on_seed_text_changed)
|
||||
_seed_edit.text_submitted.connect(_on_seed_submitted)
|
||||
_start_button.pressed.connect(_request_start)
|
||||
_back_button.pressed.connect(back_requested.emit)
|
||||
hide()
|
||||
|
||||
|
||||
func open_page(has_existing_progression: bool) -> void:
|
||||
_world_layout = WorldLayoutType.GENERATED
|
||||
_seed_mode = SeedMode.RANDOM
|
||||
_roll_random_seed()
|
||||
_overwrite_warning.visible = has_existing_progression
|
||||
_status.text = ""
|
||||
_refresh_presentation()
|
||||
show()
|
||||
UtilityPageStyle.animate_in(self)
|
||||
_generated_button.grab_focus.call_deferred()
|
||||
|
||||
|
||||
func close_page() -> void:
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner != null and is_ancestor_of(focus_owner):
|
||||
focus_owner.release_focus()
|
||||
hide()
|
||||
|
||||
|
||||
func request_back() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
|
||||
func get_selected_world_layout() -> StringName:
|
||||
return _world_layout
|
||||
|
||||
|
||||
func get_selected_world_seed() -> int:
|
||||
return (
|
||||
_random_seed
|
||||
if _seed_mode == SeedMode.RANDOM
|
||||
else parse_seed_text(_seed_edit.text)
|
||||
)
|
||||
|
||||
|
||||
static func parse_seed_text(text: String) -> int:
|
||||
var normalized: String = text.strip_edges()
|
||||
if normalized.is_empty():
|
||||
return 0
|
||||
if normalized.is_valid_int():
|
||||
var numeric_seed: int = int(normalized)
|
||||
if (
|
||||
numeric_seed <= 0
|
||||
or numeric_seed > SaveManagerType.MAX_WORLD_SEED
|
||||
):
|
||||
return 0
|
||||
return numeric_seed
|
||||
var digest: PackedByteArray = normalized.sha256_buffer()
|
||||
var hashed_seed: int = (
|
||||
(int(digest[0]) << 24)
|
||||
| (int(digest[1]) << 16)
|
||||
| (int(digest[2]) << 8)
|
||||
| int(digest[3])
|
||||
) & 0x7fffffff
|
||||
return (hashed_seed % SaveManagerType.MAX_WORLD_SEED) + 1
|
||||
|
||||
|
||||
func _set_world_layout(layout: StringName) -> void:
|
||||
if not WorldLayoutType.is_valid(layout):
|
||||
return
|
||||
_world_layout = layout
|
||||
_status.text = ""
|
||||
_refresh_presentation()
|
||||
if layout == WorldLayoutType.GENERATED:
|
||||
_random_seed_button.grab_focus.call_deferred()
|
||||
else:
|
||||
_start_button.grab_focus.call_deferred()
|
||||
|
||||
|
||||
func _choose_random_seed() -> void:
|
||||
_seed_mode = SeedMode.RANDOM
|
||||
_roll_random_seed()
|
||||
_status.text = ""
|
||||
_refresh_presentation()
|
||||
|
||||
|
||||
func _choose_custom_seed() -> void:
|
||||
_seed_mode = SeedMode.CUSTOM
|
||||
if parse_seed_text(_seed_edit.text) == _random_seed:
|
||||
_seed_edit.text = ""
|
||||
_status.text = ""
|
||||
_refresh_presentation()
|
||||
_seed_edit.grab_focus.call_deferred()
|
||||
_seed_edit.select_all.call_deferred()
|
||||
|
||||
|
||||
func _roll_random_seed() -> void:
|
||||
_random_seed = SaveManagerType.roll_world_seed()
|
||||
_seed_edit.text = str(_random_seed)
|
||||
|
||||
|
||||
func _on_seed_text_changed(_text: String) -> void:
|
||||
if _seed_mode != SeedMode.CUSTOM:
|
||||
return
|
||||
_status.text = ""
|
||||
_refresh_start_state()
|
||||
|
||||
|
||||
func _on_seed_submitted(_text: String) -> void:
|
||||
_request_start()
|
||||
|
||||
|
||||
func _request_start() -> void:
|
||||
var seed: int = get_selected_world_seed()
|
||||
if _world_layout == WorldLayoutType.GENERATED and seed == 0:
|
||||
_status.text = (
|
||||
"enter some text or a whole number from 1 to %d."
|
||||
% SaveManagerType.MAX_WORLD_SEED
|
||||
)
|
||||
_seed_edit.grab_focus()
|
||||
_seed_edit.select_all()
|
||||
return
|
||||
if seed == 0:
|
||||
seed = SaveManagerType.roll_world_seed()
|
||||
_start_button.disabled = true
|
||||
start_requested.emit(_world_layout, seed)
|
||||
|
||||
|
||||
func _refresh_presentation() -> void:
|
||||
var generated: bool = _world_layout == WorldLayoutType.GENERATED
|
||||
_generated_button.set_pressed_no_signal(generated)
|
||||
_starter_button.set_pressed_no_signal(not generated)
|
||||
_world_description.text = (
|
||||
"build a new island from terrain chunks.\n"
|
||||
+ "the same seed always builds the same world."
|
||||
if generated
|
||||
else "play on the starter island."
|
||||
)
|
||||
_seed_section.visible = generated
|
||||
_random_seed_button.set_pressed_no_signal(
|
||||
_seed_mode == SeedMode.RANDOM
|
||||
)
|
||||
_custom_seed_button.set_pressed_no_signal(
|
||||
_seed_mode == SeedMode.CUSTOM
|
||||
)
|
||||
_seed_edit.editable = _seed_mode == SeedMode.CUSTOM
|
||||
_seed_edit.focus_mode = (
|
||||
Control.FOCUS_ALL
|
||||
if _seed_mode == SeedMode.CUSTOM
|
||||
else Control.FOCUS_NONE
|
||||
)
|
||||
_seed_help.text = (
|
||||
"press random seed again to roll another world."
|
||||
if _seed_mode == SeedMode.RANDOM
|
||||
else "enter text or a number from 1 to %d. the same entry makes the same world."
|
||||
% SaveManagerType.MAX_WORLD_SEED
|
||||
)
|
||||
_refresh_start_state()
|
||||
_configure_controller_focus()
|
||||
|
||||
|
||||
func _refresh_start_state() -> void:
|
||||
_start_button.disabled = (
|
||||
_world_layout == WorldLayoutType.GENERATED
|
||||
and _seed_mode == SeedMode.CUSTOM
|
||||
and parse_seed_text(_seed_edit.text) == 0
|
||||
)
|
||||
|
||||
|
||||
func _configure_controller_focus() -> void:
|
||||
var generated: bool = _world_layout == WorldLayoutType.GENERATED
|
||||
var custom: bool = generated and _seed_mode == SeedMode.CUSTOM
|
||||
_set_neighbors(
|
||||
_generated_button,
|
||||
_generated_button,
|
||||
_starter_button,
|
||||
_generated_button,
|
||||
_random_seed_button if generated else _start_button,
|
||||
)
|
||||
_set_neighbors(
|
||||
_starter_button,
|
||||
_generated_button,
|
||||
_starter_button,
|
||||
_starter_button,
|
||||
_custom_seed_button if generated else _back_button,
|
||||
)
|
||||
_set_neighbors(
|
||||
_random_seed_button,
|
||||
_random_seed_button,
|
||||
_custom_seed_button,
|
||||
_generated_button,
|
||||
_seed_edit if custom else _start_button,
|
||||
)
|
||||
_set_neighbors(
|
||||
_custom_seed_button,
|
||||
_random_seed_button,
|
||||
_custom_seed_button,
|
||||
_starter_button,
|
||||
_seed_edit if custom else _back_button,
|
||||
)
|
||||
_set_neighbors(
|
||||
_seed_edit,
|
||||
_seed_edit,
|
||||
_seed_edit,
|
||||
_custom_seed_button,
|
||||
_start_button,
|
||||
)
|
||||
var action_top: Control = (
|
||||
_seed_edit
|
||||
if custom
|
||||
else _random_seed_button
|
||||
if generated
|
||||
else _generated_button
|
||||
)
|
||||
_set_neighbors(
|
||||
_start_button,
|
||||
_start_button,
|
||||
_back_button,
|
||||
action_top,
|
||||
_start_button,
|
||||
)
|
||||
_set_neighbors(
|
||||
_back_button,
|
||||
_start_button,
|
||||
_back_button,
|
||||
action_top,
|
||||
_back_button,
|
||||
)
|
||||
var traversal: Array[Control] = [
|
||||
_generated_button,
|
||||
_starter_button,
|
||||
]
|
||||
if generated:
|
||||
traversal.append(_random_seed_button)
|
||||
traversal.append(_custom_seed_button)
|
||||
if custom:
|
||||
traversal.append(_seed_edit)
|
||||
traversal.append(_start_button)
|
||||
traversal.append(_back_button)
|
||||
ControllerFocusNavigationType.configure_traversal(traversal)
|
||||
|
||||
|
||||
func _set_neighbors(
|
||||
control: Control,
|
||||
left: Control,
|
||||
right: Control,
|
||||
top: Control,
|
||||
bottom: Control,
|
||||
) -> void:
|
||||
control.focus_neighbor_left = control.get_path_to(left)
|
||||
control.focus_neighbor_right = control.get_path_to(right)
|
||||
control.focus_neighbor_top = control.get_path_to(top)
|
||||
control.focus_neighbor_bottom = control.get_path_to(bottom)
|
||||
1
ui/new_game_setup_page.gd.uid
Normal file
1
ui/new_game_setup_page.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d3iueu4gj6bwo
|
||||
170
ui/new_game_setup_page.tscn
Normal file
170
ui/new_game_setup_page.tscn
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/new_game_setup_page.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
|
||||
|
||||
[node name="NewGameSetupPage" type="Control"]
|
||||
visible = false
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme = ExtResource("2_theme")
|
||||
script = ExtResource("1_script")
|
||||
|
||||
[node name="Paper" type="PanelContainer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
anchor_top = 0.5
|
||||
anchor_right = 0.5
|
||||
anchor_bottom = 0.5
|
||||
offset_left = -360.0
|
||||
offset_top = -280.0
|
||||
offset_right = 360.0
|
||||
offset_bottom = 280.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="Paper"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/margin_left = 46
|
||||
theme_override_constants/margin_top = 30
|
||||
theme_override_constants/margin_right = 46
|
||||
theme_override_constants/margin_bottom = 30
|
||||
|
||||
[node name="Layout" type="VBoxContainer" parent="Paper/Margin"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="Title" type="Label" parent="Paper/Margin/Layout"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
|
||||
theme_override_font_sizes/font_size = 32
|
||||
text = "new game"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Prompt" type="Label" parent="Paper/Margin/Layout"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
|
||||
theme_override_font_sizes/font_size = 17
|
||||
text = "choose where you want to begin"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="WorldButtons" type="HBoxContainer" parent="Paper/Margin/Layout"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
alignment = 1
|
||||
|
||||
[node name="GeneratedButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(230, 58)
|
||||
layout_mode = 2
|
||||
toggle_mode = true
|
||||
text = "generate a world"
|
||||
|
||||
[node name="StarterButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(230, 58)
|
||||
layout_mode = 2
|
||||
toggle_mode = true
|
||||
text = "starter island"
|
||||
|
||||
[node name="WorldDescription" type="Label" parent="Paper/Margin/Layout"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 46)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
|
||||
theme_override_font_sizes/font_size = 16
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="SeedSection" type="VBoxContainer" parent="Paper/Margin/Layout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="SeedLabel" type="Label" parent="Paper/Margin/Layout/SeedSection"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
|
||||
theme_override_font_sizes/font_size = 20
|
||||
text = "world seed"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="SeedModes" type="HBoxContainer" parent="Paper/Margin/Layout/SeedSection"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
alignment = 1
|
||||
|
||||
[node name="RandomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(190, 48)
|
||||
layout_mode = 2
|
||||
toggle_mode = true
|
||||
text = "random seed"
|
||||
|
||||
[node name="CustomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(190, 48)
|
||||
layout_mode = 2
|
||||
toggle_mode = true
|
||||
text = "enter a seed"
|
||||
|
||||
[node name="SeedEdit" type="LineEdit" parent="Paper/Margin/Layout/SeedSection"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 46)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 20
|
||||
placeholder_text = "number or words"
|
||||
alignment = 1
|
||||
max_length = 64
|
||||
|
||||
[node name="SeedHelp" type="Label" parent="Paper/Margin/Layout/SeedSection"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 28)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="OverwriteWarning" type="Label" parent="Paper/Margin/Layout"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 0.78, 0.62, 1)
|
||||
theme_override_font_sizes/font_size = 15
|
||||
text = "starting a new game replaces your existing progression."
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Status" type="Label" parent="Paper/Margin/Layout"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 28)
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(1, 0.7, 0.65, 1)
|
||||
theme_override_font_sizes/font_size = 14
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Spacer" type="Control" parent="Paper/Margin/Layout"]
|
||||
custom_minimum_size = Vector2(0, 4)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 14
|
||||
alignment = 1
|
||||
|
||||
[node name="StartButton" type="Button" parent="Paper/Margin/Layout/Actions"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(190, 54)
|
||||
layout_mode = 2
|
||||
text = "start new game"
|
||||
|
||||
[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(150, 54)
|
||||
layout_mode = 2
|
||||
text = "back"
|
||||
|
|
@ -430,7 +430,8 @@ func _show_native_keyboard_for(control: Control) -> void:
|
|||
|
||||
|
||||
func _hide_native_keyboard() -> void:
|
||||
DisplayServer.virtual_keyboard_hide()
|
||||
if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD):
|
||||
DisplayServer.virtual_keyboard_hide()
|
||||
|
||||
|
||||
static func _line_edit_keyboard_type(
|
||||
|
|
|
|||
|
|
@ -1693,6 +1693,7 @@ func close_menu(
|
|||
_release_controller_ownership(false, true)
|
||||
get_viewport().gui_cancel_drag()
|
||||
_close_sale_confirmation()
|
||||
_hide_inventory_context_tooltip()
|
||||
if reason in [
|
||||
CloseReason.BITE_STARTED,
|
||||
CloseReason.WATER_RECOVERY,
|
||||
|
|
@ -2497,6 +2498,8 @@ func _populate_tackle_column(
|
|||
owned.quantity,
|
||||
item.max_stack,
|
||||
&"QuantityBadge",
|
||||
UtilityPageStyle.SUPPLY_BADGE_EDGE_MARGIN,
|
||||
0.0,
|
||||
)
|
||||
button.pressed.connect(_select_tackle_item.bind(owned.item_id))
|
||||
button.gui_input.connect(
|
||||
|
|
@ -2540,6 +2543,8 @@ func _on_tackle_button_gui_input(
|
|||
button: Button,
|
||||
) -> void:
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
if mouse_event != null and mouse_event.pressed:
|
||||
call_deferred("_release_pointer_focus", button)
|
||||
if (
|
||||
mouse_event == null
|
||||
or mouse_event.button_index != MOUSE_BUTTON_RIGHT
|
||||
|
|
@ -2555,6 +2560,11 @@ func _on_tackle_button_gui_input(
|
|||
button.accept_event()
|
||||
|
||||
|
||||
func _release_pointer_focus(control: Control) -> void:
|
||||
if control != null and is_instance_valid(control) and control.has_focus():
|
||||
control.release_focus()
|
||||
|
||||
|
||||
func _configure_tackle_item_focus() -> void:
|
||||
if not is_node_ready():
|
||||
return
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ func setup(
|
|||
_refresh()
|
||||
|
||||
|
||||
func set_world_interaction(interaction: PlayerStorageInteraction) -> void:
|
||||
_interaction = interaction
|
||||
|
||||
|
||||
func open_storage() -> bool:
|
||||
if (
|
||||
visible
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ var _environment_volume: float = 1.0
|
|||
var _network_profile: NetworkProfilePreferences
|
||||
var _network_session: NetworkSession
|
||||
var _data_root: PlayerDataRoot
|
||||
var _progression_saves: PlayerSaveManager
|
||||
var _identity_backups: IdentityBackupService
|
||||
var _player_identity: PlayerIdentityStore
|
||||
var _host_identity: HostIdentityStore
|
||||
|
|
@ -134,6 +135,8 @@ var _controller_mapping_panel: ControllerMappingPanelType
|
|||
var _keyboard_mouse_mapping_manager: KeyboardMouseMappingManagerType
|
||||
var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType
|
||||
var _data_folder_dialog: FileDialog
|
||||
var _progression_import_dialog: FileDialog
|
||||
var _progression_export_dialog: FileDialog
|
||||
var _backup_file_dialog: FileDialog
|
||||
var _export_file_dialog: FileDialog
|
||||
var _passphrase_dialog: ConfirmationDialog
|
||||
|
|
@ -143,6 +146,7 @@ var _pending_identity_operation := ""
|
|||
var _pending_identity_type := ""
|
||||
var _pending_identity_path := ""
|
||||
var _pending_import_data: Dictionary = {}
|
||||
var _pending_progression_path := ""
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
|
|
@ -244,6 +248,8 @@ func _connect_controls() -> void:
|
|||
%KeyboardMapping.pressed.connect(_open_keyboard_mouse_mapping)
|
||||
%OpenDataFolder.pressed.connect(_open_data_folder)
|
||||
%ChangeDataFolder.pressed.connect(_choose_data_folder)
|
||||
%ExportProgression.pressed.connect(_choose_progression_export)
|
||||
%ImportProgression.pressed.connect(_choose_progression_import)
|
||||
%ExportPlayerIdentity.pressed.connect(
|
||||
_choose_identity_export.bind("player")
|
||||
)
|
||||
|
|
@ -337,6 +343,7 @@ func setup_keyboard_mouse_mapping(
|
|||
|
||||
func setup_data_and_identity(
|
||||
data_root: PlayerDataRoot,
|
||||
progression_saves: PlayerSaveManager,
|
||||
identity_backups: IdentityBackupService,
|
||||
player_identity: PlayerIdentityStore,
|
||||
host_identity: HostIdentityStore,
|
||||
|
|
@ -344,6 +351,7 @@ func setup_data_and_identity(
|
|||
interface_fonts: InterfaceFontController,
|
||||
) -> void:
|
||||
_data_root = data_root
|
||||
_progression_saves = progression_saves
|
||||
_identity_backups = identity_backups
|
||||
_player_identity = player_identity
|
||||
_host_identity = host_identity
|
||||
|
|
@ -611,6 +619,11 @@ func _refresh_data_page() -> void:
|
|||
_data_root.override_active
|
||||
or (_network_session != null and _network_session.is_session_active())
|
||||
)
|
||||
%ExportProgression.disabled = _progression_saves == null
|
||||
%ImportProgression.disabled = (
|
||||
_progression_saves == null
|
||||
or (_network_session != null and _network_session.is_session_active())
|
||||
)
|
||||
var fingerprint: String = (
|
||||
_player_identity.fingerprint if _player_identity != null else ""
|
||||
)
|
||||
|
|
@ -661,6 +674,122 @@ func _copy_player_fingerprint() -> void:
|
|||
_feedback.text = "full player fingerprint copied for server operator setup."
|
||||
|
||||
|
||||
func _choose_progression_export() -> void:
|
||||
if _progression_saves == null or _data_root == null:
|
||||
_feedback.text = "progression export is unavailable."
|
||||
return
|
||||
var timestamp: String = Time.get_datetime_string_from_system().replace(
|
||||
":", "-"
|
||||
)
|
||||
var suggested: String = _data_root.progression_backup_directory().path_join(
|
||||
"NETfishing-progression-%s%s"
|
||||
% [timestamp, PlayerSaveManager.ARCHIVE_EXTENSION]
|
||||
)
|
||||
if _progression_export_dialog == null:
|
||||
_progression_export_dialog = FileDialog.new()
|
||||
_progression_export_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
|
||||
_progression_export_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||
_progression_export_dialog.use_native_dialog = false
|
||||
_progression_export_dialog.filters = PackedStringArray([
|
||||
"*.nfsave ; NETfishing progression archive",
|
||||
])
|
||||
_progression_export_dialog.file_selected.connect(
|
||||
_progression_export_file_selected
|
||||
)
|
||||
_interface_fonts.apply_utility_theme(_progression_export_dialog)
|
||||
add_child(_progression_export_dialog)
|
||||
_progression_export_dialog.current_dir = suggested.get_base_dir()
|
||||
_progression_export_dialog.current_file = suggested.get_file()
|
||||
_interface_fonts.popup_file_dialog(_progression_export_dialog)
|
||||
|
||||
|
||||
func _progression_export_file_selected(path: String) -> void:
|
||||
var destination: String = (
|
||||
path
|
||||
if path.ends_with(PlayerSaveManager.ARCHIVE_EXTENSION)
|
||||
else path + PlayerSaveManager.ARCHIVE_EXTENSION
|
||||
)
|
||||
var result: Dictionary = _progression_saves.export_progression_archive(
|
||||
destination
|
||||
)
|
||||
_feedback.text = str(
|
||||
result.get("message", "progression export failed.")
|
||||
)
|
||||
|
||||
|
||||
func _choose_progression_import() -> void:
|
||||
if _progression_saves == null or _data_root == null:
|
||||
_feedback.text = "progression import is unavailable."
|
||||
return
|
||||
if _network_session != null and _network_session.is_session_active():
|
||||
_feedback.text = "return to title before importing progression."
|
||||
return
|
||||
if _progression_import_dialog == null:
|
||||
_progression_import_dialog = FileDialog.new()
|
||||
_progression_import_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||
_progression_import_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||
_progression_import_dialog.use_native_dialog = false
|
||||
_progression_import_dialog.filters = PackedStringArray([
|
||||
"*.nfsave ; NETfishing progression archive",
|
||||
])
|
||||
_progression_import_dialog.file_selected.connect(
|
||||
_progression_import_file_selected
|
||||
)
|
||||
_interface_fonts.apply_utility_theme(_progression_import_dialog)
|
||||
add_child(_progression_import_dialog)
|
||||
_progression_import_dialog.current_dir = (
|
||||
_data_root.progression_backup_directory()
|
||||
)
|
||||
_interface_fonts.popup_file_dialog(_progression_import_dialog)
|
||||
|
||||
|
||||
func _progression_import_file_selected(path: String) -> void:
|
||||
var inspected: Dictionary = (
|
||||
_progression_saves.inspect_progression_archive(path)
|
||||
)
|
||||
if not bool(inspected.get("ok", false)):
|
||||
_feedback.text = str(
|
||||
inspected.get("message", "progression archive could not be opened.")
|
||||
)
|
||||
return
|
||||
_pending_progression_path = path
|
||||
var dialog := ConfirmationDialog.new()
|
||||
dialog.title = "replace saved progression?"
|
||||
dialog.ok_button_text = "import progression"
|
||||
dialog.dialog_text = (
|
||||
"this will replace the current progression after making a backup.\n\n"
|
||||
+ "fish: %d\ndiscovered: %d\nworld: %s\nworld seed: %d\n\n"
|
||||
+ "identities, settings, friends, bans, and trusted servers are unchanged."
|
||||
) % [
|
||||
int(inspected.get("catch_count", 0)),
|
||||
int(inspected.get("discovered_species_count", 0)),
|
||||
WorldLayout.display_name(inspected.get(
|
||||
"world_layout",
|
||||
String(WorldLayout.GENERATED),
|
||||
)),
|
||||
int(inspected.get("world_seed", 0)),
|
||||
]
|
||||
dialog.confirmed.connect(_confirm_progression_import.bind(dialog))
|
||||
dialog.canceled.connect(dialog.queue_free)
|
||||
_interface_fonts.apply_utility_theme(dialog)
|
||||
add_child(dialog)
|
||||
dialog.popup_centered(Vector2i(640, 390))
|
||||
_configure_confirmation_dialog.call_deferred(
|
||||
dialog, dialog.get_cancel_button()
|
||||
)
|
||||
|
||||
|
||||
func _confirm_progression_import(dialog: ConfirmationDialog) -> void:
|
||||
var result: Dictionary = _progression_saves.import_progression_archive(
|
||||
_pending_progression_path
|
||||
)
|
||||
_feedback.text = str(
|
||||
result.get("message", "progression import failed.")
|
||||
)
|
||||
_pending_progression_path = ""
|
||||
dialog.queue_free()
|
||||
|
||||
|
||||
func _choose_data_folder() -> void:
|
||||
if _data_root == null or _data_root.override_active:
|
||||
_feedback.text = "the data folder is externally managed."
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ size_flags_horizontal = 3
|
|||
focus_mode = 2
|
||||
focus_neighbor_right = NodePath("../ChangeDataFolder")
|
||||
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
|
||||
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
|
||||
focus_neighbor_bottom = NodePath("../../ProgressionRow/ExportProgression")
|
||||
text = "open data folder"
|
||||
|
||||
[node name="ChangeDataFolder" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/FolderRow"]
|
||||
|
|
@ -578,15 +578,41 @@ size_flags_horizontal = 3
|
|||
focus_mode = 2
|
||||
focus_neighbor_left = NodePath("../OpenDataFolder")
|
||||
focus_neighbor_top = NodePath("../../../../../../../../TabBar/DataTab")
|
||||
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
|
||||
focus_neighbor_bottom = NodePath("../../ProgressionRow/ImportProgression")
|
||||
text = "change data folder"
|
||||
|
||||
[node name="ProgressionRow" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="ExportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
focus_mode = 2
|
||||
focus_neighbor_right = NodePath("../ImportProgression")
|
||||
focus_neighbor_top = NodePath("../../FolderRow/OpenDataFolder")
|
||||
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
|
||||
text = "export progression"
|
||||
|
||||
[node name="ImportProgression" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons/ProgressionRow"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
focus_mode = 2
|
||||
focus_neighbor_left = NodePath("../ExportProgression")
|
||||
focus_neighbor_top = NodePath("../../FolderRow/ChangeDataFolder")
|
||||
focus_neighbor_bottom = NodePath("../../CopyPlayerFingerprint")
|
||||
text = "import progression"
|
||||
|
||||
[node name="CopyPlayerFingerprint" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/PageStack/DataPage/DataScroll/DataButtons"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 44)
|
||||
layout_mode = 2
|
||||
focus_mode = 2
|
||||
focus_neighbor_top = NodePath("../FolderRow/OpenDataFolder")
|
||||
focus_neighbor_top = NodePath("../ProgressionRow/ExportProgression")
|
||||
focus_neighbor_bottom = NodePath("../PlayerIdentityRow/ExportPlayerIdentity")
|
||||
text = "copy player fingerprint"
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const TitleCreditsPageType = preload("res://ui/title_credits_page.gd")
|
|||
const NetworkSessionType = preload("res://network/network_session.gd")
|
||||
const SavedServerStoreType = preload("res://network/saved_server_store.gd")
|
||||
const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
|
||||
const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd")
|
||||
const CurrencyPresentationType = preload(
|
||||
"res://ui/currency_presentation.gd"
|
||||
)
|
||||
|
|
@ -94,14 +95,13 @@ const INTRO_BRANDING_TRAVEL_DURATION: float = 2.0
|
|||
const INTRO_BRANDING_START_DELAY: float = 0.35
|
||||
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
|
||||
|
||||
signal new_game_requested(world_seed: int)
|
||||
signal new_game_requested(world_layout: StringName, world_seed: int)
|
||||
signal continue_game_requested
|
||||
signal quit_requested
|
||||
signal join_game_requested(endpoint: String)
|
||||
|
||||
enum ConfirmationAction {
|
||||
NONE,
|
||||
NEW_GAME,
|
||||
DELETE_SAVE,
|
||||
}
|
||||
|
||||
|
|
@ -142,12 +142,12 @@ enum ConfirmationAction {
|
|||
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter
|
||||
@onready var _start_prompt_label: Label = %StartPromptLabel
|
||||
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
|
||||
@onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage
|
||||
@onready var _credits_page: TitleCreditsPageType = %CreditsPage
|
||||
|
||||
var _save_manager: SaveManagerType
|
||||
var _settings_manager: SettingsManagerType
|
||||
var _inspection: SaveInspectionType
|
||||
var _pending_new_game_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
|
||||
var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE
|
||||
var _action_in_progress: bool = false
|
||||
var _decorative_rng := RandomNumberGenerator.new()
|
||||
|
|
@ -210,6 +210,8 @@ func _ready() -> void:
|
|||
)
|
||||
_new_game_button.pressed.connect(_on_new_game_pressed)
|
||||
_join_game_button.pressed.connect(_open_join_game)
|
||||
_new_game_setup_page.start_requested.connect(_start_configured_new_game)
|
||||
_new_game_setup_page.back_requested.connect(_close_new_game_setup)
|
||||
_settings_button.pressed.connect(_open_settings)
|
||||
_credits_button.pressed.connect(_open_credits)
|
||||
_delete_button.pressed.connect(_on_delete_pressed)
|
||||
|
|
@ -283,6 +285,7 @@ func reopen() -> void:
|
|||
_reset_confirmation()
|
||||
_settings_panel.hide()
|
||||
_join_game_page.close_page()
|
||||
_new_game_setup_page.close_page()
|
||||
_credits_page.close_page()
|
||||
_refresh_save_inspection()
|
||||
show()
|
||||
|
|
@ -311,6 +314,7 @@ func open_join_game_page(endpoint: String = "") -> void:
|
|||
_settings_panel.hide()
|
||||
_confirmation_page.hide_page()
|
||||
_credits_page.close_page()
|
||||
_new_game_setup_page.close_page()
|
||||
_join_game_page.open_page(endpoint)
|
||||
|
||||
|
||||
|
|
@ -326,6 +330,7 @@ func _open_join_game() -> void:
|
|||
if (
|
||||
_action_in_progress
|
||||
or _is_confirmation_active()
|
||||
or _new_game_setup_page.visible
|
||||
or _credits_page.visible
|
||||
):
|
||||
return
|
||||
|
|
@ -346,6 +351,7 @@ func _open_credits() -> void:
|
|||
or _is_confirmation_active()
|
||||
or _settings_panel.visible
|
||||
or _join_game_page.visible
|
||||
or _new_game_setup_page.visible
|
||||
or _credits_page.visible
|
||||
):
|
||||
return
|
||||
|
|
@ -583,6 +589,11 @@ func _input(event: InputEvent) -> void:
|
|||
_join_game_page.request_back()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if _new_game_setup_page.visible:
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_new_game_setup_page.request_back()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if (
|
||||
_title_settings_transition_active
|
||||
or _title_entry_transition_active
|
||||
|
|
@ -629,6 +640,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
|
|||
not _button_center.visible
|
||||
or _is_confirmation_active()
|
||||
or _settings_panel.visible
|
||||
or _new_game_setup_page.visible
|
||||
or _credits_page.visible
|
||||
):
|
||||
return false
|
||||
|
|
@ -1031,19 +1043,59 @@ func _on_new_game_pressed() -> void:
|
|||
"the existing save cannot be accessed safely."
|
||||
)
|
||||
return
|
||||
_pending_new_game_seed = PlayerSaveManager.roll_world_seed()
|
||||
var warning := "start a new world?"
|
||||
if _inspection.status != SaveInspectionType.Status.MISSING:
|
||||
warning += " existing progression will be deleted."
|
||||
warning += "\nworld seed: %d" % _pending_new_game_seed
|
||||
_open_confirmation(
|
||||
ConfirmationAction.NEW_GAME,
|
||||
warning,
|
||||
"start\nnew game",
|
||||
true
|
||||
_open_new_game_setup(
|
||||
_inspection.status != SaveInspectionType.Status.MISSING
|
||||
)
|
||||
|
||||
|
||||
func _open_new_game_setup(has_existing_progression: bool) -> void:
|
||||
_modal_restore_navigation_focus = _navigation_focus_active
|
||||
_set_title_bubbles_interactive(false)
|
||||
_release_primary_menu_focus()
|
||||
_presentation_center.hide()
|
||||
_start_prompt_center.hide()
|
||||
_join_game_page.close_page()
|
||||
_credits_page.close_page()
|
||||
_new_game_setup_page.open_page(has_existing_progression)
|
||||
|
||||
|
||||
func _close_new_game_setup() -> void:
|
||||
_new_game_setup_page.close_page()
|
||||
_presentation_center.show()
|
||||
_button_center.show()
|
||||
_start_prompt_center.hide()
|
||||
_set_title_bubbles_interactive(true)
|
||||
var restore_navigation_focus: bool = _modal_restore_navigation_focus
|
||||
_modal_restore_navigation_focus = false
|
||||
if restore_navigation_focus:
|
||||
_navigation_focus_active = true
|
||||
_new_game_button.grab_focus()
|
||||
else:
|
||||
_navigation_focus_active = false
|
||||
_release_title_focus()
|
||||
_update_continue_stats_visibility()
|
||||
|
||||
|
||||
func _start_configured_new_game(
|
||||
world_layout: StringName,
|
||||
world_seed: int,
|
||||
) -> void:
|
||||
if _action_in_progress or not _new_game_setup_page.visible:
|
||||
return
|
||||
_new_game_setup_page.close_page()
|
||||
_action_in_progress = true
|
||||
new_game_requested.emit(world_layout, world_seed)
|
||||
_action_in_progress = false
|
||||
# Signal delivery is synchronous. A successful start hides this title screen;
|
||||
# if it remains visible, restore the menu so a generation error is recoverable.
|
||||
if visible:
|
||||
_presentation_center.show()
|
||||
_button_center.show()
|
||||
_set_title_bubbles_interactive(true)
|
||||
_navigation_focus_active = true
|
||||
_new_game_button.grab_focus()
|
||||
|
||||
|
||||
func _on_delete_pressed() -> void:
|
||||
if (
|
||||
_action_in_progress
|
||||
|
|
@ -1071,14 +1123,6 @@ func _on_confirmation_accepted() -> void:
|
|||
var action: ConfirmationAction = _confirmation_action
|
||||
_confirmation_page.lock_interaction()
|
||||
_action_in_progress = true
|
||||
if action == ConfirmationAction.NEW_GAME:
|
||||
_confirmation_action = ConfirmationAction.NONE
|
||||
_confirmation_transition_generation += 1
|
||||
_cancel_confirmation_transition()
|
||||
_confirmation_page.hide_page()
|
||||
new_game_requested.emit(_pending_new_game_seed)
|
||||
_action_in_progress = false
|
||||
return
|
||||
if not _save_manager.delete_progression_save():
|
||||
_feedback_label.text = _center_feedback_text(
|
||||
"failed to delete saved progression."
|
||||
|
|
@ -1295,7 +1339,7 @@ func _on_settings_applied() -> void:
|
|||
|
||||
|
||||
func _on_settings_closed() -> void:
|
||||
pass
|
||||
_refresh_save_inspection()
|
||||
|
||||
|
||||
func _on_settings_closing() -> void:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
[gd_scene load_steps=23 format=3]
|
||||
[gd_scene load_steps=24 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"]
|
||||
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/new_game.png" id="17_new_game"]
|
||||
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/credits.png" id="18_credits"]
|
||||
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/delete_save.png" id="19_delete_save"]
|
||||
[ext_resource type="PackedScene" path="res://ui/new_game_setup_page.tscn" id="20_new_game_setup"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
|
||||
shader = ExtResource("4_water_shader")
|
||||
|
|
@ -225,7 +226,7 @@ unique_name_in_owner = true
|
|||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "v0.13.1-alpha"
|
||||
text = "v0.14.0-alpha"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
|
||||
|
|
@ -486,3 +487,13 @@ anchor_right = 1.0
|
|||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="NewGameSetupPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("20_new_game_setup")]
|
||||
unique_name_in_owner = true
|
||||
z_index = 220
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ static func add_supply_quantity_badge(
|
|||
total: int,
|
||||
badge_name: StringName = &"SupplyQuantityBadge",
|
||||
top_margin: float = SUPPLY_BADGE_EDGE_MARGIN,
|
||||
x_offset: float = SUPPLY_BADGE_X_OFFSET,
|
||||
) -> Panel:
|
||||
var badge := Panel.new()
|
||||
badge.name = badge_name
|
||||
|
|
@ -68,7 +69,7 @@ static func add_supply_quantity_badge(
|
|||
quantity_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
badge.add_child(quantity_label)
|
||||
configure_supply_quantity_badge(
|
||||
badge, quantity_label, current, total, top_margin
|
||||
badge, quantity_label, current, total, top_margin, x_offset
|
||||
)
|
||||
return badge
|
||||
|
||||
|
|
@ -79,6 +80,7 @@ static func configure_supply_quantity_badge(
|
|||
current: int,
|
||||
total: int,
|
||||
top_margin: float = SUPPLY_BADGE_EDGE_MARGIN,
|
||||
x_offset: float = SUPPLY_BADGE_X_OFFSET,
|
||||
) -> void:
|
||||
var supply_text := supply_quantity_text(current, total)
|
||||
var text_width: float = TuffyFont.get_string_size(
|
||||
|
|
@ -92,10 +94,10 @@ static func configure_supply_quantity_badge(
|
|||
ceilf(text_width + SUPPLY_BADGE_HORIZONTAL_PADDING),
|
||||
)
|
||||
badge.offset_left = (
|
||||
-badge_width - SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET
|
||||
-badge_width - SUPPLY_BADGE_EDGE_MARGIN + x_offset
|
||||
)
|
||||
badge.offset_top = top_margin
|
||||
badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + SUPPLY_BADGE_X_OFFSET
|
||||
badge.offset_right = -SUPPLY_BADGE_EDGE_MARGIN + x_offset
|
||||
badge.offset_bottom = top_margin + SUPPLY_BADGE_HEIGHT
|
||||
badge.pivot_offset = Vector2(
|
||||
badge_width * 0.5,
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@ script = ExtResource("1_definition")
|
|||
stable_id = &"biome_coast"
|
||||
label = "coast"
|
||||
required_chunk_tags = PackedStringArray("sand")
|
||||
forbidden_chunk_tags = PackedStringArray("mixed_surface")
|
||||
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_sand_tree")])
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
[sub_resource type="Resource" id="Rule_grass_tree"]
|
||||
script = ExtResource("2_rule")
|
||||
procedural_group = &"grass_tree"
|
||||
placement_chance = 9000
|
||||
adjacency_bonus = 1000
|
||||
maximum_density = 10000
|
||||
placement_chance = 8200
|
||||
adjacency_bonus = 1200
|
||||
maximum_density = 8500
|
||||
minimum_placements = 2
|
||||
placement_attempts_per_chunk = 3
|
||||
allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3")
|
||||
|
||||
[sub_resource type="Resource" id="Rule_grass_detail"]
|
||||
|
|
@ -26,6 +27,6 @@ script = ExtResource("1_definition")
|
|||
stable_id = &"biome_forest"
|
||||
label = "forest"
|
||||
required_chunk_tags = PackedStringArray("grass")
|
||||
forbidden_chunk_tags = PackedStringArray("spawn")
|
||||
forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface")
|
||||
region_weight = 1.4
|
||||
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")])
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@
|
|||
[sub_resource type="Resource" id="Rule_grass_tree"]
|
||||
script = ExtResource("2_rule")
|
||||
procedural_group = &"grass_tree"
|
||||
placement_chance = 9000
|
||||
adjacency_bonus = 1000
|
||||
maximum_density = 10000
|
||||
placement_chance = 8200
|
||||
adjacency_bonus = 1200
|
||||
maximum_density = 8500
|
||||
minimum_placements = 2
|
||||
placement_attempts_per_chunk = 3
|
||||
allowed_prop_ids = PackedStringArray("prop_pine")
|
||||
|
||||
[sub_resource type="Resource" id="Rule_grass_detail"]
|
||||
|
|
@ -26,6 +27,6 @@ script = ExtResource("1_definition")
|
|||
stable_id = &"biome_pine_forest"
|
||||
label = "pine forest"
|
||||
required_chunk_tags = PackedStringArray("grass")
|
||||
forbidden_chunk_tags = PackedStringArray("spawn")
|
||||
forbidden_chunk_tags = PackedStringArray("spawn", "mixed_surface")
|
||||
region_weight = 1.2
|
||||
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")])
|
||||
|
|
|
|||
|
|
@ -24,5 +24,6 @@ script = ExtResource("1_definition")
|
|||
stable_id = &"biome_plains"
|
||||
label = "plains"
|
||||
required_chunk_tags = PackedStringArray("grass")
|
||||
forbidden_chunk_tags = PackedStringArray("mixed_surface")
|
||||
region_weight = 1.0
|
||||
prop_rules = Array[ExtResource("2_rule")]([SubResource("Rule_grass_tree"), SubResource("Rule_grass_detail")])
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ compress/rdo_quality_loss=0.0
|
|||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
|
|
|
|||
BIN
world/generation/chunks/assets/chunk_0008.glb
Normal file
BIN
world/generation/chunks/assets/chunk_0008.glb
Normal file
Binary file not shown.
45
world/generation/chunks/assets/chunk_0008.glb.import
Normal file
45
world/generation/chunks/assets/chunk_0008.glb.import
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bm1teitx5ijxl"
|
||||
path="res://.godot/imported/chunk_0008.glb-0b0a6712e5384c3f72c65e6ef1fc12c4.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://world/generation/chunks/assets/chunk_0008.glb"
|
||||
dest_files=["res://.godot/imported/chunk_0008.glb-0b0a6712e5384c3f72c65e6ef1fc12c4.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
BIN
world/generation/chunks/assets/chunk_0008_sand.png
Normal file
BIN
world/generation/chunks/assets/chunk_0008_sand.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
45
world/generation/chunks/assets/chunk_0008_sand.png.import
Normal file
45
world/generation/chunks/assets/chunk_0008_sand.png.import
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dj24j2d1u8712"
|
||||
path.s3tc="res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "adfa66b8fe9da7f3450d3ddfd432b86a"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://world/generation/chunks/assets/chunk_0008_sand.png"
|
||||
dest_files=["res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.s3tc.ctex", "res://.godot/imported/chunk_0008_sand.png-55d7cf4f540d9cfb7d4ba59e9f9f38da.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
BIN
world/generation/chunks/assets/chunk_0009.glb
Normal file
BIN
world/generation/chunks/assets/chunk_0009.glb
Normal file
Binary file not shown.
45
world/generation/chunks/assets/chunk_0009.glb.import
Normal file
45
world/generation/chunks/assets/chunk_0009.glb.import
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://hasempwso5gi"
|
||||
path="res://.godot/imported/chunk_0009.glb-397f642bee3da14146c56fddf8e21369.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://world/generation/chunks/assets/chunk_0009.glb"
|
||||
dest_files=["res://.godot/imported/chunk_0009.glb-397f642bee3da14146c56fddf8e21369.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
BIN
world/generation/chunks/assets/chunk_0009_grass_lite.png
Normal file
BIN
world/generation/chunks/assets/chunk_0009_grass_lite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
|
|
@ -0,0 +1,45 @@
|
|||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://djjyaoih5vcnd"
|
||||
path.s3tc="res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.s3tc.ctex"
|
||||
path.etc2="res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.etc2.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://world/generation/chunks/assets/chunk_0009_grass_lite.png"
|
||||
dest_files=["res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.s3tc.ctex", "res://.godot/imported/chunk_0009_grass_lite.png-5d49eb465b8a8f8739796ce13c646f7d.etc2.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue