diff --git a/runtime/entry/entry_shell.gd b/runtime/entry/entry_shell.gd index 5323f638..0e30729e 100644 --- a/runtime/entry/entry_shell.gd +++ b/runtime/entry/entry_shell.gd @@ -70,6 +70,7 @@ const READINESS_COMMAND_ACCEPT = preload("res://assets/entry/pty_002_readiness/a const READINESS_COMMAND_RISK = preload("res://assets/entry/pty_002_readiness/risk_next@3x.png") const RISK_BOARD_STATIC_MASTER = preload("res://assets/entry/rsk_001_risk_board/risk-board-static-master.png") const RISK_COMMITMENT_STATIC_MASTER = preload("res://assets/entry/rsk_002_risk_commitment/risk-commitment-static-master.png") +const MARKET_STATIC_MASTER = preload("res://assets/entry/shp_001_market/market-static-master.png") const BATTLE_OPENING_BACKGROUND = preload("res://assets/backgrounds/first_expedition_field.png") const BATTLE_OPENING_ENEMY = preload("res://assets/enemies/riftblade/idle.png") const BATTLE_OPENING_HEARTHGUARD = preload("res://assets/actors/hearthguard.png") @@ -183,6 +184,7 @@ var party_master_design_canvas: Control var readiness_master_design_canvas: Control var risk_board_master_design_canvas: Control var risk_commitment_master_design_canvas: Control +var market_master_design_canvas: Control func _notification(what: int) -> void: @@ -202,6 +204,8 @@ func _notification(what: int) -> void: _layout_risk_board_master_canvas() if what == NOTIFICATION_RESIZED and is_instance_valid(risk_commitment_master_design_canvas): _layout_risk_commitment_master_canvas() + if what == NOTIFICATION_RESIZED and is_instance_valid(market_master_design_canvas): + _layout_market_master_canvas() if what == NOTIFICATION_RESIZED and is_instance_valid(battle_opening_canvas): _layout_battle_opening_canvas() if what == NOTIFICATION_RESIZED and is_instance_valid(battle_live_canvas): @@ -2434,103 +2438,410 @@ func show_market(company := {}) -> void: return guild_company = resolved current_screen = "market" - _build_world_base(true) - var frame_safe := _framed_page_safe("Market") - frame_safe.add_child(_entry_chrome("MarketFrame", "content_scene_frame")) - var safe := _content_page_safe("Market") - var layout := VBoxContainer.new() - layout.name = "MarketLayout" - layout.add_theme_constant_override("separation", 6) - safe.add_child(layout) - var contract: Dictionary = resolved.active_contract - var next_stage_market: bool = not contract.get("recovery_receipts", []).is_empty() - var short_market := size.y < 380.0 - var header := HBoxContainer.new() - header.custom_minimum_size = Vector2(0, 40 if short_market else 44) - layout.add_child(header) - var heading := VBoxContainer.new() - heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL - header.add_child(heading) - heading.add_child(_label(_tr("下一阶段市场", "NEXT-STAGE MARKET") if next_stage_market else _tr("首次市场", "FIRST MARKET"), _scaled(21), Color("#f5ead0"))) - heading.add_child(_label(_tr("战后归队 · 稳定货单", "POST-BATTLE RETURN · STABLE LEDGER") if next_stage_market else _tr("风镜初渡 · 稳定货单", "FIRST WINDGLASS CROSSING · STABLE LEDGER"), _scaled(10), Color("#e8d39d"))) - var company_line := _label(_tr("远征团 %d | 队列容量 %d", "Company %d | Line capacity %d") % [resolved.owned_recruits.size(), int(contract.line_capacity)], _scaled(12), Color("#73b9e7")) - company_line.name = "MarketCompanyStatus" - company_line.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - header.add_child(company_line) - var coin := _label(_tr("%d 金币", "%d COIN") % int(contract.coin), _scaled(14), Color("#f0ca75")) - coin.name = "MarketCoinStatus" - coin.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - header.add_child(coin) - var body := HBoxContainer.new() - body.name = "MarketBody" - body.size_flags_vertical = Control.SIZE_EXPAND_FILL - body.add_theme_constant_override("separation", 14) - layout.add_child(body) - var ledger := VBoxContainer.new() - ledger.name = "MarketLedger" - ledger.size_flags_horizontal = Control.SIZE_EXPAND_FILL - ledger.size_flags_stretch_ratio = 1.35 - ledger.add_theme_constant_override("separation", 4) - body.add_child(ledger) - var categories := HBoxContainer.new() - categories.name = "MarketCategories" - categories.custom_minimum_size = Vector2(0, 44) - categories.add_theme_constant_override("separation", 4) - ledger.add_child(categories) - for category in ["recruit", "equipment", "battle"]: - categories.add_child(_market_category_button(category)) - var offers := VBoxContainer.new() - offers.name = "MarketOfferList" - offers.size_flags_vertical = Control.SIZE_EXPAND_FILL - offers.add_theme_constant_override("separation", 3) - ledger.add_child(offers) - var rows: Array = contract.market_board.get(_market_collection_field(market_category), []) - for index in range(rows.size()): - offers.add_child(_market_offer_row(market_category, rows[index], index)) - var context := VBoxContainer.new() - context.name = "MarketContext" - context.custom_minimum_size = Vector2(minf(350.0, size.x * 0.40), 0) - context.add_theme_constant_override("separation", 5) - body.add_child(context) - _render_market_context(context, contract) - var footer := HBoxContainer.new() - footer.name = "MarketFooter" - footer.custom_minimum_size = Vector2(0, 44 if short_market else 48) - footer.add_theme_constant_override("separation", 8) - layout.add_child(footer) - var back := _company_button("MarketBackGuild", _tr("返回公会", "Back to Guild"), back_to_guild, "company_utility", "back") - back.custom_minimum_size = Vector2(180, 44 if short_market else 48) - footer.add_child(back) - var spacer := Control.new() - spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - footer.add_child(spacer) - var title := _company_button("MarketTitle", _tr("标题首页", "Title"), back_to_title, "company_secondary", "home") - title.custom_minimum_size = Vector2(150 if short_market else 180, 44 if short_market else 48) - footer.add_child(title) - var return_contract := _company_button("MarketReturnContract", _tr("返回委托", "Return Contract"), open_contract_return_review, "company_secondary", "back") - return_contract.custom_minimum_size = Vector2(170 if short_market else 190, 44 if short_market else 48) - return_contract.tooltip_text = _tr("主动结束当前委托;金币、成员、等级、装备与历史均保留", "Voluntarily leave this Contract; Coin, members, levels, equipment and history are retained") - footer.add_child(return_contract) - var formation_available: bool = contract.hire_receipts.size() == 2 and resolved.owned_recruits.size() == int(contract.line_capacity) - var formation := _company_button( - "MarketFormation", - _tr("建立队列", "Establish Line"), - open_party_line, - "company_primary", - "confirm" - ) - formation.custom_minimum_size = Vector2(160 if short_market else 190, 44 if short_market else 48) - _set_entry_button_disabled(formation, not formation_available) - formation.tooltip_text = ( - _tr("四名成员已就位;进入自动编队", "Four members are ready; enter automatic formation") - if formation_available else _tr("需要完成两笔独立招募并达到远征团 4/4", "Complete both separate hires and reach Company 4/4 first") - ) - footer.add_child(formation) - var focus_target := find_child("MarketOffer%s" % market_selected_offer_id, true, false) as Control - if focus_target != null: - focus_target.call_deferred("grab_focus") + _build_market_master_base() + _build_market_master_ui(resolved, resolved.active_contract) + + +func _build_market_master_base() -> void: + for child in get_children(): + child.free() + var matte := ColorRect.new() + matte.name = "MarketMasterMatte" + matte.color = Color("#17252c") + matte.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + matte.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(matte) + market_master_design_canvas = Control.new() + market_master_design_canvas.name = "MarketMasterCanvas" + market_master_design_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(market_master_design_canvas) + _layout_market_master_canvas() + var master := TextureRect.new() + master.name = "MarketStaticMaster" + master.texture = MARKET_STATIC_MASTER + master.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + master.stretch_mode = TextureRect.STRETCH_SCALE + master.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + master.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + master.mouse_filter = Control.MOUSE_FILTER_IGNORE + market_master_design_canvas.add_child(master) + + +func _layout_market_master_canvas() -> void: + if not is_instance_valid(market_master_design_canvas): + return + var available := size + if available.x <= 0.0 or available.y <= 0.0: + return + const DESIGN_ASPECT := 16.0 / 9.0 + var canvas_size := Vector2(available.y * DESIGN_ASPECT, available.y) if available.x / available.y >= DESIGN_ASPECT else Vector2(available.x, available.x / DESIGN_ASPECT) + market_master_design_canvas.position = (available - canvas_size) * 0.5 + market_master_design_canvas.size = canvas_size + + +func _market_master_place(control: Control, area: Rect2) -> void: + control.anchor_left = area.position.x + control.anchor_top = area.position.y + control.anchor_right = area.end.x + control.anchor_bottom = area.end.y + control.offset_left = 0 + control.offset_top = 0 + control.offset_right = 0 + control.offset_bottom = 0 + + +func _market_master_text_size(design_size: int) -> int: + var canvas_height := market_master_design_canvas.size.y if is_instance_valid(market_master_design_canvas) else size.y + var canvas_scale := clampf(canvas_height / 360.0, 1.0, 2.0) + var user_scale := float(setup_draft.get("text_scale", settings.get("text_scale", 1.0))) + return mini(15, maxi(7, int(round(float(design_size) * canvas_scale * user_scale)))) + + +func _market_master_label(parent: Control, node_name: String, area: Rect2, value: String, design_size: int, color: Color, align := HORIZONTAL_ALIGNMENT_LEFT) -> Label: + var label := _label(value, _market_master_text_size(design_size), color) + label.name = node_name + label.horizontal_alignment = align + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _market_master_place(label, area) + parent.add_child(label) + return label + + +func _market_master_badge(parent: Control, node_name: String, area: Rect2, glyph: String, fill: Color, tooltip := "") -> Control: + var badge := Control.new() + badge.name = node_name + badge.mouse_filter = Control.MOUSE_FILTER_IGNORE + _market_master_place(badge, area) + var seal := Panel.new() + seal.name = "Seal" + seal.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + seal.mouse_filter = Control.MOUSE_FILTER_IGNORE + var style := StyleBoxFlat.new() + style.bg_color = fill + style.border_color = Color("#f5e8c4") + style.set_border_width_all(1) + style.set_corner_radius_all(8) + seal.add_theme_stylebox_override("panel", style) + badge.add_child(seal) + var mark := _market_master_label(badge, "Glyph", Rect2(0.0, 0.0, 1.0, 1.0), glyph, 9, Color("#fff7dc"), HORIZONTAL_ALIGNMENT_CENTER) + mark.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.55)) + mark.add_theme_constant_override("shadow_offset_x", 1) + mark.add_theme_constant_override("shadow_offset_y", 1) + if not tooltip.is_empty(): + badge.tooltip_text = tooltip + parent.add_child(badge) + return badge + + +func _market_master_hit_button(node_name: String, area: Rect2, callback: Callable, tooltip: String, disabled := false) -> Button: + var button := Button.new() + button.name = node_name + button.flat = true + button.focus_mode = Control.FOCUS_ALL + button.disabled = disabled + button.tooltip_text = tooltip + button.mouse_default_cursor_shape = Control.CURSOR_FORBIDDEN if disabled else Control.CURSOR_POINTING_HAND + for style_name in ["normal", "disabled"]: + button.add_theme_stylebox_override(style_name, StyleBoxEmpty.new()) + button.add_theme_stylebox_override("hover", _flat_style(Color(0.95, 0.82, 0.35, 0.10), Color("#d6ad5d"), 2, 6)) + button.add_theme_stylebox_override("pressed", _flat_style(Color(0.28, 0.53, 0.38, 0.18), Color("#f3d991"), 2, 6)) + button.add_theme_stylebox_override("focus", _flat_style(Color(0.22, 0.45, 0.57, 0.14), Color("#7fc5d7"), 2, 6)) + _market_master_place(button, area) + button.pressed.connect(callback, CONNECT_DEFERRED) + return button + + +func _market_master_mode_button(overlay: Control, category: String, area: Rect2, count: int) -> Button: + var selected := market_category == category + var title: String = String({ + "recruit": _tr("招募 · %d", "RECRUIT · %d"), + "equipment": _tr("装备 · %d", "EQUIPMENT · %d"), + "battle": _tr("路线 · %d", "ROUTES · %d"), + }.get(category, category)) % count + var button := _market_master_hit_button("MarketCategory%s" % category.capitalize(), area, set_market_category.bind(category), title) + button.set_meta("selected", selected) + var plate := Panel.new() + plate.name = "ModePlate" + plate.show_behind_parent = true + plate.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + plate.mouse_filter = Control.MOUSE_FILTER_IGNORE + var style := StyleBoxFlat.new() + style.bg_color = Color("#4d5960", 0.88) if selected else Color("#26343a", 0.88) + style.border_color = Color("#d6ad5d", 0.94) if selected else Color("#7b7f75", 0.62) + style.set_border_width_all(2 if selected else 1) + style.set_corner_radius_all(5) + plate.add_theme_stylebox_override("panel", style) + button.add_child(plate) + var glyph: String = String({"recruit": "♟", "equipment": "◇", "battle": "✦"}.get(category, "•")) + _market_master_badge(button, "ModeGlyph", Rect2(0.06, 0.20, 0.18, 0.60), glyph, Color("#8d6233") if selected else Color("#46545a"), title) + var label := _market_master_label(button, "ModeLabel", Rect2(0.28, 0.12, 0.68, 0.76), title, 7, Color("#fff2cf") if selected else Color("#d6d5ca"), HORIZONTAL_ALIGNMENT_CENTER) + label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + overlay.add_child(button) + return button + + +func _market_master_offer_button(index: int, offer: Dictionary, area: Rect2) -> Button: + var offer_id := String(offer.get("source_offer_id", "")) + var selected := market_selected_kind == "recruit" and market_selected_offer_id == offer_id + var button := _market_master_hit_button("MarketOffer%s" % offer_id, area, select_market_offer.bind("recruit", offer_id), _tr("检查 %s", "Inspect %s") % _market_recruit_name(offer)) + button.set_meta("offer_id", offer_id) + button.set_meta("offer_kind", "recruit") + button.set_meta("selected", selected) + button.clip_contents = false + var contour := Panel.new() + contour.name = "OfferSelectionContour" + contour.show_behind_parent = true + contour.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + contour.mouse_filter = Control.MOUSE_FILTER_IGNORE + var contour_style := StyleBoxFlat.new() + contour_style.bg_color = Color(0.16, 0.36, 0.48, 0.08) if selected else Color(0, 0, 0, 0) + contour_style.border_color = Color("#4d9abb") if selected else Color(0, 0, 0, 0) + contour_style.set_border_width_all(3 if selected else 0) + contour_style.set_corner_radius_all(7) + contour.add_theme_stylebox_override("panel", contour_style) + button.add_child(contour) + var portrait := TextureRect.new() + portrait.name = "RecruitPortrait" + portrait.texture = InitialCompanyArt.texture_for(offer) + portrait.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + portrait.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + portrait.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + portrait.mouse_filter = Control.MOUSE_FILTER_IGNORE + _market_master_place(portrait, Rect2(0.03, 0.01, 0.94, 0.62)) + button.add_child(portrait) + var profession := _market_master_label(button, "OfferIdentity", Rect2(0.07, 0.57, 0.86, 0.075), _market_recruit_name(offer), 7, Color("#2c271f"), HORIZONTAL_ALIGNMENT_CENTER) + profession.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + var prof_trait := _market_master_label(button, "OfferProfessionTrait", Rect2(0.04, 0.65, 0.92, 0.075), "%s · %s" % [_content_name("professions", String(offer.profession_id)), _content_name("traits", String(offer.trait_id))], 6, Color("#433729"), HORIZONTAL_ALIGNMENT_CENTER) + prof_trait.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + var attr := offer.get("attribute_rolls", {}) as Dictionary + var compact_attr := "VIG %s · FOR %s · FOC %s" % [String(attr.get("vigor", "-")), String(attr.get("force", "-")), String(attr.get("focus", "-"))] + _market_master_label(button, "OfferAttributes", Rect2(0.04, 0.72, 0.92, 0.065), compact_attr, 5, Color("#584a3c"), HORIZONTAL_ALIGNMENT_CENTER) + var available := not guild_company.is_empty() and String(offer_id) in Array(guild_company.active_contract.get("available_recruit_offer_ids", [])) + var state_text := _tr("可招募 · %d 金", "AVAILABLE · %d COIN") % int(offer.get("list_price", 0)) if available else _tr("已招募 · 仍可查看", "HIRED · INSPECTABLE") + var state_color := Color("#3d7449") if available else Color("#806b4a") + _market_master_label(button, "OfferPrice", Rect2(0.04, 0.79, 0.92, 0.065), state_text, 5, state_color, HORIZONTAL_ALIGNMENT_CENTER) + return button + + +func _market_master_equipment_button(offer: Dictionary, index: int, area: Rect2) -> Button: + var offer_id := String(offer.get("offer_id", "")) + var selected := market_selected_kind == "equipment" and market_selected_offer_id == offer_id + var button := _market_master_hit_button("MarketOffer%s" % offer_id, area, select_market_offer.bind("equipment", offer_id), _tr("检查装备 %s", "Inspect equipment %s") % _content_name("equipment", String(offer.get("catalog_id", "")))) + button.set_meta("offer_id", offer_id) + button.set_meta("offer_kind", "equipment") + button.set_meta("selected", selected) + var highlight := Panel.new() + highlight.show_behind_parent = true + highlight.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + highlight.mouse_filter = Control.MOUSE_FILTER_IGNORE + var style := StyleBoxFlat.new() + style.bg_color = Color("#4d9abb", 0.16) if selected else Color(0, 0, 0, 0) + style.border_color = Color("#4d9abb") if selected else Color(0, 0, 0, 0) + style.set_border_width_all(2 if selected else 0) + style.set_corner_radius_all(5) + highlight.add_theme_stylebox_override("panel", style) + button.add_child(highlight) + var texture := _market_equipment_texture(String(offer.get("catalog_id", ""))) + if texture != null: + var item := TextureRect.new() + item.name = "EquipmentTexture" + item.texture = texture + item.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + item.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + item.texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS + item.mouse_filter = Control.MOUSE_FILTER_IGNORE + _market_master_place(item, Rect2(0.08, 0.02, 0.84, 0.70)) + button.add_child(item) + _market_master_label(button, "EquipmentLabel", Rect2(0.03, 0.70, 0.94, 0.24), _content_name("equipment", String(offer.get("catalog_id", ""))), 4, Color("#e9dec1"), HORIZONTAL_ALIGNMENT_CENTER) + return button + + +func _market_master_battle_button(offer: Dictionary, index: int, area: Rect2) -> Button: + var offer_id := String(offer.get("offer_id", "")) + var selected := market_selected_kind == "battle" and market_selected_offer_id == offer_id + var button := _market_master_hit_button("MarketOffer%s" % offer_id, area, select_market_offer.bind("battle", offer_id), _tr("检查路线 %d", "Inspect route %d") % (int(offer.get("risk", index)) + 1)) + button.set_meta("offer_id", offer_id) + button.set_meta("offer_kind", "battle") + button.set_meta("selected", selected) + var surface := Panel.new() + surface.show_behind_parent = true + surface.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + surface.mouse_filter = Control.MOUSE_FILTER_IGNORE + var style := StyleBoxFlat.new() + style.bg_color = Color("#f2e4c5", 0.85) + style.border_color = Color("#4d9abb") if selected else Color("#9c7c47") + style.set_border_width_all(3 if selected else 1) + style.set_corner_radius_all(7) + surface.add_theme_stylebox_override("panel", style) + button.add_child(surface) + var title := _market_master_label(button, "BattleRouteTitle", Rect2(0.08, 0.08, 0.84, 0.18), _tr("路线 %d", "ROUTE %d") % (int(offer.get("risk", index)) + 1), 9, Color("#2e2a23"), HORIZONTAL_ALIGNMENT_CENTER) + title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _market_master_label(button, "BattleRouteFacts", Rect2(0.08, 0.30, 0.84, 0.42), _tr("%d 波 · %d 敌人\n入场 %d · 不在市场开战", "%d WAVES · %d ENEMIES\nENTRY %d · NO BATTLE HERE") % [int(offer.get("wave_count", 0)), Array(offer.get("enemy_ids", [])).size(), int(offer.get("entry_cost", 0))], 6, Color("#4d4032"), HORIZONTAL_ALIGNMENT_CENTER) + return button + + +func _market_equipment_texture(catalog_id: String) -> Texture2D: + var paths := { + "EQ-002": "res://assets/equipment/eq_002_frontpin_greaves.png", + "EQ-012": "res://assets/equipment/eq_012_open_line_cord.png", + } + var path := String(paths.get(catalog_id, "")) + if path.is_empty() or not ResourceLoader.exists(path): + return null + return load(path) as Texture2D + + +func _build_market_master_ui(resolved: Dictionary, contract: Dictionary) -> void: + var canvas := market_master_design_canvas + if not is_instance_valid(canvas): + return + var overlay := Control.new() + overlay.name = "MarketMasterOverlay" + overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + overlay.mouse_filter = Control.MOUSE_FILTER_PASS + canvas.add_child(overlay) + var board_hash := String(contract.market_board.get("board_hash", "")) + _market_master_label(overlay, "MarketTitle", Rect2(0.41, 0.026, 0.30, 0.045), _tr("市场 · 稳定货单", "MARKET · STABLE LEDGER"), 8, Color("#2a2a25"), HORIZONTAL_ALIGNMENT_CENTER) + _market_master_label(overlay, "MarketBoardHash", Rect2(0.42, 0.069, 0.27, 0.026), "BOARD %s" % board_hash.substr(0, 8), 4, Color("#5f5037"), HORIZONTAL_ALIGNMENT_CENTER) + _market_master_badge(overlay, "MarketCompanyIcon", Rect2(0.035, 0.058, 0.050, 0.065), "♟", Color("#2f3d45"), _tr("Company owned count", "Company owned count")) + _market_master_label(overlay, "MarketCompanyStatus", Rect2(0.095, 0.050, 0.18, 0.060), _tr("Company %d | Line capacity %d", "Company %d | Line capacity %d") % [resolved.owned_recruits.size(), int(contract.line_capacity)], 7, Color("#2b241b")) + _market_master_label(overlay, "MarketOfferCount", Rect2(0.095, 0.108, 0.18, 0.036), _tr("可查看招募 %d", "%d Recruit offers visible") % contract.market_board.recruit_offers.size(), 5, Color("#6b593c")) + _market_master_badge(overlay, "MarketCoinIcon", Rect2(0.825, 0.072, 0.046, 0.060), "¢", Color("#9a7420"), _tr("Coin", "Coin")) + _market_master_label(overlay, "MarketCoinStatus", Rect2(0.875, 0.070, 0.105, 0.065), _tr("%d 金币", "%d COIN") % int(contract.coin), 7, Color("#3a2d1c"), HORIZONTAL_ALIGNMENT_CENTER) + _market_master_label(overlay, "MarketRefreshStatus", Rect2(0.82, 0.265, 0.16, 0.038), _tr("刷新:查看即不变", "REFRESH: INSPECT ONLY"), 5, Color("#e8d7b0"), HORIZONTAL_ALIGNMENT_CENTER) + _market_master_label(overlay, "MarketLockStatus", Rect2(0.82, 0.385, 0.16, 0.038), _tr("锁定:未提供", "LOCK: NOT AVAILABLE"), 5, Color("#e8d7b0"), HORIZONTAL_ALIGNMENT_CENTER) + _market_master_mode_button(overlay, "recruit", Rect2(0.805, 0.475, 0.175, 0.105), contract.market_board.recruit_offers.size()) + _market_master_mode_button(overlay, "equipment", Rect2(0.805, 0.585, 0.175, 0.105), contract.market_board.equipment_offers.size()) + _market_master_mode_button(overlay, "battle", Rect2(0.805, 0.695, 0.175, 0.105), contract.market_board.battle_offers.size()) + var offer_list := Control.new() + offer_list.name = "MarketOfferList" + offer_list.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + # The full-canvas grouping node must never swallow taps intended for the + # attached mode ledger. Individual offer buttons own their hit areas. + offer_list.mouse_filter = Control.MOUSE_FILTER_IGNORE + offer_list.z_index = 20 + overlay.add_child(offer_list) + var recruit_areas := [Rect2(0.15, 0.30, 0.16, 0.49), Rect2(0.31, 0.28, 0.22, 0.51), Rect2(0.53, 0.30, 0.24, 0.49)] + if market_category == "recruit": + for index in range(mini(3, contract.market_board.recruit_offers.size())): + offer_list.add_child(_market_master_offer_button(index, contract.market_board.recruit_offers[index], recruit_areas[index])) + elif market_category == "equipment": + for index in range(mini(4, contract.market_board.equipment_offers.size())): + # Keep the first socket clear of the attached Back command at the + # lower-left edge while retaining the tray's four-item rhythm. + var x := 0.18 + float(index) * 0.125 + offer_list.add_child(_market_master_equipment_button(contract.market_board.equipment_offers[index], index, Rect2(x, 0.82, 0.105, 0.16))) else: - categories.get_child(_market_categories().find(market_category)).call_deferred("grab_focus") + for index in range(mini(3, contract.market_board.battle_offers.size())): + offer_list.add_child(_market_master_battle_button(contract.market_board.battle_offers[index], index, recruit_areas[index])) + var context := Panel.new() + context.name = "MarketContextSurface" + _market_master_place(context, Rect2(0.79, 0.805, 0.20, 0.175)) + context.mouse_filter = Control.MOUSE_FILTER_IGNORE + context.z_index = 30 + var context_style := StyleBoxFlat.new() + context_style.bg_color = Color("#f2e4c5", 0.92) + context_style.border_color = Color("#a27e3c", 0.90) + context_style.set_border_width_all(2) + context_style.set_corner_radius_all(6) + context.add_theme_stylebox_override("panel", context_style) + overlay.add_child(context) + if market_selected_offer_id.is_empty(): + var empty := _market_master_label(overlay, "MarketNoSelection", Rect2(0.80, 0.825, 0.18, 0.075), _tr("选择一名候选\n查看职业、特质与价格", "SELECT AN OFFER\nTO REVIEW PROFESSION, TRAIT AND PRICE"), 6, Color("#5b4a34"), HORIZONTAL_ALIGNMENT_CENTER) + empty.z_index = 32 + else: + var offer := _market_offer_by_id(contract, market_selected_kind, market_selected_offer_id) + if offer.is_empty(): + _market_master_label(overlay, "MarketNoSelection", Rect2(0.80, 0.825, 0.18, 0.075), _tr("货单不可用\n未替换候选", "OFFER UNAVAILABLE\nNO SUBSTITUTE REROLLED"), 6, Color("#914b32"), HORIZONTAL_ALIGNMENT_CENTER) + else: + var title := _market_offer_title(market_selected_kind, offer) + _market_master_label(overlay, "MarketSelectionTitle", Rect2(0.805, 0.815, 0.17, 0.032), title, 6, Color("#2b241b"), HORIZONTAL_ALIGNMENT_CENTER) + var detail := _market_master_label(overlay, "MarketSelectionContext", Rect2(0.805, 0.847, 0.17, 0.060), _market_context_detail_short(market_selected_kind, offer), 5, Color("#544431"), HORIZONTAL_ALIGNMENT_CENTER) + detail.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + var review_area := Rect2(0.80, 0.905, 0.18, 0.065) + var review_name := "MarketReviewRecruit" if market_selected_kind == "recruit" else "MarketReviewEquipment" if market_selected_kind == "equipment" else "MarketReviewRoute" + var review_text := _tr("查看招募详情", "REVIEW RECRUIT") if market_selected_kind == "recruit" else _tr("查看装备", "REVIEW EQUIPMENT") if market_selected_kind == "equipment" else _tr("路线仅查看", "ROUTE INSPECT ONLY") + var review_callback := open_recruit_review if market_selected_kind == "recruit" else open_equipment_review if market_selected_kind == "equipment" else func(): show_market(guild_company) + var review := _market_master_hit_button(review_name, review_area, review_callback, review_text, market_selected_kind == "battle") + review.z_index = 35 + var review_style := StyleBoxFlat.new() + review_style.bg_color = Color("#3d7449", 0.94) if not review.disabled else Color("#77746c", 0.70) + review_style.border_color = Color("#f3d991", 0.96) + review_style.set_border_width_all(2) + review_style.set_corner_radius_all(5) + review.add_theme_stylebox_override("normal", review_style) + review.add_theme_stylebox_override("disabled", review_style) + var review_label := _market_master_label(review, "ButtonLabel", Rect2(0.04, 0.08, 0.92, 0.84), review_text, 5, Color("#fff4d2"), HORIZONTAL_ALIGNMENT_CENTER) + review_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + overlay.add_child(review) + if not market_notice.is_empty(): + var notice := _market_master_label(overlay, "MarketReceiptNotice", Rect2(0.37, 0.80, 0.39, 0.045), market_notice, 5, Color("#3d7449"), HORIZONTAL_ALIGNMENT_CENTER) + notice.z_index = 32 + var back := _market_master_hit_button("MarketBackGuild", Rect2(0.00, 0.80, 0.16, 0.18), back_to_guild, _tr("返回公会", "Back to Guild")) + back.z_index = 40 + var back_style := StyleBoxFlat.new() + back_style.bg_color = Color("#26343a", 0.82) + back_style.border_color = Color("#d6ad5d", 0.90) + back_style.set_border_width_all(2) + back_style.set_corner_radius_all(6) + back.add_theme_stylebox_override("normal", back_style) + back.add_theme_stylebox_override("hover", back_style) + _market_master_label(back, "ButtonLabel", Rect2(0.05, 0.18, 0.90, 0.64), _tr("返回公会", "BACK TO GUILD"), 6, Color("#fff4d2"), HORIZONTAL_ALIGNMENT_CENTER) + overlay.add_child(back) + var settings_button := _market_master_hit_button("MarketSettings", Rect2(0.90, 0.00, 0.10, 0.14), _market_open_settings, _tr("打开设置", "Open settings")) + settings_button.z_index = 40 + _market_master_label(settings_button, "ButtonLabel", Rect2(0.10, 0.12, 0.80, 0.76), "⚙", 11, Color("#3b3125"), HORIZONTAL_ALIGNMENT_CENTER) + overlay.add_child(settings_button) + var focus_nodes: Array[Control] = [settings_button] + var company_rail := overlay.find_child("MarketCompanyIcon", true, false) as Control + if company_rail != null: + company_rail.focus_mode = Control.FOCUS_NONE + for child in offer_list.get_children(): + if child is Control: + focus_nodes.append(child) + focus_nodes.append(overlay.find_child("MarketCategoryEquipment", true, false)) + focus_nodes.append(overlay.find_child("MarketCategoryBattle", true, false)) + var review_focus := overlay.find_child("MarketReviewRecruit", true, false) as Control + if review_focus == null: + review_focus = overlay.find_child("MarketReviewEquipment", true, false) as Control + if review_focus != null: + focus_nodes.append(review_focus) + focus_nodes.append(back) + var compact_focus: Array[Control] = [] + for node in focus_nodes: + if node != null and is_instance_valid(node): + compact_focus.append(node) + for index in range(compact_focus.size()): + var current := compact_focus[index] + current.focus_next = current.get_path_to(compact_focus[(index + 1) % compact_focus.size()]) + current.focus_previous = current.get_path_to(compact_focus[(index - 1 + compact_focus.size()) % compact_focus.size()]) + var initial_focus := overlay.find_child("MarketOffer%s" % market_selected_offer_id, true, false) as Control + if initial_focus == null: + # Keep the opening state visibly neutral: keyboard focus belongs to the + # settings command until the player deliberately chooses an offer. This + # prevents a focus ring from reading as an auto-recommendation. + initial_focus = settings_button + if initial_focus != null: + initial_focus.call_deferred("grab_focus") + + +func _market_context_detail_short(kind: String, offer: Dictionary) -> String: + match kind: + "recruit": + return _tr("%s · %s\n%d Coin · Company %d → %d", "%s · %s\n%d COIN · Company %d -> %d") % [_content_name("professions", String(offer.profession_id)), _content_name("traits", String(offer.trait_id)), int(offer.get("list_price", 0)), guild_company.owned_recruits.size(), guild_company.owned_recruits.size() + 1] + "equipment": + return _tr("%s\n%s", "%s\n%s") % [_content_name("equipment", String(offer.get("catalog_id", ""))), _equipment_effect_by_id(String(offer.get("catalog_id", "")))] + "battle": + return _tr("%d 波 · %d 敌人\n不在市场开战", "%d WAVES · %d ENEMIES\nNO BATTLE HERE") % [int(offer.get("wave_count", 0)), Array(offer.get("enemy_ids", [])).size()] + return "" + + +func _market_open_settings() -> void: + settings_caller = "market" + show_settings() func open_party_line() -> void: @@ -7665,6 +7976,8 @@ func _return_from_settings() -> void: show_risk_board(guild_company) elif settings_caller == "risk_commitment": show_risk_commitment(guild_company) + elif settings_caller == "market": + show_market(guild_company) elif settings_caller == "battle_opening": show_battle_opening(guild_company, false) elif settings_caller == "battle_live": diff --git a/runtime/tests/dev3/market_runtime_reconstruction_test.gd b/runtime/tests/dev3/market_runtime_reconstruction_test.gd new file mode 100644 index 00000000..4da244e8 --- /dev/null +++ b/runtime/tests/dev3/market_runtime_reconstruction_test.gd @@ -0,0 +1,169 @@ +extends SceneTree + +const EntryShell = preload("res://entry/entry_shell.gd") +const SettingsStore = preload("res://entry/settings_store.gd") +const DraftService = preload("res://entry/initial_company_draft_service.gd") +const ContractService = preload("res://entry/guild_contract_service.gd") + +const DRAFT_PATH := "user://m09_market_runtime_draft.json" +const COMPANY_PATH := "user://m09_market_runtime_company.json" +const SEED := "0f1e2d3c4b5a69788796a5b4c3d2e1f0" +const CONTRACT_OPERATION := "abcdef0123456789abcdef0123456789" + +var failures: Array[String] = [] +var parsed_touches := 0 + + +func _initialize() -> void: + call_deferred("run_test") + + +func run_test() -> void: + root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS + root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_EXPAND + root.content_scale_size = Vector2i(760, 360) + root.size = Vector2i(760, 360) + _cleanup() + var company := _create_company() + if company.is_empty(): + _finish() + return + var settings := SettingsStore.new().defaults() + settings.setup_completed = true + settings.locale = "zh_CN" + settings.text_scale = 1.0 + var shell := EntryShell.new() + shell.auto_start = false + shell.initial_company_service.path = DRAFT_PATH + shell.initial_company_service.company_path = COMPANY_PATH + root.add_child(shell) + await _frames(3) + shell.settings = settings + shell.show_market(company) + await _frames(5) + + var canvas := shell.find_child("MarketMasterCanvas", true, false) as Control + var master := shell.find_child("MarketStaticMaster", true, false) as TextureRect + expect(shell.current_screen == "market", "Market runtime opens on the active Contract") + expect(canvas != null and master != null, "contained canvas and clean static master are present") + if canvas != null: + var canvas_rect := canvas.get_global_rect() + expect(is_equal_approx(canvas_rect.size.x / canvas_rect.size.y, 16.0 / 9.0), "Market canvas preserves 16:9") + expect(Rect2(Vector2.ZERO, Vector2(760, 360)).encloses(canvas_rect), "Market canvas remains inside the viewport") + + var offer_list := shell.find_child("MarketOfferList", true, false) as Control + expect(offer_list != null and offer_list.get_child_count() == 3, "three equal Recruit offers are rendered") + if offer_list != null: + for offer_button in offer_list.get_children(): + var button := offer_button as Control + if button == null: + continue + expect(button.get_global_rect().size.y >= 48.0, "%s keeps a 48px logical target" % button.name) + expect(shell.find_child("MarketNoSelection", true, false) != null, "opening state has no auto-selected offer") + expect(shell.find_child("MarketCompanyStatus", true, false) != null and shell.find_child("MarketCoinStatus", true, false) != null, "Company/Line and Coin remain engine-owned visible facts") + expect(shell.find_child("MarketCategoryEquipment", true, false) != null and shell.find_child("MarketCategoryBattle", true, false) != null, "equipment and route modes remain reachable") + + var authority_hash := String(_load_company().company.company_hash) + var recruit_id := String(company.active_contract.market_board.recruit_offers[0].source_offer_id) + await _touch(shell.find_child("MarketOffer%s" % recruit_id, true, false)) + expect(shell.market_selected_kind == "recruit" and shell.market_selected_offer_id == recruit_id, "Recruit selection is local and source-ID bound") + expect(String(_load_company().company.company_hash) == authority_hash, "list selection does not mutate Company") + await _touch(shell.find_child("MarketReviewRecruit", true, false)) + expect(shell.current_screen == "recruit_review", "Review action opens the owning Recruit review boundary") + await _touch(shell.find_child("RecruitReviewCancel", true, false)) + var returned := shell.find_child("MarketOffer%s" % recruit_id, true, false) as Button + expect(shell.current_screen == "market" and shell.market_selected_offer_id == recruit_id and returned != null and returned.has_focus(), "cancel returns to the same offer and restores focus") + + await _touch(shell.find_child("MarketCategoryEquipment", true, false)) + var equipment_list := shell.find_child("MarketOfferList", true, false) as Control + expect(shell.market_category == "equipment" and equipment_list != null and equipment_list.get_child_count() == 4, "equipment mode exposes four inspectable offers") + var equipment_id := String(company.active_contract.market_board.equipment_offers[0].offer_id) + await _touch(shell.find_child("MarketOffer%s" % equipment_id, true, false)) + expect(shell.market_selected_kind == "equipment" and shell.find_child("MarketReviewRecruit", true, false) == null, "equipment selection cannot trigger Recruit hire") + await _touch(shell.find_child("MarketCategoryBattle", true, false)) + var battle_list := shell.find_child("MarketOfferList", true, false) as Control + expect(shell.market_category == "battle" and battle_list != null and battle_list.get_child_count() == 3, "route mode exposes three inspectable routes") + var battle_id := String(company.active_contract.market_board.battle_offers[0].offer_id) + await _touch(shell.find_child("MarketOffer%s" % battle_id, true, false)) + expect(shell.market_selected_kind == "battle" and shell.find_child("MarketReviewRecruit", true, false) == null, "route selection remains inspection-only") + expect(String(_load_company().company.company_hash) == authority_hash, "all Market inspections preserve authoritative Company state") + + shell.queue_free() + await _frames(3) + _finish() + + +func _create_company() -> Dictionary: + var draft_service := DraftService.new() + draft_service.path = DRAFT_PATH + draft_service.company_path = COMPANY_PATH + var draft: Dictionary = draft_service.begin_or_load(SEED) + if not draft.ok: + expect(false, "fixture creates Initial Company draft") + return {} + draft_service.set_selection([String(draft.draft.offers[0].offer_id), String(draft.draft.offers[2].offer_id)]) + var committed: Dictionary = draft_service.commit_company() + if not committed.ok: + expect(false, "fixture commits Initial Company") + return {} + var contract_service := ContractService.new() + contract_service.company_path = COMPANY_PATH + var created: Dictionary = contract_service.create_contract(CONTRACT_OPERATION, "CTR-R1-01", "standard") + expect(created.ok, "fixture creates first Contract") + return created.company if created.ok else {} + + +func _load_company() -> Dictionary: + var service := DraftService.new() + service.path = DRAFT_PATH + service.company_path = COMPANY_PATH + return service.load_company() + + +func _touch(control: Control) -> void: + if control == null: + expect(false, "touch target exists") + return + var center := root.get_final_transform() * control.get_global_rect().get_center() + var press := InputEventScreenTouch.new() + press.position = center + press.pressed = true + press.index = 0 + Input.parse_input_event(press) + await _frames(2) + var release := InputEventScreenTouch.new() + release.position = center + release.pressed = false + release.index = 0 + Input.parse_input_event(release) + parsed_touches += 1 + await _frames(5) + + +func _cleanup() -> void: + for base in [DRAFT_PATH, COMPANY_PATH]: + for suffix in ["", ".bak", ".candidate", ".bak.candidate", ".corrupt"]: + var path := "%s%s" % [base, suffix] + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) + + +func _frames(count: int) -> void: + for _frame in range(count): + await process_frame + + +func _finish() -> void: + _cleanup() + if failures.is_empty(): + print("ABG_M09_MARKET_RUNTIME_OK canvas=16:9 offers=3 equipment=4 routes=3 inspect_only=true review_boundary=true company_unchanged=true parsed_touch=%d" % parsed_touches) + quit(0) + else: + for failure in failures: + push_error(failure) + quit(1) + + +func expect(condition: bool, message: String) -> void: + if not condition: + failures.append("M09 Market Runtime: %s" % message)