Add Art Kit painting upgrades

This commit is contained in:
Alexander Sellite 2026-08-02 00:45:07 -04:00
parent 0edbfabdb1
commit 1406a0abc7
34 changed files with 1530 additions and 165 deletions

View file

@ -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:

View file

@ -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

64
economy/art_shop_stock.gd Normal file
View file

@ -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 ""

View file

@ -0,0 +1 @@
uid://cqagg27unh12c

View file

@ -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

View file

@ -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

View file

@ -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")]

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect x="7" y="10" width="50" height="44" rx="12" fill="#35b9c7"/>
<rect x="12" y="15" width="40" height="34" rx="9" fill="#0d2c3a"/>
<circle cx="23" cy="27" r="6" fill="#f5eed9"/>
<circle cx="39" cy="27" r="6" fill="#ef5b62"/>
<path d="M18 42L30 33L35 38L46 30L50 42Z" fill="#ffd166"/>
</svg>

After

Width:  |  Height:  |  Size: 388 B

View file

@ -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

View file

@ -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)
))

View file

@ -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"])

View file

@ -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",

View file

@ -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",

View file

@ -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,
}

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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)

View file

@ -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))

View file

@ -0,0 +1 @@
uid://cixpwmn33k2x2

View file

@ -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

View file

@ -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()

View file

@ -0,0 +1 @@
uid://dr671f64t58wc

View file

@ -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)

View file

@ -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"

View file

@ -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)

View file

@ -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],

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
uid://ebnfrdwfm1gm

View file

@ -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