From 1406a0abc75542dc02514bd714bcad3e9bb8d275 Mon Sep 17 00:00:00 2001 From: Voyager Date: Sun, 2 Aug 2026 00:45:07 -0400 Subject: [PATCH] Add Art Kit painting upgrades --- drawing/surface_drawing_placement.gd | 18 +- drawing/surface_drawing_protocol.gd | 36 +- economy/art_shop_stock.gd | 64 +++ economy/art_shop_stock.gd.uid | 1 + fishing/fishing_spot.gd | 9 + items/catalog/art_kit.tres | 17 + items/catalog/item_catalog.tres | 5 +- items/icons/placeholder/art_kit.svg | 7 + items/icons/placeholder/art_kit.svg.import | 43 ++ main/main.gd | 14 +- network/network_item_use_service.gd | 10 +- network/network_protocol.gd | 4 +- network/network_session.gd | 1 + network/network_shop_protocol.gd | 3 + network/network_shop_service.gd | 134 ++++++- network/network_surface_drawing_service.gd | 378 ++++++++++++++++-- player/player.gd | 10 + player/player.tscn | 19 +- progression/player_art_unlocks.gd | 173 ++++++++ progression/player_art_unlocks.gd.uid | 1 + save/player_save_manager.gd | 28 ++ tests/art_tools_validation.gd | 97 +++++ tests/art_tools_validation.gd.uid | 1 + tests/economy_regression_validation.gd | 61 +++ .../surface_drawing_multiplayer_validation.gd | 6 + tests/surface_drawing_runtime_validation.gd | 12 +- tests/surface_drawing_validation.gd | 43 +- ui/fishing_shop.gd | 122 +++++- ui/fishing_shop.tscn | 2 +- ui/game_ui.gd | 61 ++- ui/game_ui.tscn | 62 +-- ui/surface_drawing_toolbar.gd | 195 +++++++++ ui/surface_drawing_toolbar.gd.uid | 1 + ui/surface_drawing_toolbar.tscn | 57 +++ 34 files changed, 1530 insertions(+), 165 deletions(-) create mode 100644 economy/art_shop_stock.gd create mode 100644 economy/art_shop_stock.gd.uid create mode 100644 items/catalog/art_kit.tres create mode 100644 items/icons/placeholder/art_kit.svg create mode 100644 items/icons/placeholder/art_kit.svg.import create mode 100644 progression/player_art_unlocks.gd create mode 100644 progression/player_art_unlocks.gd.uid create mode 100644 tests/art_tools_validation.gd create mode 100644 tests/art_tools_validation.gd.uid create mode 100644 ui/surface_drawing_toolbar.gd create mode 100644 ui/surface_drawing_toolbar.gd.uid create mode 100644 ui/surface_drawing_toolbar.tscn diff --git a/drawing/surface_drawing_placement.gd b/drawing/surface_drawing_placement.gd index 01eaaa5..bfa1abc 100644 --- a/drawing/surface_drawing_placement.gd +++ b/drawing/surface_drawing_placement.gd @@ -11,6 +11,7 @@ static func resolve( normal: Vector3, fallback_tangent: Vector3, canvas_states: Array[Dictionary], + requested_grid_size: int = SurfaceDrawingProtocol.DEFAULT_GRID_SIZE, ) -> Dictionary: var surface_normal: Vector3 = normal.normalized() var surface_tangent: Vector3 = _projected_tangent( @@ -46,16 +47,25 @@ static func resolve( ).normalized() var width: float = float(state["width"]) * float(state["cell_size"]) var height: float = float(state["height"]) * float(state["cell_size"]) + var requested_extent: float = ( + float(requested_grid_size) * SurfaceDrawingProtocol.CELL_SIZE + ) if width <= 0.0 or height <= 0.0: continue - var horizontal_step: int = roundi(relative.dot(anchor_tangent) / width) - var vertical_step: int = roundi(relative.dot(anchor_bitangent) / height) + var horizontal_spacing: float = (width + requested_extent) * 0.5 + var vertical_spacing: float = (height + requested_extent) * 0.5 + var horizontal_step: int = roundi( + relative.dot(anchor_tangent) / horizontal_spacing + ) + var vertical_step: int = roundi( + relative.dot(anchor_bitangent) / vertical_spacing + ) if horizontal_step == 0 and vertical_step == 0: continue var candidate: Vector3 = ( anchor_origin - + anchor_tangent * float(horizontal_step) * width - + anchor_bitangent * float(vertical_step) * height + + anchor_tangent * float(horizontal_step) * horizontal_spacing + + anchor_bitangent * float(vertical_step) * vertical_spacing ) var distance: float = candidate.distance_to(origin) if distance > nearest_distance: diff --git a/drawing/surface_drawing_protocol.gd b/drawing/surface_drawing_protocol.gd index 3add969..5ca4ae0 100644 --- a/drawing/surface_drawing_protocol.gd +++ b/drawing/surface_drawing_protocol.gd @@ -1,13 +1,18 @@ class_name SurfaceDrawingProtocol extends RefCounted -const CAPABILITY: StringName = &"surface_drawing_v1" +const CAPABILITY: StringName = &"surface_drawing_v2" const RELIABLE_CHANNEL: int = NetworkProtocol.ITEM_RELIABLE_CHANNEL -const GRID_WIDTH: int = 32 -const GRID_HEIGHT: int = 32 +const GRID_SIZES: Array[int] = [16, 32, 64, 128] +const DEFAULT_GRID_SIZE: int = 16 +const MAX_GRID_SIZE: int = 128 +# Compatibility aliases for callers that only need the default dimensions. +const GRID_WIDTH: int = DEFAULT_GRID_SIZE +const GRID_HEIGHT: int = DEFAULT_GRID_SIZE const CELL_SIZE: float = 0.075 const MAX_ACTIVE_CANVASES: int = 24 const MAX_CANVASES: int = 48 +const MAX_SESSION_GRID_CELLS: int = 49152 const MAX_EDITS_PER_REQUEST: int = 16 const MAX_CANVAS_ID_LENGTH: int = 64 const MAX_REQUEST_ID_LENGTH: int = 64 @@ -26,9 +31,9 @@ static func validate_canvas_request(data: Variant) -> bool: and _valid_vector(value.get("normal")) and _valid_vector(value.get("tangent")) and typeof(value.get("width")) == TYPE_INT - and int(value["width"]) == GRID_WIDTH + and int(value["width"]) in GRID_SIZES and typeof(value.get("height")) == TYPE_INT - and int(value["height"]) == GRID_HEIGHT + and int(value["height"]) == int(value["width"]) and typeof(value.get("cell_size")) in [TYPE_FLOAT, TYPE_INT] and is_equal_approx(float(value["cell_size"]), CELL_SIZE) ) @@ -44,6 +49,9 @@ static func validate_edit_request(data: Variant) -> bool: or str(value["canvas_id"]).is_empty() or str(value["canvas_id"]).length() > MAX_CANVAS_ID_LENGTH or not _valid_stroke_id(value.get("stroke_id")) + or typeof(value.get("brush_size")) != TYPE_INT + or int(value["brush_size"]) < 1 + or int(value["brush_size"]) > 4 or typeof(value.get("edits")) != TYPE_ARRAY ): return false @@ -90,9 +98,9 @@ static func validate_canvas_state(data: Variant) -> bool: or not _valid_vector(value.get("normal")) or not _valid_vector(value.get("tangent")) or typeof(value.get("width")) != TYPE_INT - or int(value["width"]) != GRID_WIDTH + or int(value["width"]) not in GRID_SIZES or typeof(value.get("height")) != TYPE_INT - or int(value["height"]) != GRID_HEIGHT + or int(value["height"]) != int(value["width"]) or typeof(value.get("cell_size")) not in [TYPE_FLOAT, TYPE_INT] or not is_equal_approx(float(value["cell_size"]), CELL_SIZE) or typeof(value.get("revision")) != TYPE_INT @@ -108,10 +116,16 @@ static func validate_canvas_state(data: Variant) -> bool: ): return false var cells: Array = value["cells"] - if cells.size() > GRID_WIDTH * GRID_HEIGHT: + var grid_width: int = int(value["width"]) + var grid_height: int = int(value["height"]) + if cells.size() > grid_width * grid_height: return false for cell_value: Variant in cells: - if not validate_authoritative_cell(cell_value): + if ( + not validate_authoritative_cell(cell_value) + or int((cell_value as Dictionary)["x"]) >= grid_width + or int((cell_value as Dictionary)["y"]) >= grid_height + ): return false return true @@ -164,10 +178,10 @@ static func validate_cell_edit(data: Variant) -> bool: if ( typeof(value.get("x")) != TYPE_INT or int(value["x"]) < 0 - or int(value["x"]) >= GRID_WIDTH + or int(value["x"]) >= MAX_GRID_SIZE or typeof(value.get("y")) != TYPE_INT or int(value["y"]) < 0 - or int(value["y"]) >= GRID_HEIGHT + or int(value["y"]) >= MAX_GRID_SIZE or typeof(value.get("color_id")) not in [TYPE_STRING, TYPE_STRING_NAME] ): return false diff --git a/economy/art_shop_stock.gd b/economy/art_shop_stock.gd new file mode 100644 index 0000000..6da6a4c --- /dev/null +++ b/economy/art_shop_stock.gd @@ -0,0 +1,64 @@ +class_name ArtShopStock +extends RefCounted + +const ART_KIT_ITEM_ID: StringName = &"art_kit" +const ART_KIT_PRICE: int = 1000 +const UPGRADE_PRICE: int = 50 + +const MARKER_PRODUCTS: Array[StringName] = [ + &"marker_ocean_teal", + &"marker_coral", + &"marker_sunny", + &"marker_leaf", + &"marker_blue", + &"marker_violet", + &"marker_charcoal", +] +const BRUSH_PRODUCTS: Array[StringName] = [ + &"brush_2x", + &"brush_3x", + &"brush_4x", +] +const GRID_PRODUCTS: Array[StringName] = [ + &"grid_32x", + &"grid_64x", + &"grid_128x", +] + + +static func get_price(product_id: StringName) -> int: + if product_id == ART_KIT_ITEM_ID: + return ART_KIT_PRICE + return UPGRADE_PRICE if PlayerArtUnlocks.is_product_id(product_id) else -1 + + +static func get_display_name(product_id: StringName) -> String: + if product_id == ART_KIT_ITEM_ID: + return "Art Kit" + var color_id: StringName = PlayerArtUnlocks.color_id_for_product(product_id) + if not color_id.is_empty(): + return "%s marker" % SurfaceDrawingPalette.get_display_name(color_id) + var brush_size: int = PlayerArtUnlocks.brush_size_for_product(product_id) + if brush_size > 0: + return "%d× brush" % brush_size + var grid_size: int = PlayerArtUnlocks.grid_size_for_product(product_id) + if grid_size > 0: + return "%d×%d grid" % [grid_size, grid_size] + return "Unknown art supply" + + +static func get_description(product_id: StringName) -> String: + if product_id == ART_KIT_ITEM_ID: + return "Press P in the game world to paint!" + var color_id: StringName = PlayerArtUnlocks.color_id_for_product(product_id) + if not color_id.is_empty(): + return "Unlocks this marker color in the Paint UI." + var brush_size: int = PlayerArtUnlocks.brush_size_for_product(product_id) + if brush_size > 0: + return "Unlocks the %d× brush in the Paint UI." % brush_size + var grid_size: int = PlayerArtUnlocks.grid_size_for_product(product_id) + if grid_size > 0: + return "Unlocks the %d×%d grid in the Paint UI." % [ + grid_size, grid_size, + ] + return "" diff --git a/economy/art_shop_stock.gd.uid b/economy/art_shop_stock.gd.uid new file mode 100644 index 0000000..3358b54 --- /dev/null +++ b/economy/art_shop_stock.gd.uid @@ -0,0 +1 @@ +uid://cqagg27unh12c diff --git a/fishing/fishing_spot.gd b/fishing/fishing_spot.gd index 3a628d7..bdeec55 100644 --- a/fishing/fishing_spot.gd +++ b/fishing/fishing_spot.gd @@ -36,6 +36,7 @@ const FishingSurfaceSampleType = preload( const FishingSurfaceResolverType = preload( "res://fishing/fishing_surface_resolver.gd" ) +const ArtShopStockType = preload("res://economy/art_shop_stock.gd") signal status_changed(status: String) signal catch_display_changed( @@ -56,6 +57,7 @@ signal showcase_changed( signal bite_activated signal ready_for_equipment_refresh signal fish_showcase_toggle_requested +signal art_ui_toggle_requested enum FishingState { READY, @@ -467,6 +469,13 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return var active_item: ItemDataType = _get_active_item() + if ( + active_item != null + and active_item.item_id == ArtShopStockType.ART_KIT_ITEM_ID + ): + art_ui_toggle_requested.emit() + get_viewport().set_input_as_handled() + return if ( active_item != null and active_item.category == ItemDataType.Category.CONSUMABLE diff --git a/items/catalog/art_kit.tres b/items/catalog/art_kit.tres new file mode 100644 index 0000000..3bbc0f6 --- /dev/null +++ b/items/catalog/art_kit.tres @@ -0,0 +1,17 @@ +[gd_resource type="Resource" script_class="ItemData" load_steps=3 format=3] + +[ext_resource type="Script" path="res://items/item_data.gd" id="1_item"] +[ext_resource type="Texture2D" path="res://items/icons/placeholder/art_kit.svg" id="2_icon"] + +[resource] +script = ExtResource("1_item") +item_id = &"art_kit" +display_name = "Art Kit" +description = "Press P in the game world to paint!" +category = 1 +icon = ExtResource("2_icon") +stackable = false +max_stack = 1 +usable = false +equippable = true +hotbar_allowed = true diff --git a/items/catalog/item_catalog.tres b/items/catalog/item_catalog.tres index 3cccb42..20f4bd1 100644 --- a/items/catalog/item_catalog.tres +++ b/items/catalog/item_catalog.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="ItemCatalog" load_steps=8 format=3] +[gd_resource type="Resource" script_class="ItemCatalog" load_steps=9 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"] @@ -7,7 +7,8 @@ [ext_resource type="Resource" path="res://items/catalog/snack.tres" id="5_snack"] [ext_resource type="Resource" path="res://items/catalog/fish_finder.tres" id="6_finder"] [ext_resource type="Resource" path="res://items/catalog/cooler_expansion.tres" id="7_cooler"] +[ext_resource type="Resource" path="res://items/catalog/art_kit.tres" id="8_art_kit"] [resource] script = ExtResource("1_script") -items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("7_cooler")] +items = [ExtResource("2_rod"), ExtResource("3_coffee"), ExtResource("4_energy"), ExtResource("5_snack"), ExtResource("6_finder"), ExtResource("7_cooler"), ExtResource("8_art_kit")] diff --git a/items/icons/placeholder/art_kit.svg b/items/icons/placeholder/art_kit.svg new file mode 100644 index 0000000..bd89210 --- /dev/null +++ b/items/icons/placeholder/art_kit.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/items/icons/placeholder/art_kit.svg.import b/items/icons/placeholder/art_kit.svg.import new file mode 100644 index 0000000..d28daa6 --- /dev/null +++ b/items/icons/placeholder/art_kit.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bm0kkevv62mu0" +path="res://.godot/imported/art_kit.svg-d4bd09e1da2e2339f7d505ee23d771f8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://items/icons/placeholder/art_kit.svg" +dest_files=["res://.godot/imported/art_kit.svg-d4bd09e1da2e2339f7d505ee23d771f8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/main/main.gd b/main/main.gd index c4a5832..677240b 100644 --- a/main/main.gd +++ b/main/main.gd @@ -19,6 +19,7 @@ const PlayerSettingsType = preload("res://settings/player_settings.gd") const TitleScreenType = preload("res://ui/title_screen.gd") const PauseMenuType = preload("res://ui/pause_menu.gd") const ItemCatalogType = preload("res://items/item_catalog.gd") +const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ItemDataType = preload("res://items/item_data.gd") const FishingShopType = preload("res://ui/fishing_shop.gd") const FishingShopInteractionType = preload( @@ -323,7 +324,8 @@ func _initialize_after_data_root() -> void: _player.hotbar, item_catalog, _player.fishing_upgrades, - _player.cooler_capacity + _player.cooler_capacity, + _player.art_unlocks, ) _save_manager.set_autosave_enabled(false) _asset_reservations.setup( @@ -364,6 +366,8 @@ func _initialize_after_data_root() -> void: _relationships, _player, _surface_drawings_root, + _player.bag, + _player.art_unlocks, ) _network_player_list.set_surface_drawing_service( _network_surface_drawing @@ -408,6 +412,7 @@ func _initialize_after_data_root() -> void: item_catalog, _player.fishing_upgrades, _player.cooler_capacity, + _player.art_unlocks, _save_manager, _asset_reservations ) @@ -454,6 +459,7 @@ func _initialize_after_data_root() -> void: _network_player_list, _settings_manager, _network_surface_drawing, + _player.art_unlocks, ) _game_ui.setup_data_and_identity( _data_root, @@ -1339,6 +1345,12 @@ func _on_active_hotbar_item_changed( and _player.bag.owns_item(item_id) ) _player.set_active_item_is_rod(active_is_rod) + _player.set_active_art_kit( + item.icon if item != null else null, + item_id == ArtShopStockType.ART_KIT_ITEM_ID + and item != null + and _player.bag.owns_item(item_id), + ) _network_item_use.submit_local_equipped(item_id, active_is_rod or ( item != null and _player.bag.owns_item(item_id) )) diff --git a/network/network_item_use_service.gd b/network/network_item_use_service.gd index 5fe231e..654b45b 100644 --- a/network/network_item_use_service.gd +++ b/network/network_item_use_service.gd @@ -1,6 +1,8 @@ class_name NetworkItemUseService extends Node +const ArtShopStockType = preload("res://economy/art_shop_stock.gd") + const MAX_LEDGER_ENTRIES: int = 64 signal local_item_use_pending(request_id: String) @@ -324,8 +326,12 @@ func _apply_equipped(data: Dictionary) -> void: var peer_id: int = data["owner_peer_id"] var avatar := _spawn_service.get_avatar(peer_id) if avatar != null: - avatar.set_active_item_is_rod( - int(data["category"]) == ItemData.Category.ROD + var item_id := StringName(str(data["item_id"])) + var item: ItemData = _catalog.get_item_by_id(item_id) + avatar.set_active_item_is_rod(int(data["category"]) == ItemData.Category.ROD) + avatar.set_active_art_kit( + item.icon if item != null else null, + item_id == ArtShopStockType.ART_KIT_ITEM_ID and bool(data["owns_item"]), ) equipped_state_changed.emit( peer_id, StringName(str(data["item_id"])), int(data["category"]) diff --git a/network/network_protocol.gd b/network/network_protocol.gd index e0f124c..1e60145 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -18,7 +18,8 @@ const ITEM_RELIABLE_CHANNEL: int = 7 const CHAT_RELIABLE_CHANNEL: int = 8 const MAIL_RELIABLE_CHANNEL: int = 9 const ENET_CHANNEL_COUNT: int = 10 -const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v1" +const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2" +const ART_SHOP_CAPABILITY: String = "art_shop_v1" enum RejectionCode { NONE, @@ -198,6 +199,7 @@ static func make_server_hello( "fishing_v1", "sale_v1", "shop_v1", + ART_SHOP_CAPABILITY, "item_use_v1", "equipment_v1", "fish_showcase_v1", diff --git a/network/network_session.gd b/network/network_session.gd index 4e5e389..4ccb371 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -361,6 +361,7 @@ func supports_server_capability(capability: StringName) -> bool: if is_host(): return str(capability) in PackedStringArray([ "movement_v1", "fishing_v1", "sale_v1", "shop_v1", + NetworkProtocol.ART_SHOP_CAPABILITY, "item_use_v1", "equipment_v1", "fish_showcase_v1", NetworkProtocol.SURFACE_DRAWING_CAPABILITY, "chat_v1", diff --git a/network/network_shop_protocol.gd b/network/network_shop_protocol.gd index a545b31..65a0395 100644 --- a/network/network_shop_protocol.gd +++ b/network/network_shop_protocol.gd @@ -2,6 +2,7 @@ class_name NetworkShopProtocol extends RefCounted const CAPABILITY: StringName = &"shop_v1" +const ART_CAPABILITY: StringName = &"art_shop_v1" const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL const MAX_ID_LENGTH: int = 96 const MAX_MESSAGE_LENGTH: int = 160 @@ -13,6 +14,8 @@ enum ProductCategory { REEL_SPEED_UPGRADE, BARRIER_POWER_UPGRADE, COOLER_CAPACITY_UPGRADE, + ART_KIT, + ART_UPGRADE, } diff --git a/network/network_shop_service.gd b/network/network_shop_service.gd index bdfbca2..cffcee5 100644 --- a/network/network_shop_service.gd +++ b/network/network_shop_service.gd @@ -6,6 +6,7 @@ const FishingShopStockType = preload( ) const ItemDataType = preload("res://items/item_data.gd") const OwnedItemType = preload("res://items/owned_item.gd") +const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const SHOP_ID: StringName = &"main_fishing_shop" const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade" @@ -33,6 +34,7 @@ var _bag: PlayerBag var _item_catalog: ItemCatalog var _upgrades: PlayerFishingUpgrades var _cooler_capacity: PlayerCoolerCapacity +var _art_unlocks: PlayerArtUnlocks var _save_manager: PlayerSaveManager var _request_ledgers: Dictionary[int, Dictionary] = {} var _pending_by_peer: Dictionary[int, String] = {} @@ -54,6 +56,7 @@ func setup( item_catalog: ItemCatalog, upgrades: PlayerFishingUpgrades, cooler_capacity: PlayerCoolerCapacity, + art_unlocks: PlayerArtUnlocks, save_manager: PlayerSaveManager, reservations: PlayerAssetReservationService, ) -> void: @@ -66,6 +69,7 @@ func setup( _item_catalog = item_catalog _upgrades = upgrades _cooler_capacity = cooler_capacity + _art_unlocks = art_unlocks _save_manager = save_manager _reservations = reservations if not _session.peer_removed.is_connected(_on_peer_removed): @@ -87,6 +91,18 @@ func can_request_purchase() -> bool: ) +func can_request_art_purchase() -> bool: + return ( + can_request_purchase() + and ( + _session.is_host() + or _session.supports_server_capability( + NetworkShopProtocol.ART_CAPABILITY + ) + ) + ) + + func is_local_purchase_pending() -> bool: return not _pending_local_request.is_empty() @@ -127,6 +143,39 @@ func request_cooler_capacity_upgrade() -> String: ) +func request_art_kit() -> String: + return _request_purchase( + ArtShopStockType.ART_KIT_ITEM_ID, + NetworkShopProtocol.ProductCategory.ART_KIT, + 1, + _bag.get_quantity(ArtShopStockType.ART_KIT_ITEM_ID) + if _bag != null else 0, + ) + + +func request_art_upgrade(product_id: StringName) -> String: + if ( + _bag == null + or not _bag.owns_item(ArtShopStockType.ART_KIT_ITEM_ID) + ): + local_purchase_finished.emit( + "", + false, + "Own an Art Kit before buying upgrades.", + product_id, + NetworkShopProtocol.ProductCategory.ART_UPGRADE, + 0, + 0, + ) + return "" + return _request_purchase( + product_id, + NetworkShopProtocol.ProductCategory.ART_UPGRADE, + 1, + _art_unlocks.get_unlock_mask() if _art_unlocks != null else 0, + ) + + func _request_purchase( product_id: StringName, category: int, @@ -149,6 +198,18 @@ func _request_purchase( 0 ) return "" + if ( + category in [ + NetworkShopProtocol.ProductCategory.ART_KIT, + NetworkShopProtocol.ProductCategory.ART_UPGRADE, + ] + and not can_request_art_purchase() + ): + local_purchase_finished.emit( + "", false, "Art supplies require a newer server.", + product_id, category, 0, 0, + ) + return "" var request_id: String = _new_id("shop") var request: Dictionary = { "request_id": request_id, @@ -316,6 +377,43 @@ func _build_authoritative_result( ] ) resulting_state = current_state + 1 + NetworkShopProtocol.ProductCategory.ART_KIT: + var art_item: ItemDataType = _item_catalog.get_item_by_id( + product_id + ) if _item_catalog != null else null + cost = ArtShopStockType.get_price(product_id) + if ( + product_id != ArtShopStockType.ART_KIT_ITEM_ID + or art_item == null + or not art_item.is_valid() + or art_item.category != ItemDataType.Category.TOOL + or art_item.stackable + or not art_item.equippable + or not art_item.hotbar_allowed + or current_state != 0 + ): + rejection = ( + "Art Kit already owned." + if current_state > 0 + else "Purchase could not be completed." + ) + else: + resulting_state = 1 + NetworkShopProtocol.ProductCategory.ART_UPGRADE: + cost = ArtShopStockType.get_price(product_id) + if ( + not PlayerArtUnlocks.is_product_id(product_id) + or current_state < 0 + or (current_state & ~PlayerArtUnlocks.ALL_UNLOCK_MASK) != 0 + or PlayerArtUnlocks.resulting_mask( + current_state, product_id + ) == current_state + ): + rejection = "Upgrade is already unlocked." + else: + resulting_state = PlayerArtUnlocks.resulting_mask( + current_state, product_id + ) if rejection.is_empty() and (cost < 0 or wallet_balance < cost): rejection = "Not enough fish coin." if not rejection.is_empty(): @@ -430,11 +528,13 @@ func _apply_purchase_result(data: Dictionary) -> void: var reel_snapshot: int = _upgrades.get_reel_speed_level() var barrier_snapshot: int = _upgrades.get_barrier_power_level() var cooler_snapshot: int = _cooler_capacity.get_level() + var art_snapshot: int = _art_unlocks.get_unlock_mask() var applied: bool = _apply_local_product(data) if not applied or not _save_manager.save_if_dirty(): _bag.replace_all_items(bag_snapshot) _upgrades.restore_levels(reel_snapshot, barrier_snapshot) _cooler_capacity.restore_level(cooler_snapshot) + _art_unlocks.restore_mask(art_snapshot) _wallet.restore_balance(wallet_snapshot) _save_manager.save_if_dirty() _fail_local_apply(data, "Purchase could not be completed.") @@ -453,6 +553,7 @@ func _validate_local_result(data: Dictionary) -> String: or _bag == null or _upgrades == null or _cooler_capacity == null + or _art_unlocks == null or _save_manager == null or _wallet.get_balance() != int(data["expected_wallet"]) or str(data["product_id"]) @@ -472,7 +573,11 @@ func _validate_local_result(data: Dictionary) -> String: and _reservations.get_available_fish_coin() < cost ): return "Reserved in a letter." - if int(data["resulting_state"]) != expected_state + 1: + var resulting_state: int = int(data["resulting_state"]) + if ( + category != NetworkShopProtocol.ProductCategory.ART_UPGRADE + and resulting_state != expected_state + 1 + ): return "Purchase could not be completed." if not _wallet.can_afford(cost): return "Not enough fish coin." @@ -497,6 +602,24 @@ func _validate_local_result(data: Dictionary) -> String: return "Purchase could not be completed." if _cooler_capacity.get_next_cost() != cost: return "Purchase could not be completed." + NetworkShopProtocol.ProductCategory.ART_KIT: + if ( + product_id != ArtShopStockType.ART_KIT_ITEM_ID + or _bag.get_quantity(product_id) != expected_state + or not _bag.can_add_item(product_id, 1) + or cost != ArtShopStockType.ART_KIT_PRICE + ): + return "Purchase could not be completed." + NetworkShopProtocol.ProductCategory.ART_UPGRADE: + if ( + _art_unlocks.get_unlock_mask() != expected_state + or not PlayerArtUnlocks.is_product_id(product_id) + or resulting_state != PlayerArtUnlocks.resulting_mask( + expected_state, product_id + ) + or cost != ArtShopStockType.UPGRADE_PRICE + ): + return "Purchase could not be completed." _: return "Purchase could not be completed." return "" @@ -517,6 +640,15 @@ func _apply_local_product(data: Dictionary) -> bool: return _upgrades.purchase_barrier_power(_wallet) NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE: return _cooler_capacity.purchase(_wallet) + NetworkShopProtocol.ProductCategory.ART_KIT: + return ( + _wallet.debit(int(data["total_cost"])) + and _bag.add_item(product_id, 1) + ) + NetworkShopProtocol.ProductCategory.ART_UPGRADE: + return _art_unlocks.purchase_product( + product_id, _wallet, int(data["total_cost"]) + ) return false diff --git a/network/network_surface_drawing_service.gd b/network/network_surface_drawing_service.gd index c080062..0cf33d0 100644 --- a/network/network_surface_drawing_service.gd +++ b/network/network_surface_drawing_service.gd @@ -4,6 +4,7 @@ extends Node const BrushHighlightShader: Shader = preload( "res://drawing/surface_drawing_highlight.gdshader" ) +const ArtShopStockType = preload("res://economy/art_shop_stock.gd") signal hud_state_changed( is_active: bool, @@ -11,6 +12,7 @@ signal hud_state_changed( color_name: String, color_value: Color, brush_size: int, + grid_size: int, status: String, ) signal session_artwork_changed(canvas_count: int, painted_cell_count: int) @@ -30,6 +32,8 @@ var _session: NetworkSession var _spawn_service: PlayerSpawnService var _relationships: PlayerRelationshipStore var _local_player: Player +var _bag: PlayerBag +var _art_unlocks: PlayerArtUnlocks var _drawing_root: Node3D var _canvas_states: Dictionary[String, Dictionary] = {} var _canvas_nodes: Dictionary[String, SurfaceDrawingCanvas] = {} @@ -42,6 +46,7 @@ var _placement_preview_material: StandardMaterial3D var _active: bool = false var _placing_grid: bool = false var _brush_size: int = 1 +var _grid_size: int = SurfaceDrawingProtocol.DEFAULT_GRID_SIZE var _color_ids: Array[StringName] = [] var _color_index: int = 0 var _painting: bool = false @@ -60,6 +65,7 @@ var _peer_request_times: Dictionary[int, PackedInt64Array] = {} var _peer_request_ids: Dictionary[int, PackedStringArray] = {} var _stroke_history_by_peer: Dictionary[int, Dictionary] = {} var _cell_last_stroke: Dictionary[String, String] = {} +var _peer_art_entitlements: Dictionary[int, Dictionary] = {} func setup( @@ -68,13 +74,17 @@ func setup( relationships: PlayerRelationshipStore, local_player: Player, drawing_root: Node3D, + bag: PlayerBag, + art_unlocks: PlayerArtUnlocks, ) -> void: _session = session _spawn_service = spawn_service _relationships = relationships _local_player = local_player _drawing_root = drawing_root - _color_ids = SurfaceDrawingPalette.get_color_ids() + _bag = bag + _art_unlocks = art_unlocks + _refresh_local_unlocks() if _session != null: _session.state_changed.connect(_on_session_state_changed) _session.peer_removed.connect(_on_peer_removed) @@ -82,6 +92,14 @@ func setup( _relationships.relationship_changed.connect( _on_relationship_changed ) + if _bag != null and not _bag.contents_changed.is_connected( + _on_local_art_entitlement_changed + ): + _bag.contents_changed.connect(_on_local_art_entitlement_changed) + if _art_unlocks != null and not _art_unlocks.unlocks_changed.is_connected( + _on_local_art_unlocks_changed + ): + _art_unlocks.unlocks_changed.connect(_on_local_art_unlocks_changed) _create_brush_preview() _create_placement_preview() set_process(true) @@ -99,7 +117,7 @@ func handle_input(event: InputEvent, can_open: bool) -> bool: if _active: deactivate() return true - if can_open and _drawing_available(): + if can_open and can_activate(): activate() return true return false @@ -180,7 +198,7 @@ func handle_input(event: InputEvent, can_open: bool) -> bool: if _camera_look_active: return false _pointer_screen_position = _clamped_pointer_position( - _pointer_screen_position + motion_event.screen_relative + motion_event.position ) _update_aim() return true @@ -188,18 +206,20 @@ func handle_input(event: InputEvent, can_open: bool) -> bool: func activate() -> void: - if _active or not _drawing_available(): + if _active or not can_activate(): return _active = true - _placing_grid = false + _placing_grid = true + _refresh_local_unlocks() + _rebuild_placement_preview() _reset_stroke() _pointer_screen_position = get_viewport().get_visible_rect().size * 0.5 _prior_mouse_mode = Input.mouse_mode - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _update_aim() _refresh_stencil_visibility() _emit_hud_state( - "r place grid • click draw • shift erase • ctrl z undo • shift scroll zoom" + "click to place a shared grid" ) @@ -226,6 +246,55 @@ func is_placement_mode() -> bool: return _placing_grid +func can_activate() -> bool: + return _drawing_available() and _owns_art_kit() + + +func set_placement_mode(enabled: bool) -> void: + _set_placement_mode(enabled) + + +func set_color_id(color_id: StringName) -> bool: + var color_index: int = _color_ids.find(color_id) + if color_index < 0: + return false + _color_index = color_index + _reset_stroke() + _emit_hud_state("") + return true + + +func set_brush_size(value: int) -> bool: + if _art_unlocks == null or not _art_unlocks.is_brush_size_unlocked(value): + return false + _set_brush_size(value) + return true + + +func set_grid_size(value: int) -> bool: + if _art_unlocks == null or not _art_unlocks.is_grid_size_unlocked(value): + return false + if _grid_size == value: + return true + _grid_size = value + _rebuild_placement_preview() + _update_previews() + _emit_hud_state("") + return true + + +func get_brush_size() -> int: + return _brush_size + + +func get_grid_size() -> int: + return _grid_size + + +func get_color_id() -> StringName: + return _current_color_id() + + func get_pointer_screen_position() -> Vector2: return _pointer_screen_position @@ -236,12 +305,166 @@ func set_unlocked_color_ids(unlocked_ids: Array[StringName]) -> void: _emit_hud_state("") +func _refresh_local_unlocks() -> void: + if _art_unlocks == null: + _color_ids = [SurfaceDrawingPalette.DEFAULT_COLOR_ID] + return + var previous_color: StringName = _current_color_id() + _color_ids = _art_unlocks.get_unlocked_color_ids() + var previous_index: int = _color_ids.find(previous_color) + _color_index = previous_index if previous_index >= 0 else 0 + if not _art_unlocks.is_brush_size_unlocked(_brush_size): + _brush_size = PlayerArtUnlocks.BASE_BRUSH_SIZE + if not _art_unlocks.is_grid_size_unlocked(_grid_size): + _grid_size = PlayerArtUnlocks.BASE_GRID_SIZE + + +func _owns_art_kit() -> bool: + return _bag != null and _bag.owns_item(ArtShopStockType.ART_KIT_ITEM_ID) + + +func _local_entitlement() -> Dictionary: + return { + "has_kit": _owns_art_kit(), + "unlock_mask": ( + _art_unlocks.get_unlock_mask() if _art_unlocks != null else 0 + ), + } + + +func _publish_local_entitlement() -> void: + if _session == null or not _session.is_gameplay_session_active(): + return + var entitlement: Dictionary = _local_entitlement() + if _session.is_host(): + _peer_art_entitlements[_session.get_local_peer_id()] = entitlement + else: + submit_art_entitlement.rpc_id( + 1, + _session.get_session_id(), + bool(entitlement["has_kit"]), + int(entitlement["unlock_mask"]), + ) + + +@rpc( + "any_peer", + "call_remote", + "reliable", + SurfaceDrawingProtocol.RELIABLE_CHANNEL, +) +func submit_art_entitlement( + session_id: String, + has_kit: bool, + unlock_mask: int, +) -> void: + var sender_id: int = multiplayer.get_remote_sender_id() + if ( + _session == null + or not _session.is_host() + or not _session.is_authenticated_peer(sender_id) + or session_id != _session.get_session_id() + or unlock_mask < 0 + or (unlock_mask & ~PlayerArtUnlocks.ALL_UNLOCK_MASK) != 0 + ): + return + _peer_art_entitlements[sender_id] = { + "has_kit": has_kit, + "unlock_mask": unlock_mask, + } + + +func _peer_entitlement(peer_id: int) -> Dictionary: + if _session != null and peer_id == _session.get_local_peer_id(): + return _local_entitlement() + return Dictionary(_peer_art_entitlements.get(peer_id, {})) + + +func _peer_owns_art_kit(peer_id: int) -> bool: + return bool(_peer_entitlement(peer_id).get("has_kit", false)) + + +func _peer_can_place_grid(peer_id: int, grid_size: int) -> bool: + var entitlement: Dictionary = _peer_entitlement(peer_id) + return ( + bool(entitlement.get("has_kit", false)) + and _mask_unlocks_grid( + int(entitlement.get("unlock_mask", 0)), grid_size + ) + ) + + +func _peer_can_edit(peer_id: int, request: Dictionary) -> bool: + var entitlement: Dictionary = _peer_entitlement(peer_id) + if not bool(entitlement.get("has_kit", false)): + return false + var unlock_mask: int = int(entitlement.get("unlock_mask", 0)) + if not _mask_unlocks_brush(unlock_mask, int(request["brush_size"])): + return false + for value: Variant in request["edits"]: + var edit: Dictionary = value + var color_id := StringName(str(edit.get("color_id", ""))) + if not color_id.is_empty() and not _mask_unlocks_color( + unlock_mask, color_id + ): + return false + return true + + +func _mask_unlocks_color(unlock_mask: int, color_id: StringName) -> bool: + if color_id == SurfaceDrawingPalette.DEFAULT_COLOR_ID: + return true + for product_id: StringName in PlayerArtUnlocks.COLOR_PRODUCTS: + if ( + PlayerArtUnlocks.color_id_for_product(product_id) == color_id + and _mask_owns_product(unlock_mask, product_id) + ): + return true + return false + + +func _mask_unlocks_brush(unlock_mask: int, brush_size: int) -> bool: + if brush_size == PlayerArtUnlocks.BASE_BRUSH_SIZE: + return true + for product_id: StringName in PlayerArtUnlocks.BRUSH_PRODUCTS: + if ( + PlayerArtUnlocks.brush_size_for_product(product_id) == brush_size + and _mask_owns_product(unlock_mask, product_id) + ): + return true + return false + + +func _mask_unlocks_grid(unlock_mask: int, grid_size: int) -> bool: + if grid_size == PlayerArtUnlocks.BASE_GRID_SIZE: + return true + for product_id: StringName in PlayerArtUnlocks.GRID_PRODUCTS: + if ( + PlayerArtUnlocks.grid_size_for_product(product_id) == grid_size + and _mask_owns_product(unlock_mask, product_id) + ): + return true + return false + + +func _mask_owns_product(unlock_mask: int, product_id: StringName) -> bool: + var bit: int = PlayerArtUnlocks.get_product_bit(product_id) + return bit >= 0 and (unlock_mask & (1 << bit)) != 0 + + func request_canvas_at_surface( origin: Vector3, normal: Vector3, tangent: Vector3, ) -> bool: - if not _drawing_available() or normal.is_zero_approx() or tangent.is_zero_approx(): + if ( + not _drawing_available() + or not _owns_art_kit() + or _art_unlocks == null + or not _art_unlocks.is_grid_size_unlocked(_grid_size) + or normal.is_zero_approx() + or tangent.is_zero_approx() + ): return false var data: Dictionary = { "request_id": _new_request_id("canvas"), @@ -249,8 +472,8 @@ func request_canvas_at_surface( "origin": SurfaceDrawingProtocol.vector_to_array(origin), "normal": SurfaceDrawingProtocol.vector_to_array(normal.normalized()), "tangent": SurfaceDrawingProtocol.vector_to_array(tangent.normalized()), - "width": SurfaceDrawingProtocol.GRID_WIDTH, - "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "width": _grid_size, + "height": _grid_size, "cell_size": SurfaceDrawingProtocol.CELL_SIZE, } if _session.is_host(): @@ -265,7 +488,12 @@ func request_cell_edits( edits: Array[Dictionary], stroke_id: String = "", ) -> bool: - if not _drawing_available() or canvas_id.is_empty() or edits.is_empty(): + if ( + not _drawing_available() + or not _owns_art_kit() + or canvas_id.is_empty() + or edits.is_empty() + ): return false var resolved_stroke_id: String = stroke_id if resolved_stroke_id.is_empty(): @@ -276,6 +504,7 @@ func request_cell_edits( "session_id": _session.get_session_id(), "canvas_id": canvas_id, "stroke_id": resolved_stroke_id, + "brush_size": _brush_size, "edits": edits, } if not SurfaceDrawingProtocol.validate_edit_request(data): @@ -292,7 +521,7 @@ func request_guide_visibility( should_be_visible: bool, should_finalize: bool = false, ) -> bool: - if not _drawing_available() or canvas_id.is_empty(): + if not _drawing_available() or not _owns_art_kit() or canvas_id.is_empty(): return false var data: Dictionary = { "request_id": _new_request_id("guide"), @@ -311,7 +540,11 @@ func request_guide_visibility( func request_undo_last_stroke() -> bool: - if not _drawing_available() or _last_local_stroke_id.is_empty(): + if ( + not _drawing_available() + or not _owns_art_kit() + or _last_local_stroke_id.is_empty() + ): _emit_hud_state("nothing to undo") return false var data: Dictionary = { @@ -540,6 +773,7 @@ func _resolved_placement() -> Dictionary: normal, _surface_tangent(normal), states, + _grid_size, ) @@ -562,7 +796,7 @@ func _request_canvas_at_aim() -> void: else: _emit_hud_state("a shared grid already covers this area") return - if _overlaps_existing_canvas(origin, normal): + if _overlaps_existing_canvas(origin, normal, _grid_size): _emit_hud_state("a shared grid already covers this area") return if request_canvas_at_surface(origin, normal, placement["tangent"]): @@ -586,13 +820,17 @@ func submit_canvas_request(data: Dictionary) -> void: func _handle_canvas_request(peer_id: int, data: Dictionary) -> void: + var requested_size: int = int(data.get("width", 0)) if ( not _session.is_host() or not SurfaceDrawingProtocol.validate_canvas_request(data) or str(data["session_id"]) != _session.get_session_id() or not _accept_request(peer_id, str(data["request_id"])) + or not _peer_can_place_grid(peer_id, requested_size) or _canvas_states.size() >= SurfaceDrawingProtocol.MAX_CANVASES or _active_canvas_count() >= SurfaceDrawingProtocol.MAX_ACTIVE_CANVASES + or _allocated_grid_cells() + requested_size * requested_size + > SurfaceDrawingProtocol.MAX_SESSION_GRID_CELLS ): return var avatar: Player = _spawn_service.get_avatar(peer_id) @@ -616,7 +854,9 @@ func _handle_canvas_request(peer_id: int, data: Dictionary) -> void: var normal: Vector3 = _normalized_or( surface_hit.get("normal", requested_normal), requested_normal ) - if _overlaps_existing_canvas(surface_hit["position"], normal): + if _overlaps_existing_canvas( + surface_hit["position"], normal, requested_size + ): return var tangent: Vector3 = SurfaceDrawingProtocol.array_to_vector( data["tangent"] @@ -639,8 +879,8 @@ func _handle_canvas_request(peer_id: int, data: Dictionary) -> void: ), "normal": SurfaceDrawingProtocol.vector_to_array(normal), "tangent": SurfaceDrawingProtocol.vector_to_array(tangent), - "width": SurfaceDrawingProtocol.GRID_WIDTH, - "height": SurfaceDrawingProtocol.GRID_HEIGHT, + "width": requested_size, + "height": requested_size, "cell_size": SurfaceDrawingProtocol.CELL_SIZE, "revision": 0, "guide_visible": true, @@ -672,6 +912,7 @@ func _handle_guide_request(peer_id: int, data: Dictionary) -> void: or not SurfaceDrawingProtocol.validate_guide_request(data) or str(data["session_id"]) != _session.get_session_id() or not _accept_request(peer_id, str(data["request_id"])) + or not _peer_owns_art_kit(peer_id) ): return var canvas_id: String = str(data["canvas_id"]) @@ -794,6 +1035,7 @@ func _handle_edit_request(peer_id: int, data: Dictionary) -> void: or not SurfaceDrawingProtocol.validate_edit_request(data) or str(data["session_id"]) != _session.get_session_id() or not _accept_request(peer_id, str(data["request_id"])) + or not _peer_can_edit(peer_id, data) ): return var canvas_id: String = str(data["canvas_id"]) @@ -808,11 +1050,15 @@ func _handle_edit_request(peer_id: int, data: Dictionary) -> void: ): return var stroke_id: String = str(data["stroke_id"]) + var grid_width: int = int(state["width"]) + var grid_height: int = int(state["height"]) var mutations: Dictionary[String, Dictionary] = {} for edit_value: Variant in data["edits"]: var edit: Dictionary = edit_value var x: int = int(edit["x"]) var y: int = int(edit["y"]) + if x >= grid_width or y >= grid_height: + continue var cell_position: Vector3 = _state_cell_position( state, x, y ) @@ -998,7 +1244,9 @@ func _publish_cell_mutations( var state: Dictionary = _canvas_states.get(canvas_id, {}) if state.is_empty(): continue - var cells: Dictionary[int, Dictionary] = _cells_by_key(state["cells"]) + var cells: Dictionary[int, Dictionary] = _cells_by_key( + state["cells"], int(state["width"]) + ) var canvas_mutations: Dictionary = mutations[canvas_id] var cell_keys: Array[int] = [] for cell_key_value: Variant in canvas_mutations.keys(): @@ -1048,6 +1296,7 @@ func _handle_undo_request(peer_id: int, data: Dictionary) -> void: or not SurfaceDrawingProtocol.validate_undo_request(data) or str(data["session_id"]) != _session.get_session_id() or not _accept_request(peer_id, str(data["request_id"])) + or not _peer_owns_art_kit(peer_id) ): return var stroke_id: String = str(data["stroke_id"]) @@ -1226,10 +1475,18 @@ func _apply_canvas_update(data: Dictionary) -> void: var canvas: SurfaceDrawingCanvas = _canvas_nodes.get(canvas_id) if state.is_empty() or canvas == null or int(data["revision"]) <= int(state["revision"]): return - var cells: Dictionary[int, Dictionary] = _cells_by_key(state["cells"]) + var cells: Dictionary[int, Dictionary] = _cells_by_key( + state["cells"], int(state["width"]) + ) + var grid_width: int = int(state["width"]) + var grid_height: int = int(state["height"]) for edit_value: Variant in data["edits"]: var edit: Dictionary = edit_value - var key: int = int(edit["y"]) * int(state["width"]) + int(edit["x"]) + var x: int = int(edit["x"]) + var y: int = int(edit["y"]) + if x < 0 or x >= grid_width or y < 0 or y >= grid_height: + return + var key: int = y * grid_width + x if str(edit["color_id"]).is_empty(): cells.erase(key) else: @@ -1365,7 +1622,11 @@ func _state_cell_position(state: Dictionary, x: int, y: int) -> Vector3: ) -func _overlaps_existing_canvas(origin: Vector3, normal: Vector3) -> bool: +func _overlaps_existing_canvas( + origin: Vector3, + normal: Vector3, + requested_size: int, +) -> bool: for state: Dictionary in _canvas_states.values(): if bool(state.get("finalized", false)): continue @@ -1384,8 +1645,16 @@ func _overlaps_existing_canvas(origin: Vector3, normal: Vector3) -> bool: state["tangent"] ).normalized() var bitangent: Vector3 = existing_normal.cross(tangent).normalized() - var width: float = float(state["width"]) * float(state["cell_size"]) - var height: float = float(state["height"]) * float(state["cell_size"]) + var width: float = ( + (float(state["width"]) + float(requested_size)) + * float(state["cell_size"]) + * 0.5 + ) + var height: float = ( + (float(state["height"]) + float(requested_size)) + * float(state["cell_size"]) + * 0.5 + ) var clearance: float = float(state["cell_size"]) * 0.5 if ( absf(relative.dot(tangent)) < width - clearance @@ -1403,6 +1672,13 @@ func _active_canvas_count() -> int: return count +func _allocated_grid_cells() -> int: + var count: int = 0 + for state: Dictionary in _canvas_states.values(): + count += int(state.get("width", 0)) * int(state.get("height", 0)) + return count + + func _create_brush_preview() -> void: if _drawing_root == null: return @@ -1427,8 +1703,15 @@ func _create_brush_preview() -> void: func _create_placement_preview() -> void: + _rebuild_placement_preview() + + +func _rebuild_placement_preview() -> void: if _drawing_root == null: return + if _placement_preview != null: + _placement_preview.queue_free() + _placement_preview = null var mesh := ImmediateMesh.new() _placement_preview_material = StandardMaterial3D.new() _placement_preview_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED @@ -1437,22 +1720,22 @@ func _create_placement_preview() -> void: _placement_preview_material.albedo_color = Color(0.46, 0.91, 0.95, 0.72) mesh.surface_begin(Mesh.PRIMITIVE_LINES, _placement_preview_material) var half_width: float = ( - float(SurfaceDrawingProtocol.GRID_WIDTH) + float(_grid_size) * SurfaceDrawingProtocol.CELL_SIZE * 0.5 ) var half_height: float = ( - float(SurfaceDrawingProtocol.GRID_HEIGHT) + float(_grid_size) * SurfaceDrawingProtocol.CELL_SIZE * 0.5 ) - for x: int in range(SurfaceDrawingProtocol.GRID_WIDTH + 1): + for x: int in range(_grid_size + 1): var horizontal: float = ( -half_width + float(x) * SurfaceDrawingProtocol.CELL_SIZE ) mesh.surface_add_vertex(Vector3(horizontal, -half_height, 0.0)) mesh.surface_add_vertex(Vector3(horizontal, half_height, 0.0)) - for y: int in range(SurfaceDrawingProtocol.GRID_HEIGHT + 1): + for y: int in range(_grid_size + 1): var vertical: float = ( -half_height + float(y) * SurfaceDrawingProtocol.CELL_SIZE ) @@ -1528,7 +1811,12 @@ func _cycle_color(direction: int) -> void: func _set_brush_size(value: int) -> void: - _brush_size = clampi(value, 1, 4) + var requested: int = clampi(value, 1, 4) + if _art_unlocks != null and not _art_unlocks.is_brush_size_unlocked( + requested + ): + return + _brush_size = requested _reset_stroke() _emit_hud_state("") @@ -1552,6 +1840,7 @@ func _emit_hud_state(status: String) -> void: SurfaceDrawingPalette.get_display_name(_current_color_id()), _current_color(), _brush_size, + _grid_size, status, ) @@ -1582,14 +1871,17 @@ func _new_request_id(prefix: String) -> String: return "%s-%d-%d" % [prefix, Time.get_ticks_msec(), _request_sequence] -func _cells_by_key(values: Array) -> Dictionary[int, Dictionary]: +func _cells_by_key( + values: Array, + grid_width: int, +) -> Dictionary[int, Dictionary]: var result: Dictionary[int, Dictionary] = {} for value: Variant in values: if typeof(value) != TYPE_DICTIONARY: continue var cell: Dictionary = value var key: int = ( - int(cell.get("y", -1)) * SurfaceDrawingProtocol.GRID_WIDTH + int(cell.get("y", -1)) * grid_width + int(cell.get("x", -1)) ) result[key] = cell.duplicate(true) @@ -1597,7 +1889,9 @@ func _cells_by_key(values: Array) -> Dictionary[int, Dictionary]: func _state_cell(state: Dictionary, x: int, y: int) -> Dictionary: - var cells: Dictionary[int, Dictionary] = _cells_by_key(state.get("cells", [])) + var cells: Dictionary[int, Dictionary] = _cells_by_key( + state.get("cells", []), int(state["width"]) + ) return Dictionary( cells.get(_cell_key_for_state(state, x, y), {}) ).duplicate(true) @@ -1686,6 +1980,20 @@ func _on_peer_removed(peer_id: int) -> void: _peer_request_times.erase(peer_id) _peer_request_ids.erase(peer_id) _stroke_history_by_peer.erase(peer_id) + _peer_art_entitlements.erase(peer_id) + + +func _on_local_art_entitlement_changed() -> void: + if _active and not _owns_art_kit(): + deactivate() + _publish_local_entitlement() + + +func _on_local_art_unlocks_changed(_unlock_mask: int) -> void: + _refresh_local_unlocks() + _rebuild_placement_preview() + _publish_local_entitlement() + _emit_hud_state("") func _on_relationship_changed(_fingerprint: String) -> void: @@ -1695,8 +2003,15 @@ func _on_relationship_changed(_fingerprint: String) -> void: func _on_session_state_changed(state: NetworkSession.State) -> void: if state == NetworkSession.State.JOINED_CLIENT: + _publish_local_entitlement() request_canvas_snapshot.rpc_id(1, _new_request_id("snapshot")) return + if state in [ + NetworkSession.State.PRIVATE_HOST, + NetworkSession.State.OPEN_HOST, + ]: + _publish_local_entitlement() + return if state not in [ NetworkSession.State.INACTIVE, NetworkSession.State.DISCONNECTING, @@ -1709,6 +2024,7 @@ func _on_session_state_changed(state: NetworkSession.State) -> void: _canvas_sequence = 0 _peer_request_times.clear() _peer_request_ids.clear() + _peer_art_entitlements.clear() func _clear_session_artwork_state() -> void: diff --git a/player/player.gd b/player/player.gd index 7733766..7b8a947 100644 --- a/player/player.gd +++ b/player/player.gd @@ -18,6 +18,9 @@ const PlayerItemEffectsType = preload( const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const PlayerArtUnlocksType = preload( + "res://progression/player_art_unlocks.gd" +) const FishingRodAttachmentScene = preload( "res://player/fishing_rod_attachment.tscn" ) @@ -105,11 +108,13 @@ class ShowcaseCameraSnapshot: @onready var fishing_upgrades: PlayerFishingUpgradesType = %FishingUpgrades @onready var item_effects: PlayerItemEffectsType = %ItemEffects @onready var cooler_capacity: PlayerCoolerCapacityType = %CoolerCapacity +@onready var art_unlocks: PlayerArtUnlocksType = %ArtUnlocks @onready var _cast_origin: Marker3D = %CastOrigin @onready var _catch_display: Node3D = %CatchDisplay @onready var _catch_sprite: Sprite3D = %CatchSprite @onready var _held_fish_display: Node3D = %HeldFishDisplay @onready var _held_fish_sprite: Sprite3D = %HeldFishSprite +@onready var _held_art_kit_sprite: Sprite3D = %HeldArtKitSprite var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity")) var _camera_dragging: bool = false @@ -721,6 +726,11 @@ func set_active_item_is_rod(active_is_rod: bool) -> void: _fishing_rod.visible = active_is_rod +func set_active_art_kit(icon: Texture2D, should_show: bool) -> void: + _held_art_kit_sprite.texture = icon if should_show else null + _held_art_kit_sprite.visible = should_show and icon != null + + func set_held_fish( fish: FishDataType, display_scale: float, diff --git a/player/player.tscn b/player/player.tscn index b3e7175..9606811 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=16 format=3] +[gd_scene load_steps=17 format=3] [ext_resource type="Script" path="res://player/player.gd" id="1_script"] [ext_resource type="Script" path="res://inventory/fish_inventory.gd" id="2_inventory"] @@ -13,6 +13,7 @@ [ext_resource type="Script" path="res://player/player_ground_shadow.gd" id="11_ground_shadow"] [ext_resource type="Material" path="res://player/materials/player_blob_shadow.tres" id="12_blob_shadow"] [ext_resource type="PackedScene" path="res://art/exported/characters/base/netfishing_base_character.glb" id="13_character"] +[ext_resource type="Script" path="res://progression/player_art_unlocks.gd" id="14_art_unlocks"] [sub_resource type="CapsuleShape3D" id="PlayerShape"] radius = 0.45 @@ -99,6 +100,10 @@ script = ExtResource("9_effects") unique_name_in_owner = true script = ExtResource("10_capacity") +[node name="ArtUnlocks" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("14_art_unlocks") + [node name="CatchDisplayAnchor" type="Marker3D" parent="Visuals"] unique_name_in_owner = true position = Vector3(0, 1.45, -1.15) @@ -130,6 +135,18 @@ shaded = false double_sided = true texture_filter = 0 +[node name="HeldArtKitAnchor" type="Marker3D" parent="Visuals"] +position = Vector3(0, 1.05, -0.72) + +[node name="HeldArtKitSprite" type="Sprite3D" parent="Visuals/HeldArtKitAnchor"] +unique_name_in_owner = true +visible = false +pixel_size = 0.008 +billboard = 1 +shaded = false +double_sided = true +texture_filter = 1 + [node name="CameraYaw" type="Node3D" parent="."] unique_name_in_owner = true position = Vector3(0, 1.35, 0) diff --git a/progression/player_art_unlocks.gd b/progression/player_art_unlocks.gd new file mode 100644 index 0000000..c6a6e57 --- /dev/null +++ b/progression/player_art_unlocks.gd @@ -0,0 +1,173 @@ +class_name PlayerArtUnlocks +extends Node + +const PlayerWalletType = preload("res://economy/player_wallet.gd") + +signal unlocks_changed(unlock_mask: int) + +const BASE_BRUSH_SIZE: int = 1 +const BASE_GRID_SIZE: int = 16 +const BRUSH_SIZES: Array[int] = [1, 2, 3, 4] +const GRID_SIZES: Array[int] = [16, 32, 64, 128] + +# Stable product IDs are both the save and shop transaction boundary. +const PRODUCT_BITS: Dictionary[StringName, int] = { + &"marker_ocean_teal": 0, + &"marker_coral": 1, + &"marker_sunny": 2, + &"marker_leaf": 3, + &"marker_blue": 4, + &"marker_violet": 5, + &"marker_charcoal": 6, + &"brush_2x": 7, + &"brush_3x": 8, + &"brush_4x": 9, + &"grid_32x": 10, + &"grid_64x": 11, + &"grid_128x": 12, +} +const COLOR_PRODUCTS: Dictionary[StringName, StringName] = { + &"marker_ocean_teal": &"ocean_teal", + &"marker_coral": &"coral", + &"marker_sunny": &"sunny", + &"marker_leaf": &"leaf", + &"marker_blue": &"blue", + &"marker_violet": &"violet", + &"marker_charcoal": &"charcoal", +} +const BRUSH_PRODUCTS: Dictionary[StringName, int] = { + &"brush_2x": 2, + &"brush_3x": 3, + &"brush_4x": 4, +} +const GRID_PRODUCTS: Dictionary[StringName, int] = { + &"grid_32x": 32, + &"grid_64x": 64, + &"grid_128x": 128, +} +const ALL_UNLOCK_MASK: int = (1 << 13) - 1 + +var _unlock_mask: int = 0 + + +func get_unlock_mask() -> int: + return _unlock_mask + + +func owns_product(product_id: StringName) -> bool: + var bit: int = get_product_bit(product_id) + return bit >= 0 and (_unlock_mask & (1 << bit)) != 0 + + +func unlock_product(product_id: StringName) -> bool: + var bit: int = get_product_bit(product_id) + if bit < 0 or owns_product(product_id): + return false + _unlock_mask |= 1 << bit + unlocks_changed.emit(_unlock_mask) + return true + + +func purchase_product( + product_id: StringName, + wallet: PlayerWalletType, + cost: int, +) -> bool: + if ( + wallet == null + or cost < 0 + or owns_product(product_id) + or get_product_bit(product_id) < 0 + or not wallet.can_afford(cost) + ): + return false + if not wallet.debit(cost): + return false + if unlock_product(product_id): + return true + if not wallet.credit(cost): + push_error("Art upgrade purchase rollback failed.") + return false + + +func restore_mask(value: int) -> bool: + if value < 0 or (value & ~ALL_UNLOCK_MASK) != 0: + return false + var changed: bool = _unlock_mask != value + _unlock_mask = value + if changed: + unlocks_changed.emit(_unlock_mask) + return true + + +func reset_to_defaults() -> void: + restore_mask(0) + + +func to_save_data() -> Dictionary: + return {"unlock_mask": _unlock_mask} + + +func get_unlocked_color_ids() -> Array[StringName]: + var result: Array[StringName] = [SurfaceDrawingPalette.DEFAULT_COLOR_ID] + for product_id: StringName in COLOR_PRODUCTS: + if owns_product(product_id): + result.append(StringName(str(COLOR_PRODUCTS[product_id]))) + return result + + +func get_unlocked_brush_sizes() -> Array[int]: + var result: Array[int] = [BASE_BRUSH_SIZE] + for product_id: StringName in BRUSH_PRODUCTS: + if owns_product(product_id): + result.append(int(BRUSH_PRODUCTS[product_id])) + result.sort() + return result + + +func get_unlocked_grid_sizes() -> Array[int]: + var result: Array[int] = [BASE_GRID_SIZE] + for product_id: StringName in GRID_PRODUCTS: + if owns_product(product_id): + result.append(int(GRID_PRODUCTS[product_id])) + result.sort() + return result + + +func is_color_unlocked(color_id: StringName) -> bool: + return color_id in get_unlocked_color_ids() + + +func is_brush_size_unlocked(brush_size: int) -> bool: + return brush_size in get_unlocked_brush_sizes() + + +func is_grid_size_unlocked(grid_size: int) -> bool: + return grid_size in get_unlocked_grid_sizes() + + +static func get_product_bit(product_id: StringName) -> int: + return int(PRODUCT_BITS.get(product_id, -1)) + + +static func is_product_id(product_id: StringName) -> bool: + return PRODUCT_BITS.has(product_id) + + +static func resulting_mask(current_mask: int, product_id: StringName) -> int: + var bit: int = get_product_bit(product_id) + if bit < 0: + return current_mask + return current_mask | (1 << bit) + + +static func color_id_for_product(product_id: StringName) -> StringName: + return StringName(str(COLOR_PRODUCTS.get(product_id, ""))) + + +static func brush_size_for_product(product_id: StringName) -> int: + return int(BRUSH_PRODUCTS.get(product_id, -1)) + + +static func grid_size_for_product(product_id: StringName) -> int: + return int(GRID_PRODUCTS.get(product_id, -1)) diff --git a/progression/player_art_unlocks.gd.uid b/progression/player_art_unlocks.gd.uid new file mode 100644 index 0000000..e0d2f0e --- /dev/null +++ b/progression/player_art_unlocks.gd.uid @@ -0,0 +1 @@ +uid://cixpwmn33k2x2 diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index dc4bf62..e24cd33 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -17,6 +17,9 @@ const PlayerFishingUpgradesType = preload( const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const PlayerArtUnlocksType = preload( + "res://progression/player_art_unlocks.gd" +) const SAVE_VERSION: int = 4 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" @@ -36,6 +39,7 @@ class LoadSnapshot: var reel_speed_level: int = 0 var barrier_power_level: int = 0 var cooler_capacity_level: int = 0 + var art_unlock_mask: int = 0 @export_range(0.05, 5.0, 0.05) var autosave_delay: float = 0.5 @@ -49,6 +53,7 @@ var _hotbar: PlayerHotbarType var _item_catalog: ItemCatalogType var _fishing_upgrades: PlayerFishingUpgradesType var _cooler_capacity: PlayerCoolerCapacityType +var _art_unlocks: PlayerArtUnlocksType var _autosave_timer: Timer var _is_configured: bool = false var _is_restoring: bool = false @@ -90,6 +95,7 @@ func setup( item_catalog: ItemCatalogType, fishing_upgrades: PlayerFishingUpgradesType, cooler_capacity: PlayerCoolerCapacityType, + art_unlocks: PlayerArtUnlocksType, ) -> void: _inventory = inventory _collection_log = collection_log @@ -100,6 +106,7 @@ func setup( _item_catalog = item_catalog _fishing_upgrades = fishing_upgrades _cooler_capacity = cooler_capacity + _art_unlocks = art_unlocks _is_configured = ( _inventory != null and _collection_log != null @@ -110,6 +117,7 @@ func setup( and _item_catalog != null and _fishing_upgrades != null and _cooler_capacity != null + and _art_unlocks != null ) if not _is_configured: push_error("PlayerSaveManager setup is missing required references.") @@ -142,6 +150,8 @@ func setup( _cooler_capacity.capacity_changed.connect( _on_cooler_capacity_changed ) + if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed): + _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed) func load_player_data() -> bool: @@ -216,6 +226,9 @@ func load_player_data() -> bool: var cooler_restored: bool = _cooler_capacity.restore_level( snapshot.cooler_capacity_level ) + var art_restored: bool = _art_unlocks.restore_mask( + snapshot.art_unlock_mask + ) _is_restoring = false if ( not inventory_restored @@ -225,6 +238,7 @@ func load_player_data() -> bool: or not hotbar_restored or not upgrades_restored or not cooler_restored + or not art_restored ): push_error("Validated player save could not be restored.") return false @@ -431,6 +445,7 @@ func _build_save_dictionary() -> Dictionary: }, "upgrades": _fishing_upgrades.to_save_data(), "cooler": _cooler_capacity.to_save_data(), + "art": _art_unlocks.to_save_data(), } @@ -450,6 +465,7 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: var hotbar_data: Dictionary = save_data["hotbar"] var upgrades_data: Dictionary = {} var cooler_data: Dictionary = {} + var art_data: Dictionary = {} if typeof(save_data.get("upgrades")) == TYPE_DICTIONARY: upgrades_data = save_data["upgrades"] else: @@ -460,6 +476,8 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: cooler_data = save_data["cooler"] else: push_warning("Saved Cooler data was missing or invalid; using defaults.") + if typeof(save_data.get("art")) == TYPE_DICTIONARY: + art_data = save_data["art"] if ( not wallet_data.has("balance") or typeof(collection_data.get("discovered_fish_ids")) != TYPE_ARRAY @@ -657,6 +675,11 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: PlayerCoolerCapacityType.MAX_LEVEL, "cooler.capacity_level" ) + snapshot.art_unlock_mask = _read_integer( + art_data.get("unlock_mask"), + 0, + PlayerArtUnlocksType.ALL_UNLOCK_MASK, + ) return snapshot @@ -766,6 +789,10 @@ func _on_cooler_capacity_changed( _mark_dirty() +func _on_art_unlocks_changed(_unlock_mask: int) -> void: + _mark_dirty() + + func _on_autosave_timeout() -> void: if _is_dirty: save_now() @@ -822,6 +849,7 @@ func _restore_defaults() -> void: _hotbar.replace_state(default_slots, 0) _fishing_upgrades.reset_to_defaults() _cooler_capacity.reset_to_defaults() + _art_unlocks.reset_to_defaults() _is_restoring = false _is_dirty = false diff --git a/tests/art_tools_validation.gd b/tests/art_tools_validation.gd new file mode 100644 index 0000000..cc4e014 --- /dev/null +++ b/tests/art_tools_validation.gd @@ -0,0 +1,97 @@ +extends SceneTree + +const MainScene: PackedScene = preload("res://main/main.tscn") +const TEST_PORT: int = 18136 + + +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"))) + assert(bool(main.call("_prepare_private_host"))) + var save_manager := main.get("_save_manager") as PlayerSaveManager + assert(save_manager.initialize_new_game()) + main.call("_enter_gameplay") + await physics_frame + await physics_frame + + var player := main.get("_player") as Player + var service := main.get_node( + "%NetworkSurfaceDrawingService" + ) as NetworkSurfaceDrawingService + var game_ui := main.get_node("%GameUI") as GameUI + var toolbar := game_ui.get_node( + "%SurfaceDrawingToolbar" + ) as SurfaceDrawingToolbar + assert(player != null and service != null and toolbar != null) + assert(not service.can_activate()) + assert(not service.is_active() and not toolbar.visible) + assert(player.bag.add_item(ArtShopStock.ART_KIT_ITEM_ID, 1)) + assert(service.can_activate()) + + var paint_key := InputEventKey.new() + paint_key.physical_keycode = KEY_P + paint_key.pressed = true + assert(service.handle_input(paint_key, true)) + await process_frame + assert(service.is_active() and service.is_placement_mode()) + assert(toolbar.visible) + assert(toolbar.position.is_equal_approx(Vector2(18.0, 18.0))) + assert(toolbar.get_global_rect().end.x <= 1280.0) + assert(toolbar.get_global_rect().end.y <= 720.0) + + var brush_option := toolbar.get_node("%BrushOption") as OptionButton + var grid_option := toolbar.get_node("%GridOption") as OptionButton + assert(brush_option.item_count == 4) + assert(grid_option.item_count == 4) + assert(not brush_option.get_popup().is_item_disabled(0)) + assert(brush_option.get_popup().is_item_disabled(1)) + assert(not grid_option.get_popup().is_item_disabled(0)) + assert(grid_option.get_popup().is_item_disabled(1)) + var color_buttons: Dictionary = toolbar.get("_color_buttons") + assert(color_buttons.size() == SurfaceDrawingPalette.COLORS.size()) + assert(not (color_buttons[&"chalk_white"] as Button).disabled) + assert((color_buttons[&"ocean_teal"] as Button).disabled) + + for product_id: StringName in [ + &"marker_ocean_teal", &"brush_4x", &"grid_128x", + ]: + assert(player.art_unlocks.unlock_product(product_id)) + await process_frame + assert(not (color_buttons[&"ocean_teal"] as Button).disabled) + assert(not brush_option.get_popup().is_item_disabled(3)) + assert(not grid_option.get_popup().is_item_disabled(3)) + assert(service.set_color_id(&"ocean_teal")) + assert(service.set_brush_size(4)) + assert(service.set_grid_size(128)) + assert(service.get_color_id() == &"ocean_teal") + assert(service.get_brush_size() == 4) + assert(service.get_grid_size() == 128) + + assert(service.handle_input(paint_key, true)) + await process_frame + assert(not service.is_active() and not toolbar.visible) + assert(player.hotbar.assign_item(0, ArtShopStock.ART_KIT_ITEM_ID)) + var fishing_spot := main.get_node("%FishingSpot") as FishingSpot + fishing_spot.art_ui_toggle_requested.emit() + await process_frame + assert(service.is_active() and toolbar.visible) + assert(service.get_grid_size() == 128) + + print("Art tools validation: PASS") + var session := main.get_node("%NetworkSession") as NetworkSession + session.disconnect_session("") + main.queue_free() + await process_frame + quit() diff --git a/tests/art_tools_validation.gd.uid b/tests/art_tools_validation.gd.uid new file mode 100644 index 0000000..7c7edc5 --- /dev/null +++ b/tests/art_tools_validation.gd.uid @@ -0,0 +1 @@ +uid://dr671f64t58wc diff --git a/tests/economy_regression_validation.gd b/tests/economy_regression_validation.gd index 2df0479..0f54ca7 100644 --- a/tests/economy_regression_validation.gd +++ b/tests/economy_regression_validation.gd @@ -72,6 +72,7 @@ func _run() -> void: assert(session.set_host_open(false)) await _test_host_shop_purchase(main, player, shop_service) + await _test_host_art_shop_purchase(main, player, shop_service) await _test_fishing_shop_sale_ui( main, player, catalog, sale_service, reservations ) @@ -205,6 +206,25 @@ func _run_multiplayer_client() -> void: print("Client shop result: ", _shop_result) assert(not _shop_result.is_empty() and bool(_shop_result[1])) assert(not shop_service.is_local_purchase_pending()) + var required_art_balance: int = ( + ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE + ) + if player.wallet.get_balance() < required_art_balance: + assert(player.wallet.credit( + required_art_balance - player.wallet.get_balance() + )) + _shop_result.clear() + assert(not shop_service.request_art_kit().is_empty()) + while _shop_result.is_empty(): + await process_frame + assert(bool(_shop_result[1])) + _shop_result.clear() + assert(not shop_service.request_art_upgrade(&"grid_32x").is_empty()) + while _shop_result.is_empty(): + await process_frame + assert(bool(_shop_result[1])) + assert(player.bag.owns_item(ArtShopStock.ART_KIT_ITEM_ID)) + assert(player.art_unlocks.is_grid_size_unlocked(32)) print("Economy multiplayer client validation: PASS") session.disconnect_session("Economy client validation complete.") main.queue_free() @@ -450,6 +470,42 @@ func _test_host_shop_purchase( assert(not shop_service.is_local_purchase_pending()) +func _test_host_art_shop_purchase( + main: Node, + player: Player, + shop_service: NetworkShopService, +) -> void: + var interaction := main.get("_shop_interaction") as FishingShopInteraction + assert(interaction != null) + player.global_position = interaction.global_position + for _frame: int in 4: + await physics_frame + var required_balance: int = ( + ArtShopStock.ART_KIT_PRICE + ArtShopStock.UPGRADE_PRICE * 3 + ) + if player.wallet.get_balance() < required_balance: + assert(player.wallet.credit(required_balance - player.wallet.get_balance())) + _shop_result.clear() + assert(not shop_service.request_art_kit().is_empty()) + assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert(player.bag.owns_item(ArtShopStock.ART_KIT_ITEM_ID)) + var item_catalog := main.get("item_catalog") as ItemCatalog + assert(item_catalog != null) + var art_item: ItemData = item_catalog.get_item_by_id( + ArtShopStock.ART_KIT_ITEM_ID + ) + assert(art_item != null and art_item.hotbar_allowed and art_item.equippable) + assert(player.hotbar.assign_item(0, ArtShopStock.ART_KIT_ITEM_ID)) + for product_id: StringName in [ + &"marker_ocean_teal", &"brush_2x", &"grid_32x", + ]: + _shop_result.clear() + assert(not shop_service.request_art_upgrade(product_id).is_empty()) + assert(not _shop_result.is_empty() and bool(_shop_result[1])) + assert(player.art_unlocks.owns_product(product_id)) + assert(not shop_service.is_local_purchase_pending()) + + func _test_fishing_shop_sale_ui( main: Node, player: Player, @@ -499,6 +555,11 @@ func _test_fishing_shop_sale_ui( var buy_mode := shop.get_node("%BuyModeButton") as Button var sell_mode := shop.get_node("%SellModeButton") as Button assert(buy_mode.visible and sell_mode.visible) + var stock_sections: Array[String] = [] + for child: Node in shop.get_node("%SuppliesList").get_children(): + if child is Label: + stock_sections.append((child as Label).text) + assert(stock_sections == ["supplies", "art kit", "markers", "brushes", "grids"]) await _activate_focused_button(sell_mode, ui_viewport) await process_frame assert(shop.visible and not player_menu.visible) diff --git a/tests/surface_drawing_multiplayer_validation.gd b/tests/surface_drawing_multiplayer_validation.gd index cdd850d..12925ba 100644 --- a/tests/surface_drawing_multiplayer_validation.gd +++ b/tests/surface_drawing_multiplayer_validation.gd @@ -30,6 +30,9 @@ func _run_host() -> void: main.call("_enter_gameplay") await physics_frame await physics_frame + var player := main.get("_player") as Player + assert(player.bag.add_item(&"art_kit", 1)) + assert(player.art_unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK)) var service := main.get_node( "%NetworkSurfaceDrawingService" @@ -133,6 +136,9 @@ func _run_client() -> void: assert(session.supports_server_capability( SurfaceDrawingProtocol.CAPABILITY )) + var player := main.get("_player") as Player + assert(player.bag.add_item(&"art_kit", 1)) + assert(player.art_unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK)) var service := main.get_node( "%NetworkSurfaceDrawingService" diff --git a/tests/surface_drawing_runtime_validation.gd b/tests/surface_drawing_runtime_validation.gd index dda1823..d730a6a 100644 --- a/tests/surface_drawing_runtime_validation.gd +++ b/tests/surface_drawing_runtime_validation.gd @@ -28,6 +28,8 @@ func _run() -> void: await physics_frame var player := main.get("_player") as Player + assert(player.bag.add_item(&"art_kit", 1)) + assert(player.art_unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK)) var service := main.get_node( "%NetworkSurfaceDrawingService" ) as NetworkSurfaceDrawingService @@ -119,19 +121,19 @@ func _validate_marker_controls( var prior_mouse_mode: Input.MouseMode = Input.mouse_mode service.activate() assert(service.is_active()) - assert(not service.is_placement_mode()) + assert(service.is_placement_mode()) if DisplayServer.get_name() != "headless": - assert(Input.mouse_mode == Input.MOUSE_MODE_CAPTURED) + assert(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE) var placement_key := InputEventKey.new() placement_key.physical_keycode = KEY_R placement_key.pressed = true assert(service.handle_input(placement_key, true)) - assert(service.is_placement_mode()) + assert(not service.is_placement_mode()) var pointer_before: Vector2 = service.get_pointer_screen_position() var pointer_motion := InputEventMouseMotion.new() - pointer_motion.screen_relative = Vector2(30.0, -12.0) + pointer_motion.position = pointer_before + Vector2(30.0, -12.0) assert(service.handle_input(pointer_motion, true)) assert(service.get_pointer_screen_position() != pointer_before) var zoom_event := InputEventMouseButton.new() @@ -156,6 +158,6 @@ func _validate_marker_controls( assert(not service.handle_input(camera_release, true)) assert(service.handle_input(placement_key, true)) - assert(not service.is_placement_mode()) + assert(service.is_placement_mode()) service.deactivate() assert(Input.mouse_mode == prior_mouse_mode) diff --git a/tests/surface_drawing_validation.gd b/tests/surface_drawing_validation.gd index 25c1873..aa94f0f 100644 --- a/tests/surface_drawing_validation.gd +++ b/tests/surface_drawing_validation.gd @@ -14,6 +14,7 @@ func _initialize() -> void: func _run() -> void: _validate_palette() + _validate_art_unlocks() _validate_protocol_bounds() _validate_grid_snapping() await _validate_canvas_geometry_and_collaboration() @@ -37,13 +38,14 @@ func _validate_palette() -> void: func _validate_protocol_bounds() -> void: - assert(SurfaceDrawingProtocol.GRID_WIDTH == 32) - assert(SurfaceDrawingProtocol.GRID_HEIGHT == 32) + assert(SurfaceDrawingProtocol.GRID_WIDTH == 16) + assert(SurfaceDrawingProtocol.GRID_HEIGHT == 16) + assert(SurfaceDrawingProtocol.GRID_SIZES == [16, 32, 64, 128]) assert(is_equal_approx(SurfaceDrawingProtocol.CELL_SIZE, 0.075)) assert(is_equal_approx( float(SurfaceDrawingProtocol.GRID_WIDTH) * SurfaceDrawingProtocol.CELL_SIZE, - 2.4, + 1.2, )) var canvas_request: Dictionary = { "request_id": "canvas-1", @@ -64,15 +66,28 @@ func _validate_protocol_bounds() -> void: "session_id": "session", "canvas_id": "canvas-1", "stroke_id": "stroke-1", + "brush_size": 1, "edits": [{"x": 0, "y": 0, "color_id": "ocean_teal"}], } assert(SurfaceDrawingProtocol.validate_edit_request(edit_request)) edit_request["edits"] = [{ - "x": SurfaceDrawingProtocol.GRID_WIDTH, + "x": SurfaceDrawingProtocol.MAX_GRID_SIZE, "y": 0, "color_id": "ocean_teal", }] assert(not SurfaceDrawingProtocol.validate_edit_request(edit_request)) + edit_request["edits"] = [{"x": 0, "y": 0, "color_id": "ocean_teal"}] + edit_request["brush_size"] = 5 + assert(not SurfaceDrawingProtocol.validate_edit_request(edit_request)) + for grid_size: int in SurfaceDrawingProtocol.GRID_SIZES: + var sized_request: Dictionary = canvas_request.duplicate(true) + sized_request["width"] = grid_size + sized_request["height"] = grid_size + assert(SurfaceDrawingProtocol.validate_canvas_request(sized_request)) + var unsupported_size: Dictionary = canvas_request.duplicate(true) + unsupported_size["width"] = 48 + unsupported_size["height"] = 48 + assert(not SurfaceDrawingProtocol.validate_canvas_request(unsupported_size)) var guide_request: Dictionary = { "request_id": "guide-1", "session_id": "session", @@ -91,6 +106,20 @@ func _validate_protocol_bounds() -> void: assert(SurfaceDrawingProtocol.validate_undo_request(undo_request)) +func _validate_art_unlocks() -> void: + var unlocks := PlayerArtUnlocks.new() + assert(unlocks.get_unlocked_color_ids() == [&"chalk_white"]) + assert(unlocks.get_unlocked_brush_sizes() == [1]) + assert(unlocks.get_unlocked_grid_sizes() == [16]) + assert(unlocks.unlock_product(&"marker_ocean_teal")) + assert(unlocks.unlock_product(&"brush_4x")) + assert(unlocks.unlock_product(&"grid_128x")) + assert(unlocks.is_color_unlocked(&"ocean_teal")) + assert(unlocks.is_brush_size_unlocked(4)) + assert(unlocks.is_grid_size_unlocked(128)) + assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1)) + + func _validate_grid_snapping() -> void: var anchor: Dictionary = { "session_id": "session", @@ -109,16 +138,16 @@ func _validate_grid_snapping() -> void: "cells": [], } var snapped: Dictionary = SurfaceDrawingPlacement.resolve( - Vector3(2.28, 0.02, 0.08), + Vector3(1.08, 0.02, 0.08), Vector3.UP, Vector3.RIGHT, [anchor], ) assert(bool(snapped["snapped"])) var snapped_origin: Vector3 = snapped["origin"] - assert(snapped_origin.is_equal_approx(Vector3(2.4, 0.0, 0.0))) + assert(snapped_origin.is_equal_approx(Vector3(1.2, 0.0, 0.0))) var unsnapped: Dictionary = SurfaceDrawingPlacement.resolve( - Vector3(1.2, 0.0, 0.0), + Vector3(0.6, 0.0, 0.0), Vector3.UP, Vector3.RIGHT, [anchor], diff --git a/ui/fishing_shop.gd b/ui/fishing_shop.gd index ba97e6d..79ba3f8 100644 --- a/ui/fishing_shop.gd +++ b/ui/fishing_shop.gd @@ -17,6 +17,10 @@ const PlayerType = preload("res://player/player.gd") const PlayerCoolerCapacityType = preload( "res://progression/player_cooler_capacity.gd" ) +const PlayerArtUnlocksType = preload( + "res://progression/player_art_unlocks.gd" +) +const ArtShopStockType = preload("res://economy/art_shop_stock.gd") const ShopInteractionType = preload( "res://world/fishing_shop_interaction.gd" ) @@ -66,6 +70,7 @@ var _interaction: ShopInteractionType var _bag: PlayerBagType var _item_catalog: ItemCatalogType var _cooler_capacity: PlayerCoolerCapacityType +var _art_unlocks: PlayerArtUnlocksType var _network_shop: NetworkShopService var _prior_movement_enabled: bool = true var _prior_camera_enabled: bool = true @@ -102,10 +107,10 @@ func _request_shop_cooler() -> void: func _focus_buy_page() -> void: _feedback.text = "" - if _supplies_list.get_child_count() > 0: - var first_supply := _supplies_list.get_child(0) as Button - if first_supply != null and not first_supply.disabled: - first_supply.grab_focus() + for child: Node in _supplies_list.get_children(): + var stock_button := child as Button + if stock_button != null and not stock_button.disabled: + stock_button.grab_focus() return _reel_purchase.grab_focus() @@ -160,6 +165,7 @@ func setup( bag: PlayerBagType, item_catalog: ItemCatalogType, cooler_capacity: PlayerCoolerCapacityType, + art_unlocks: PlayerArtUnlocksType, network_shop: NetworkShopService, ) -> void: _player = player @@ -171,6 +177,7 @@ func setup( _bag = bag _item_catalog = item_catalog _cooler_capacity = cooler_capacity + _art_unlocks = art_unlocks _network_shop = network_shop if ( _network_shop != null @@ -194,6 +201,8 @@ func setup( _on_cooler_capacity_changed ): _cooler_capacity.capacity_changed.connect(_on_cooler_capacity_changed) + if not _art_unlocks.unlocks_changed.is_connected(_on_art_unlocks_changed): + _art_unlocks.unlocks_changed.connect(_on_art_unlocks_changed) _refresh_all() @@ -402,6 +411,7 @@ func _refresh_supplies() -> void: for child: Node in _supplies_list.get_children(): _supplies_list.remove_child(child) child.queue_free() + _add_stock_section("supplies") for item_id: StringName in FishingShopStockType.get_stock_item_ids(): var item: ItemDataType = _item_catalog.get_item_by_id(item_id) if item == null: @@ -435,6 +445,92 @@ func _refresh_supplies() -> void: UtilityPageStyleType.apply_ocean_button(button) button.pressed.connect(_purchase_supply.bind(item_id)) _supplies_list.add_child(button) + _add_stock_section("art kit") + _add_art_kit_button() + _add_stock_section("markers") + for product_id: StringName in ArtShopStockType.MARKER_PRODUCTS: + _add_art_upgrade_button(product_id) + _add_stock_section("brushes") + for product_id: StringName in ArtShopStockType.BRUSH_PRODUCTS: + _add_art_upgrade_button(product_id) + _add_stock_section("grids") + for product_id: StringName in ArtShopStockType.GRID_PRODUCTS: + _add_art_upgrade_button(product_id) + + +func _add_stock_section(title: String) -> void: + var label := Label.new() + label.text = title + label.add_theme_font_size_override("font_size", 21) + label.add_theme_color_override( + "font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY + ) + _supplies_list.add_child(label) + + +func _add_art_kit_button() -> void: + var item: ItemDataType = _item_catalog.get_item_by_id( + ArtShopStockType.ART_KIT_ITEM_ID + ) + if item == null: + return + var owned: bool = _bag.get_quantity(ArtShopStockType.ART_KIT_ITEM_ID) > 0 + var button: Button = _make_stock_button( + "%s\n$%d • %s" % [ + item.display_name, + ArtShopStockType.ART_KIT_PRICE, + "owned" if owned else "not owned", + ], + item.description, + ) + button.icon = item.icon + button.expand_icon = true + button.disabled = ( + owned + or _transaction_in_progress + or _closing + or _network_shop == null + or not _network_shop.can_request_art_purchase() + or not _bag.can_add_item(ArtShopStockType.ART_KIT_ITEM_ID, 1) + or not _wallet.can_afford(ArtShopStockType.ART_KIT_PRICE) + ) + button.pressed.connect(_purchase_art_kit) + + +func _add_art_upgrade_button(product_id: StringName) -> void: + var kit_owned: bool = ( + _bag.get_quantity(ArtShopStockType.ART_KIT_ITEM_ID) > 0 + ) + var unlocked: bool = _art_unlocks.owns_product(product_id) + var button: Button = _make_stock_button( + "%s\n$%d • %s" % [ + ArtShopStockType.get_display_name(product_id), + ArtShopStockType.UPGRADE_PRICE, + "unlocked" if unlocked else "locked", + ], + ArtShopStockType.get_description(product_id), + ) + button.disabled = ( + not kit_owned + or unlocked + or _transaction_in_progress + or _closing + or _network_shop == null + or not _network_shop.can_request_art_purchase() + or not _wallet.can_afford(ArtShopStockType.UPGRADE_PRICE) + ) + button.pressed.connect(_purchase_art_upgrade.bind(product_id)) + + +func _make_stock_button(button_text: String, tooltip: String) -> Button: + var button := Button.new() + button.custom_minimum_size = Vector2(195, 54) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.text = button_text + button.tooltip_text = tooltip + UtilityPageStyleType.apply_ocean_button(button) + _supplies_list.add_child(button) + return button func _refresh_cooler_capacity() -> void: @@ -490,6 +586,20 @@ func _purchase_supply(item_id: StringName) -> void: _network_shop.request_supply(item_id) +func _purchase_art_kit() -> void: + if _network_shop == null or not _is_transaction_context_valid(): + _feedback.text = "Purchase could not be completed." + return + _network_shop.request_art_kit() + + +func _purchase_art_upgrade(product_id: StringName) -> void: + if _network_shop == null or not _is_transaction_context_valid(): + _feedback.text = "Purchase could not be completed." + return + _network_shop.request_art_upgrade(product_id) + + func _purchase_cooler_capacity() -> void: if _transaction_in_progress or not _is_transaction_context_valid(): _feedback.text = "unable to complete purchase." @@ -594,6 +704,10 @@ func _on_cooler_capacity_changed(_level: int, _capacity: int) -> void: _refresh_cooler_capacity() +func _on_art_unlocks_changed(_unlock_mask: int) -> void: + _refresh_supplies() + + func _apply_mouse_close_policy(reason: CloseReason) -> void: if not _mouse_snapshot_stored: return diff --git a/ui/fishing_shop.tscn b/ui/fishing_shop.tscn index f4dd2a3..2b1914d 100644 --- a/ui/fishing_shop.tscn +++ b/ui/fishing_shop.tscn @@ -167,7 +167,7 @@ theme_override_constants/separation = 6 [node name="Title" type="Label" parent="ShopPanel/Margin/Layout/Body/Supplies"] layout_mode = 2 -text = "supplies" +text = "shop stock" theme_override_font_sizes/font_size = 23 [node name="Scroll" type="ScrollContainer" parent="ShopPanel/Margin/Layout/Body/Supplies"] diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 210d9cd..738fcbb 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -33,6 +33,9 @@ const PlayerCoolerCapacityType = preload( ) const ChatUIType = preload("res://ui/chat_ui.gd") const EmoteRadialMenuType = preload("res://ui/emote_radial_menu.gd") +const SurfaceDrawingToolbarType = preload( + "res://ui/surface_drawing_toolbar.gd" +) const PlayerSettingsManagerType = preload( "res://settings/player_settings_manager.gd" ) @@ -65,10 +68,9 @@ signal shop_backdrop_visibility_changed(is_visible: bool) @onready var _effect_status: Label = %EffectStatus @onready var _chat_ui: ChatUIType = %ChatUI @onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu -@onready var _marker_hud: PanelContainer = %MarkerHUD -@onready var _marker_swatch: ColorRect = %MarkerSwatch -@onready var _marker_summary: Label = %MarkerSummary -@onready var _marker_help: Label = %MarkerHelp +@onready var _surface_drawing_toolbar: SurfaceDrawingToolbarType = ( + %SurfaceDrawingToolbar +) @onready var _title_settings_panel: SettingsPanelType = ( $UIRoot/TitleScreen/ResponsiveTitleStage/TitlePresentationScaleRoot/SettingsPanel ) @@ -142,6 +144,7 @@ func setup( network_player_list: NetworkPlayerListService, settings_manager: PlayerSettingsManagerType, surface_drawing: NetworkSurfaceDrawingService, + art_unlocks: PlayerArtUnlocks, ) -> void: _player = player _fishing_spot = fishing_spot @@ -159,6 +162,7 @@ func setup( fishing_spot.status_changed.connect(_on_fishing_status_changed) fishing_spot.catch_display_changed.connect(_on_catch_display_changed) fishing_spot.showcase_changed.connect(_on_showcase_changed) + fishing_spot.art_ui_toggle_requested.connect(_toggle_surface_drawing) _player_menu.menu_visibility_changed.connect( _on_player_menu_visibility_changed ) @@ -200,6 +204,7 @@ func setup( bag, item_catalog, cooler_capacity, + art_unlocks, network_shop_service, ) _fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed) @@ -213,13 +218,15 @@ func setup( _main_shop_buyer = main_shop_buyer _shop_interaction = shop_interaction _surface_drawing = surface_drawing - if _surface_drawing != null: - _surface_drawing.hud_state_changed.connect( - _on_surface_drawing_hud_state_changed - ) + _surface_drawing_toolbar.setup(_surface_drawing, art_unlocks) func _input(event: InputEvent) -> void: + if ( + _surface_drawing_toolbar != null + and _surface_drawing_toolbar.owns_pointer_event(event) + ): + return var drawing_can_open: bool = ( _gameplay_ui_enabled and not _system_menu_open @@ -255,6 +262,15 @@ func _on_emote_selected(emote_id: StringName) -> void: _player.toggle_sitting() +func _toggle_surface_drawing() -> void: + if _surface_drawing == null: + return + if _surface_drawing.is_active(): + _surface_drawing.deactivate() + elif _surface_drawing.can_activate(): + _surface_drawing.activate() + + func setup_data_and_identity( data_root: PlayerDataRoot, identity_backups: IdentityBackupService, @@ -719,35 +735,6 @@ func _on_chat_text_entry_ownership_changed(active: bool) -> void: _emit_interactive_pointer_ui_changed() -func _on_surface_drawing_hud_state_changed( - is_active: bool, - mode_name: String, - color_name: String, - color_value: Color, - brush_size: int, - status: String, -) -> void: - _marker_hud.visible = is_active and _gameplay_ui_enabled - _marker_swatch.visible = mode_name != "place grid" - _marker_swatch.color = color_value - _marker_summary.text = ( - "place shared grid" - if mode_name == "place grid" - else "marker • %s • brush %d" % [ - color_name.to_lower(), brush_size, - ] - ) - _marker_help.text = ( - status - if not status.is_empty() - else ( - "click place/restore • shift hide • ctrl shift finish • shift scroll zoom" - if mode_name == "place grid" - else "click draw • shift click erase • ctrl z undo • shift scroll zoom" - ) - ) - - func _refresh_chat_availability() -> void: if _chat_ui == null: return diff --git a/ui/game_ui.tscn b/ui/game_ui.tscn index 0d0a297..4740dc0 100644 --- a/ui/game_ui.tscn +++ b/ui/game_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=15 format=3] +[gd_scene load_steps=16 format=3] [ext_resource type="Script" path="res://ui/game_ui.gd" id="1_ui"] [ext_resource type="PackedScene" path="res://ui/player_menu.tscn" id="2_menu"] @@ -10,6 +10,7 @@ [ext_resource type="PackedScene" path="res://ui/fishing_shop.tscn" id="8_shop"] [ext_resource type="Script" path="res://ui/chat_ui.gd" id="9_chat"] [ext_resource type="PackedScene" path="res://ui/emote_radial_menu.tscn" id="10_emote"] +[ext_resource type="PackedScene" path="res://ui/surface_drawing_toolbar.tscn" id="11_drawing_toolbar"] [sub_resource type="StyleBoxFlat" id="StyleBox_chase_background"] bg_color = Color(0.032, 0.118, 0.15, 1) @@ -34,17 +35,6 @@ corner_radius_bottom_left = 12 [sub_resource type="StyleBoxFlat" id="StyleBox_transparent"] bg_color = Color(0, 0, 0, 0) -[sub_resource type="StyleBoxFlat" id="StyleBox_marker_panel"] -bg_color = Color(0.051, 0.173, 0.227, 0.96) -corner_radius_top_left = 12 -corner_radius_top_right = 12 -corner_radius_bottom_right = 12 -corner_radius_bottom_left = 12 -content_margin_left = 14.0 -content_margin_top = 9.0 -content_margin_right = 14.0 -content_margin_bottom = 9.0 - [node name="GameUI" type="CanvasLayer"] script = ExtResource("1_ui") @@ -56,6 +46,9 @@ mouse_filter = 2 unique_name_in_owner = true mouse_filter = 2 +[node name="SurfaceDrawingToolbar" parent="UIRoot/CanonicalStage" instance=ExtResource("11_drawing_toolbar")] +unique_name_in_owner = true + [node name="GameplayTransientHUD" type="Control" parent="UIRoot/CanonicalStage"] unique_name_in_owner = true layout_mode = 1 @@ -265,51 +258,6 @@ theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.9) theme_override_constants/shadow_offset_x = 2 theme_override_constants/shadow_offset_y = 2 -[node name="MarkerHUD" type="PanelContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD"] -unique_name_in_owner = true -visible = false -z_index = 58 -anchors_preset = 10 -anchor_left = 1.0 -anchor_right = 1.0 -offset_left = -510.0 -offset_top = 18.0 -offset_right = -18.0 -offset_bottom = 92.0 -grow_horizontal = 0 -mouse_filter = 2 -theme = ExtResource("3_theme") -theme_override_styles/panel = SubResource("StyleBox_marker_panel") - -[node name="Layout" type="HBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD"] -layout_mode = 2 -theme_override_constants/separation = 12 - -[node name="MarkerSwatch" type="ColorRect" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout"] -unique_name_in_owner = true -custom_minimum_size = Vector2(42, 42) -layout_mode = 2 -mouse_filter = 2 -color = Color(0.960784, 0.933333, 0.85098, 1) - -[node name="Text" type="VBoxContainer" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout"] -layout_mode = 2 -size_flags_horizontal = 3 -theme_override_constants/separation = 0 - -[node name="MarkerSummary" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout/Text"] -unique_name_in_owner = true -layout_mode = 2 -text = "marker • chalk white • brush 1" -theme_override_font_sizes/font_size = 18 - -[node name="MarkerHelp" type="Label" parent="UIRoot/CanonicalStage/GameplayTransientHUD/MarkerHUD/Layout/Text"] -unique_name_in_owner = true -layout_mode = 2 -text = "click draw • shift erase • ctrl z undo • shift scroll zoom" -theme_override_colors/font_color = Color(0.623529, 0.811765, 0.823529, 1) -theme_override_font_sizes/font_size = 13 - [node name="ChatUI" type="Control" parent="UIRoot"] unique_name_in_owner = true layout_mode = 1 diff --git a/ui/surface_drawing_toolbar.gd b/ui/surface_drawing_toolbar.gd new file mode 100644 index 0000000..c4cac1b --- /dev/null +++ b/ui/surface_drawing_toolbar.gd @@ -0,0 +1,195 @@ +class_name SurfaceDrawingToolbar +extends PanelContainer + +const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") + +@onready var _mode_button: Button = %ModeButton +@onready var _brush_option: OptionButton = %BrushOption +@onready var _grid_option: OptionButton = %GridOption +@onready var _color_list: VBoxContainer = %ColorList + +var _service: NetworkSurfaceDrawingService +var _unlocks: PlayerArtUnlocks +var _color_buttons: Dictionary[StringName, Button] = {} + + +func _ready() -> void: + UtilityPageStyleType.apply_page(self) + var panel: StyleBoxFlat = UtilityPageStyleType.panel_style() + panel.content_margin_left = 10.0 + panel.content_margin_top = 10.0 + panel.content_margin_right = 10.0 + panel.content_margin_bottom = 10.0 + add_theme_stylebox_override("panel", panel) + UtilityPageStyleType.apply_ocean_button(_mode_button) + UtilityPageStyleType.apply_ocean_button(_brush_option) + UtilityPageStyleType.apply_ocean_button(_grid_option) + _mode_button.custom_minimum_size = Vector2(48, 48) + _make_mode_button_round() + _mode_button.pressed.connect(_toggle_mode) + _brush_option.item_selected.connect(_select_brush) + _grid_option.item_selected.connect(_select_grid) + _build_options() + hide() + + +func _make_mode_button_round() -> void: + for style_name: StringName in [ + &"normal", &"hover", &"pressed", &"focus", &"disabled", + ]: + var existing: StyleBox = _mode_button.get_theme_stylebox(style_name) + var style := existing.duplicate() as StyleBoxFlat + if style == null: + continue + style.set_corner_radius_all(24) + _mode_button.add_theme_stylebox_override(style_name, style) + + +func setup( + service: NetworkSurfaceDrawingService, + unlocks: PlayerArtUnlocks, +) -> void: + _service = service + _unlocks = unlocks + if _service != null and not _service.hud_state_changed.is_connected( + _on_service_state_changed + ): + _service.hud_state_changed.connect(_on_service_state_changed) + if _unlocks != null and not _unlocks.unlocks_changed.is_connected( + _on_unlocks_changed + ): + _unlocks.unlocks_changed.connect(_on_unlocks_changed) + _refresh_unlocks() + + +func owns_pointer_event(event: InputEvent) -> bool: + if not visible: + return false + if _brush_option.get_popup().visible or _grid_option.get_popup().visible: + return true + if event is InputEventMouse: + return get_global_rect().has_point((event as InputEventMouse).position) + return false + + +func _build_options() -> void: + _brush_option.clear() + for brush_size: int in PlayerArtUnlocks.BRUSH_SIZES: + _brush_option.add_item("%d×" % brush_size, brush_size) + _grid_option.clear() + for grid_size: int in PlayerArtUnlocks.GRID_SIZES: + _grid_option.add_item("%d×" % grid_size, grid_size) + for child: Node in _color_list.get_children(): + child.queue_free() + _color_buttons.clear() + for color_id: StringName in SurfaceDrawingPalette.get_color_ids(): + var button := Button.new() + button.custom_minimum_size = Vector2(34, 34) + button.focus_mode = Control.FOCUS_ALL + button.tooltip_text = SurfaceDrawingPalette.get_display_name(color_id) + button.pressed.connect(_select_color.bind(color_id)) + _color_list.add_child(button) + _color_buttons[color_id] = button + _apply_color_button_style(button, color_id, false) + + +func _refresh_unlocks() -> void: + if not is_node_ready() or _unlocks == null: + return + var brush_popup: PopupMenu = _brush_option.get_popup() + for index: int in range(_brush_option.item_count): + var brush_size: int = _brush_option.get_item_id(index) + brush_popup.set_item_disabled( + index, not _unlocks.is_brush_size_unlocked(brush_size) + ) + var grid_popup: PopupMenu = _grid_option.get_popup() + for index: int in range(_grid_option.item_count): + var grid_size: int = _grid_option.get_item_id(index) + grid_popup.set_item_disabled( + index, not _unlocks.is_grid_size_unlocked(grid_size) + ) + for color_id: StringName in _color_buttons: + var button: Button = _color_buttons[color_id] + button.disabled = not _unlocks.is_color_unlocked(color_id) + _apply_color_button_style( + button, + color_id, + _service != null and _service.get_color_id() == color_id, + ) + + +func _apply_color_button_style( + button: Button, + color_id: StringName, + selected: bool, +) -> void: + var color: Color = ( + SurfaceDrawingPalette.get_color(color_id) + if not button.disabled + else UtilityPageStyleType.OCEAN_DISABLED + ) + for style_name: StringName in [&"normal", &"hover", &"pressed", &"focus"]: + var style := StyleBoxFlat.new() + style.bg_color = color.lightened(0.12) if style_name == &"hover" else color + style.set_border_width_all(3 if selected else 0) + style.border_color = UtilityPageStyleType.OCEAN_TEXT_PRIMARY + style.set_corner_radius_all(17) + button.add_theme_stylebox_override(style_name, style) + var disabled_style := StyleBoxFlat.new() + disabled_style.bg_color = UtilityPageStyleType.OCEAN_DISABLED + disabled_style.set_corner_radius_all(17) + button.add_theme_stylebox_override("disabled", disabled_style) + + +func _toggle_mode() -> void: + if _service != null: + _service.set_placement_mode(not _service.is_placement_mode()) + + +func _select_brush(index: int) -> void: + if _service != null: + _service.set_brush_size(_brush_option.get_item_id(index)) + + +func _select_grid(index: int) -> void: + if _service != null: + _service.set_grid_size(_grid_option.get_item_id(index)) + + +func _select_color(color_id: StringName) -> void: + if _service != null: + _service.set_color_id(color_id) + + +func _on_unlocks_changed(_unlock_mask: int) -> void: + _refresh_unlocks() + + +func _on_service_state_changed( + is_active: bool, + mode_name: String, + _color_name: String, + _color_value: Color, + brush_size: int, + grid_size: int, + _status: String, +) -> void: + visible = is_active + if not is_active: + return + _mode_button.text = "▦" if mode_name == "place grid" else "●" + _mode_button.tooltip_text = ( + "Switch to marker mode" + if mode_name == "place grid" + else "Switch to grid mode" + ) + _select_option_by_id(_brush_option, brush_size) + _select_option_by_id(_grid_option, grid_size) + _refresh_unlocks() + + +func _select_option_by_id(option: OptionButton, item_id: int) -> void: + for index: int in range(option.item_count): + if option.get_item_id(index) == item_id: + option.select(index) + return diff --git a/ui/surface_drawing_toolbar.gd.uid b/ui/surface_drawing_toolbar.gd.uid new file mode 100644 index 0000000..d116d9d --- /dev/null +++ b/ui/surface_drawing_toolbar.gd.uid @@ -0,0 +1 @@ +uid://ebnfrdwfm1gm diff --git a/ui/surface_drawing_toolbar.tscn b/ui/surface_drawing_toolbar.tscn new file mode 100644 index 0000000..dbc81a5 --- /dev/null +++ b/ui/surface_drawing_toolbar.tscn @@ -0,0 +1,57 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ui/surface_drawing_toolbar.gd" id="1_script"] +[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"] + +[node name="SurfaceDrawingToolbar" type="PanelContainer"] +visible = false +z_index = 85 +layout_mode = 0 +offset_left = 18.0 +offset_top = 18.0 +offset_right = 284.0 +offset_bottom = 340.0 +mouse_filter = 0 +theme = ExtResource("2_theme") +script = ExtResource("1_script") + +[node name="Layout" type="HBoxContainer" parent="."] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="ColorList" type="VBoxContainer" parent="Layout"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_constants/separation = 5 + +[node name="Tools" type="VBoxContainer" parent="Layout"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="Top" type="HBoxContainer" parent="Layout/Tools"] +layout_mode = 2 +theme_override_constants/separation = 6 + +[node name="ModeButton" type="Button" parent="Layout/Tools/Top"] +unique_name_in_owner = true +layout_mode = 2 +text = "▦" + +[node name="BrushOption" type="OptionButton" parent="Layout/Tools/Top"] +unique_name_in_owner = true +custom_minimum_size = Vector2(80, 48) +layout_mode = 2 +tooltip_text = "brush size" + +[node name="GridOption" type="OptionButton" parent="Layout/Tools/Top"] +unique_name_in_owner = true +custom_minimum_size = Vector2(92, 48) +layout_mode = 2 +tooltip_text = "grid size" + +[node name="Help" type="Label" parent="Layout/Tools"] +layout_mode = 2 +text = "click place/draw\nshift click hide/erase\nctrl z undo\nshift scroll zoom" +theme_override_colors/font_color = Color(0.623529, 0.811765, 0.823529, 1) +theme_override_font_sizes/font_size = 14