feat(shp001): reconstruct market runtime

This commit is contained in:
2026-08-25 02:44:46 +08:00
parent aca0b4862d
commit ef482ca9e5
2 changed files with 578 additions and 96 deletions
+409 -96
View File
@@ -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":
@@ -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)