Nicht abgenommen: Nach dem ersten Spieltest wurde entschieden, Champions of Luneraria nach Unity 6 zu portieren. Dieser Stand ist die Referenz für das HUD im Unity-Projekt (../luneraria-unity). - HUD neu aus Komponenten mit eigenem Theme, Porträts aus den echten Modellen (einmal gerendert, zwischengespeichert), Lane-Übersicht mit deterministischer Bedrohung aus öffentlichem Zustand, Bossgrotte, nächste Welle, Ereignisse, Minimap mit zusammengefassten Armeen, Held/Items/Talente, Einheitendetails, Angriffschampions, Bereit-Sperre, UI-Größe, Team-Formen statt nur Farben - Privatsphäre über LuneHudModel: fremdes Gold und fremde Entwürfe erscheinen nie - Keine Regeländerung: Regeln 91/91, M2 73/73, Netz 43/43; verify_g3 715/715; verify_m1 38/38 und verify_m2 14/14 mit angepassten UI-Treibern - Spieltest bis Welle 8 über die echte Oberfläche, Befunde in G3-HUD.md - HELDEN-KONZEPT.md: vom Projektinhaber vorgegebene Helden (nicht umgesetzt) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
778 lines
39 KiB
GDScript
778 lines
39 KiB
GDScript
extends Node
|
||
## G3 acceptance: the in-match HUD in the real game (LuneGame with the real HUD, real input).
|
||
## Part A layout: every region inside the window and free of overlaps at 1280×720, 1920×1080,
|
||
## 2560×1440, ultrawide 2560×1080 and the minimum window, at several UI scales
|
||
## Part B match status: Ancients, towers, lanes, unlimited waves (two and three digits), gold
|
||
## Part C hero, items, normal units (no item slots), lane orders with the grotto warning
|
||
## Part D attack champions (temporary), the boss grotto in every state, target lane, banner
|
||
## Part E Ready lock, bots, the network guest (privacy, same public information), disconnect
|
||
## Part F input: minimap click, camera hotkeys, lane clicks, no click passthrough, tooltips
|
||
## Part G events, critical alerts and "Bewegung reduzieren"
|
||
## Part H update rates and the portrait cache
|
||
## Run (window needed): godot --path . --resolution 1920x1080 res://tools/luneraria/verify_g3.tscn
|
||
const OUT := "res://docs/luneraria/logs/g3/verify-g3.json"
|
||
const Game = preload("res://scripts/luneraria/lune_game.gd")
|
||
const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd")
|
||
const BUNDS: Array = ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]
|
||
|
||
var checks: int = 0
|
||
var failures: Array[String] = []
|
||
var facts: Dictionary = {}
|
||
var parts: Dictionary = {}
|
||
var _part: String = ""
|
||
var game: Node3D
|
||
|
||
func check(ok: bool, label: String) -> void:
|
||
checks += 1
|
||
parts[_part] = int(parts.get(_part, 0)) + 1
|
||
if not ok:
|
||
failures.append("[%s] %s" % [_part, label])
|
||
printerr("G3_FAIL: [", _part, "] ", label)
|
||
|
||
func part(name: String) -> void:
|
||
_part = name
|
||
print("G3_PART ", name)
|
||
|
||
func _ready() -> void:
|
||
call_deferred("run")
|
||
|
||
func run() -> void:
|
||
Soundscape.enabled = false
|
||
var started: int = Time.get_ticks_msec()
|
||
var scale_before: float = Preferences.ui_scale
|
||
var motion_before: bool = Preferences.reduced_motion
|
||
Preferences.reduced_motion = false
|
||
part("A layout")
|
||
await _layout()
|
||
part("B status")
|
||
await _status()
|
||
part("C hero and units")
|
||
await _hero_and_units()
|
||
part("D champions and boss")
|
||
await _champions_and_boss()
|
||
part("E ready, bots, network")
|
||
await _ready_bots_network()
|
||
part("F input")
|
||
await _input_checks()
|
||
part("G events and motion")
|
||
await _events_and_motion()
|
||
part("H rates and portraits")
|
||
await _rates()
|
||
await _free_game()
|
||
Preferences.ui_scale = scale_before
|
||
Preferences.reduced_motion = motion_before
|
||
facts.seconds = (Time.get_ticks_msec() - started) / 1000.0
|
||
facts.checks_per_part = parts
|
||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT.get_base_dir()))
|
||
var file := FileAccess.open(OUT, FileAccess.WRITE)
|
||
file.store_string(JSON.stringify({"checks": checks, "failures": failures, "facts": facts}, "\t"))
|
||
file.close()
|
||
print("G3_VERIFY_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()])
|
||
get_tree().quit(0 if failures.is_empty() else 1)
|
||
|
||
# ---------------------------------------------------------------- helpers
|
||
func frames(count: int = 1) -> void:
|
||
for i: int in count:
|
||
await get_tree().process_frame
|
||
|
||
func wait(seconds: float) -> void:
|
||
await get_tree().create_timer(seconds).timeout
|
||
|
||
func _free_game() -> void:
|
||
if game != null and is_instance_valid(game):
|
||
game.queue_free()
|
||
await frames(2)
|
||
game = null
|
||
|
||
func fresh(seed: int = 4, slot: int = 1) -> LuneMatch:
|
||
await _free_game()
|
||
game = Game.new()
|
||
add_child(game)
|
||
game.start({"seed": seed, "human_slot": slot, "bunds": BUNDS})
|
||
await frames(3)
|
||
return game.match_state
|
||
|
||
func hud() -> Control:
|
||
return game.hud
|
||
|
||
func sync() -> void:
|
||
game.hud.mark_dirty()
|
||
await frames(2)
|
||
|
||
func state() -> LuneMatch:
|
||
return game.match_state
|
||
|
||
func command(value: Dictionary) -> Dictionary:
|
||
var result: Dictionary = state().submit(game.human_slot, value)
|
||
game._rebuild_preview()
|
||
return result
|
||
|
||
func point_event(at: Vector2, button: int, pressed: bool) -> InputEventMouseButton:
|
||
var event := InputEventMouseButton.new()
|
||
event.button_index = button
|
||
event.position = at
|
||
event.global_position = at
|
||
event.pressed = pressed
|
||
return event
|
||
|
||
func move_to(at: Vector2) -> void:
|
||
var motion := InputEventMouseMotion.new()
|
||
motion.position = at
|
||
motion.global_position = at
|
||
get_viewport().push_input(motion, true)
|
||
await get_tree().process_frame
|
||
|
||
func wheel_at(at: Vector2) -> void:
|
||
await move_to(at)
|
||
for pressed: bool in [true, false]:
|
||
get_viewport().push_input(point_event(at, MOUSE_BUTTON_WHEEL_UP, pressed), true)
|
||
await frames(2)
|
||
|
||
func click_at(at: Vector2, button: int = MOUSE_BUTTON_LEFT) -> void:
|
||
await move_to(at)
|
||
for pressed: bool in [true, false]:
|
||
get_viewport().push_input(point_event(at, button, pressed), true)
|
||
await get_tree().process_frame
|
||
|
||
func click(control: Control) -> void:
|
||
if control == null or not control.is_visible_in_tree():
|
||
check(false, "control to click exists and is visible (%s)" % (control.name if control != null else "null"))
|
||
return
|
||
await click_at(control.get_global_rect().get_center())
|
||
|
||
func key(code: int) -> void:
|
||
for pressed: bool in [true, false]:
|
||
var event := InputEventKey.new()
|
||
event.keycode = code
|
||
event.physical_keycode = code
|
||
event.pressed = pressed
|
||
get_viewport().push_input(event)
|
||
await get_tree().process_frame
|
||
|
||
func texts(root: Node) -> String:
|
||
var result: PackedStringArray = []
|
||
for node: Node in root.find_children("*", "", true, false):
|
||
if node is Label and (node as Label).is_visible_in_tree():
|
||
result.append((node as Label).text)
|
||
elif node is Button and (node as Button).is_visible_in_tree():
|
||
result.append((node as Button).text)
|
||
return "\n".join(result)
|
||
|
||
func rect_inside(inner: Rect2, outer: Rect2) -> bool:
|
||
return inner.position.x >= outer.position.x - 1.0 and inner.position.y >= outer.position.y - 1.0 \
|
||
and inner.end.x <= outer.end.x + 1.0 and inner.end.y <= outer.end.y + 1.0
|
||
|
||
func armies(round_number: int) -> void:
|
||
var match_state: LuneMatch = state()
|
||
match_state.round_number = round_number
|
||
for slot: int in range(6):
|
||
match_state.players[slot].draft.roster.clear()
|
||
match_state.players[slot].draft.gold = 100000
|
||
LuneBot.plan(match_state, slot, "ausgewogen")
|
||
for entry: Dictionary in match_state.players[slot].draft.roster:
|
||
if entry.lane == LuneMatch.GROTTO:
|
||
entry.lane = match_state.players[slot].lane
|
||
var hero: Dictionary = match_state.players[slot].draft.hero
|
||
if not hero.is_empty() and hero.lane == LuneMatch.GROTTO:
|
||
hero.lane = match_state.players[slot].lane
|
||
|
||
func advance(seconds: float) -> void:
|
||
var match_state: LuneMatch = state()
|
||
game.paused = false
|
||
while match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat.elapsed < seconds:
|
||
for i: int in range(20):
|
||
if match_state.phase != LuneMatch.Phase.COMBAT or match_state.combat.elapsed >= seconds:
|
||
break
|
||
match_state.step_combat()
|
||
await get_tree().process_frame
|
||
game.paused = true
|
||
|
||
# ---------------------------------------------------------------- A layout
|
||
func _layout() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
match_state.round_number = 3
|
||
match_state.players[game.human_slot].draft.gold = 2000
|
||
game._starter()
|
||
command({"type": "hero_buy", "hero_id": "held_quellgeflecht"})
|
||
for index: int in range(6):
|
||
command({"type": "buy", "type_id": match_state.base_ids(match_state.players[game.human_slot].bund)[index % 5]})
|
||
var roster: Array = match_state.players[game.human_slot].draft.roster
|
||
hud().select_unit(str(roster[0].uid))
|
||
var sizes: Array[Vector2i] = [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440), Vector2i(2560, 1080), Vector2i(1152, 720)]
|
||
var measured: Array = []
|
||
for window: Vector2i in sizes:
|
||
get_window().size = window
|
||
for wanted: float in [.9, 1.0, 1.3]:
|
||
Preferences.ui_scale = wanted
|
||
Preferences.changed.emit()
|
||
await frames(6)
|
||
var label: String = "%dx%d @%d%%" % [window.x, window.y, int(wanted * 100.0)]
|
||
var area: Rect2 = get_viewport().get_visible_rect()
|
||
measured.append([label, get_window().size, area.size, hud().ui_scale])
|
||
check(hud().ui_scale <= wanted + .001, "%s: UI scale never exceeds the chosen size (%.2f)" % [label, hud().ui_scale])
|
||
if wanted <= 1.0:
|
||
check(is_equal_approx(hud().ui_scale, wanted) or area.size.x / wanted < Hud.MIN_AREA.x or area.size.y / wanted < Hud.MIN_AREA.y, "%s: the chosen UI scale applies when it fits" % label)
|
||
_regions_ok(label + " planning, unit selected")
|
||
_labels_fit(label)
|
||
# Combat layout of the same window at 100 %.
|
||
get_window().size = Vector2i(1920, 1080)
|
||
Preferences.ui_scale = 1.0
|
||
Preferences.changed.emit()
|
||
await frames(6)
|
||
hud().open_hero()
|
||
await frames(4)
|
||
var modal: Rect2 = hud()._modal.panel.get_global_rect()
|
||
check(rect_inside(modal, get_viewport().get_visible_rect()), "hero window inside the screen")
|
||
var covered: float = modal.get_area() / get_viewport().get_visible_rect().get_area()
|
||
check(covered < .75, "hero window leaves the field partly visible (%.0f %% covered)" % (covered * 100.0))
|
||
hud().close_modal()
|
||
hud().open_champions()
|
||
await frames(4)
|
||
_regions_ok("1920x1080 champions tab")
|
||
hud().open_troops()
|
||
hud().clear_selection()
|
||
game._on_ready()
|
||
await frames(4)
|
||
for window: Vector2i in [Vector2i(1280, 720), Vector2i(2560, 1080), Vector2i(1920, 1080)]:
|
||
get_window().size = window
|
||
await frames(6)
|
||
_regions_ok("%dx%d combat" % [window.x, window.y])
|
||
facts.layouts = measured
|
||
|
||
func _regions_ok(label: String) -> void:
|
||
var area: Rect2 = get_viewport().get_visible_rect()
|
||
var regions: Dictionary = hud().regions()
|
||
for key: String in regions:
|
||
check(rect_inside(regions[key], area), "%s: %s inside the window (%s in %s)" % [label, key, regions[key], area])
|
||
var keys: Array = regions.keys()
|
||
for i: int in range(keys.size()):
|
||
for j: int in range(i + 1, keys.size()):
|
||
var a: Rect2 = regions[keys[i]]
|
||
var b: Rect2 = regions[keys[j]]
|
||
check(not a.grow(-1.0).intersects(b.grow(-1.0)), "%s: %s and %s do not overlap" % [label, keys[i], keys[j]])
|
||
# The right column never takes more than 30 % of the width.
|
||
if regions.has("right"):
|
||
check((regions.right as Rect2).size.x <= area.size.x * .3, "%s: context column at most 30 %% wide" % label)
|
||
|
||
## Unclipped labels must fit their box (clipped ones end with an ellipsis by design).
|
||
func _labels_fit(label: String) -> void:
|
||
var bad: PackedStringArray = []
|
||
for node: Node in hud().find_children("*", "Label", true, false):
|
||
var text_label := node as Label
|
||
if not text_label.is_visible_in_tree() or text_label.clip_text or text_label.autowrap_mode != TextServer.AUTOWRAP_OFF or text_label.text.is_empty():
|
||
continue
|
||
if text_label.get_minimum_size().x > text_label.size.x + 1.5:
|
||
bad.append(text_label.text)
|
||
check(bad.is_empty(), "%s: no label wider than its box %s" % [label, str(bad)])
|
||
|
||
# ---------------------------------------------------------------- B status
|
||
func _status() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
get_window().size = Vector2i(1920, 1080)
|
||
await frames(4)
|
||
check(hud().lanes.size() == 3, "three lane indicators")
|
||
for lane: String in LuneMap.LANES:
|
||
var indicator: Control = hud().lanes[lane]
|
||
check(indicator.is_visible_in_tree() and rect_inside(indicator.get_global_rect(), get_viewport().get_visible_rect()), "lane %s visible" % lane)
|
||
match_state.building_hp["blau_ancient"] = 5000.0
|
||
match_state.building_hp["rot_ancient"] = 2600.0
|
||
match_state.building_hp["rot_top_outer"] = 0.0
|
||
match_state.building_hp["blau_mid_outer"] = 3000.0
|
||
await sync()
|
||
var own: Dictionary = hud()._ancients[0]
|
||
var enemy: Dictionary = hud()._ancients[1]
|
||
check((own.value as Label).text == "5.000 / 12.000", "own Ancient HP shown (%s)" % (own.value as Label).text)
|
||
check(absf((own.bar as LuneHpBar).fraction - 5000.0 / 12000.0) < .002, "own Ancient bar follows HP")
|
||
check((enemy.value as Label).text == "2.600 / 12.000" and (enemy.badge as LuneStatusBadge).text == "KRITISCH", "enemy Ancient below 30 %% is KRITISCH (%s)" % (enemy.badge as LuneStatusBadge).text)
|
||
check((own.caption as Label).text.contains("BLAU") and (enemy.caption as Label).text.contains("ROT"), "Ancients are named by team, not only coloured")
|
||
var top: LuneLaneIndicator = hud().lanes["top"]
|
||
check(not top.towers[2].alive and top.towers[3].alive, "TOP: enemy Grenzwacht shown destroyed, Bastion alive")
|
||
var mid: LuneLaneIndicator = hud().lanes["mid"]
|
||
check(mid.towers[1].alive and absf(mid.towers[1].fraction - .5) < .011, "MID: own Grenzwacht at 50 %% (%.2f)" % mid.towers[1].fraction)
|
||
check(mid.level == LuneLaneThreat.Level.CONTESTED or mid.level == LuneLaneThreat.Level.PRESSURE, "MID threat rises with a damaged tower (%s)" % LuneLaneThreat.NAMES[mid.level])
|
||
check(top.towers[0].team == 0 and top.towers[2].team == 1, "tower chain runs from the own base to the enemy base")
|
||
# Threat is deterministic and public: the same for every viewer of the same team.
|
||
var a: Dictionary = LuneLaneThreat.evaluate(match_state, 0, "mid")
|
||
var b: Dictionary = LuneLaneThreat.evaluate(match_state, 0, "mid")
|
||
check(a.level == b.level and a.reasons == b.reasons, "lane threat is deterministic")
|
||
match_state.building_hp["blau_bot_outer"] = 0.0
|
||
match_state.building_hp["blau_bot_inner"] = 0.0
|
||
await sync()
|
||
check((hud().lanes["bot"] as LuneLaneIndicator).level == LuneLaneThreat.Level.CRITICAL, "both lane towers down: BOTTOM is KRITISCH")
|
||
for round_number: int in [14, 21, 137]:
|
||
match_state.round_number = round_number
|
||
await sync()
|
||
var wave: Label = hud()._wave
|
||
check(wave.text == "WELLE %d" % round_number, "wave %d shown as \"%s\"" % [round_number, wave.text])
|
||
check(not wave.text.contains("/"), "no wave limit in the display (%s)" % wave.text)
|
||
check(wave.get_minimum_size().x <= wave.size.x + 1.0 and rect_inside(hud()._top.get_global_rect(), get_viewport().get_visible_rect()), "wave %d fits the top bar" % round_number)
|
||
match_state.round_number = 3
|
||
match_state.players[game.human_slot].draft.gold = 420
|
||
await sync()
|
||
check(hud()._gold.amount.text == "420", "own gold shown (%s)" % hud()._gold.amount.text)
|
||
check(hud()._gold.detail.text.contains(str(match_state.balance.round_income(3))), "base income of the next settlement shown")
|
||
var ids: Array[String] = match_state.base_ids(match_state.players[game.human_slot].bund)
|
||
await click(hud()._shop_buttons[ids[0]])
|
||
await sync()
|
||
var cost: int = int(DataCatalog.get_unit(ids[0]).cost)
|
||
check(match_state.players[game.human_slot].draft.gold == 420 - cost and hud()._gold.amount.text == str(420 - cost), "a card click buys and the gold display follows")
|
||
for slot: int in range(6):
|
||
if slot != game.human_slot:
|
||
match_state.players[slot].draft.gold = 7777 + slot
|
||
match_state.players[slot].gold = 6666 + slot
|
||
await sync()
|
||
var shown: String = texts(hud())
|
||
var leaked: bool = false
|
||
for slot: int in range(6):
|
||
if slot != game.human_slot:
|
||
for value: int in [7777 + slot, 6666 + slot]:
|
||
leaked = leaked or shown.contains(str(value)) or shown.contains(LuneUiTheme.number(value))
|
||
check(not leaked, "no other player's gold appears anywhere in the HUD")
|
||
|
||
# ---------------------------------------------------------------- C hero and units
|
||
func _hero_and_units() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
match_state.round_number = 5
|
||
match_state.players[game.human_slot].draft.gold = 3000
|
||
await sync()
|
||
await click(hud().hero_card)
|
||
await frames(3)
|
||
check(hud().modal_open() and hud().hero_detail.recruit_buttons.size() == 3, "hero card opens the recruiting window with three heroes")
|
||
await click(hud().hero_detail.recruit_buttons["held_ringborke"])
|
||
await sync()
|
||
var hero: Dictionary = match_state.players[game.human_slot].draft.hero
|
||
check(not hero.is_empty() and hero.hero_id == "held_ringborke", "hero recruited through the window")
|
||
hero.xp = 500
|
||
await sync()
|
||
check(hud().hero_card._level.text == "STUFE 4", "hero level from experience (%s)" % hud().hero_card._level.text)
|
||
check(absf(hud().hero_card._xp.fraction - (500.0 - 480.0) / (720.0 - 480.0)) < .01, "experience progress to the next level")
|
||
check(hud().hero_card._slots.size() == 3, "hero card shows three item slots")
|
||
var definition: Dictionary = LuneContent.hero_definition(hero)
|
||
hud().open_hero()
|
||
await frames(3)
|
||
var window_text: String = texts(hud()._modal)
|
||
check(window_text.contains(LuneUiTheme.number(definition.max_hp)) and window_text.contains(LuneUiTheme.decimal(definition.damage)), "hero window shows the real level-scaled HP and damage")
|
||
check(window_text.contains(str(DataCatalog.get_unit("w04").ability_description)), "hero window shows the real ability text")
|
||
hud().open_items()
|
||
await frames(3)
|
||
var cards: Dictionary = hud().hero_detail.item_cards
|
||
check(cards.size() == LuneContent.ITEMS.size(), "all items listed")
|
||
check((cards["kristallsplitter"] as LuneItemSlot).state == LuneItemSlot.State.AVAILABLE, "level-4 hero may buy Kristallsplitter")
|
||
await click(cards["glutklinge"])
|
||
await sync()
|
||
check((hero.items as Array).has("glutklinge"), "item bought through its card")
|
||
cards = hud().hero_detail.item_cards
|
||
check((cards["glutklinge"] as LuneItemSlot).state == LuneItemSlot.State.UNIQUE, "a carried item shows GETRAGEN (unique)")
|
||
check(hud().hero_card._slots[0].glyph == "glutklinge", "the bottom hero card shows the equipped item")
|
||
command({"type": "item_buy", "item_id": "basaltschild", "uid": hero.uid})
|
||
command({"type": "item_buy", "item_id": "rankenmantel", "uid": hero.uid})
|
||
await sync()
|
||
check((hud().hero_detail.item_cards["funkenfokus"] as LuneItemSlot).state == LuneItemSlot.State.FULL, "fourth item: slots full")
|
||
match_state.players[game.human_slot].draft.gold = 10
|
||
command({"type": "item_sell", "item_id": "rankenmantel"})
|
||
match_state.players[game.human_slot].draft.gold = 10
|
||
await sync()
|
||
check((hud().hero_detail.item_cards["funkenfokus"] as LuneItemSlot).state == LuneItemSlot.State.UNAFFORDABLE, "missing gold: item marked unaffordable")
|
||
var young: Dictionary = hero.duplicate(true)
|
||
hero.xp = 0
|
||
(hero.items as Array).erase("basaltschild")
|
||
match_state.players[game.human_slot].draft.gold = 3000
|
||
await sync()
|
||
check((hud().hero_detail.item_cards["kristallsplitter"] as LuneItemSlot).state == LuneItemSlot.State.INCOMPATIBLE, "level-1 hero: Kristallsplitter needs level 3")
|
||
hero.xp = young.xp
|
||
hud().close_modal()
|
||
# A normal unit: details without item slots, lane order with the grotto warning.
|
||
var ids: Array[String] = match_state.base_ids(match_state.players[game.human_slot].bund)
|
||
command({"type": "buy", "type_id": ids[1]})
|
||
await sync()
|
||
var entry: Dictionary = match_state.players[game.human_slot].draft.roster[0]
|
||
var chip: Button = hud()._chips.get(str(entry.uid))
|
||
await click(chip)
|
||
await sync()
|
||
var detail: LuneUnitDetail = hud().unit_detail
|
||
check(detail.visible and detail.uid == str(entry.uid), "army chip selects the unit")
|
||
var slots: int = 0
|
||
for node: Node in detail.find_children("*", "", true, false):
|
||
slots += int(node is LuneItemSlot)
|
||
check(slots == 0 and not texts(detail).contains("ITEMS"), "normal units have no item slots")
|
||
var data: Dictionary = DataCatalog.get_unit(ids[1])
|
||
var detail_text: String = texts(detail)
|
||
check(detail_text.contains(LuneUiTheme.number(data.max_hp)) and detail_text.contains(LuneUiTheme.decimal(data.damage)) and detail_text.contains(str(data.ability_description)), "unit details show real HP, damage and ability")
|
||
check(detail_text.contains(str(DataCatalog.get_unit(str(data.upgrade_id)).display_name)) and detail_text.contains(str(int(DataCatalog.get_unit(str(data.upgrade_id)).cost))), "upgrade target and cost shown")
|
||
await click(detail._lanes["top"])
|
||
await sync()
|
||
check(str(match_state.players[game.human_slot].draft.roster[0].lane) == "top", "lane order TOP through the detail panel")
|
||
match_state.boss.next_available_round = 1
|
||
await sync()
|
||
await click(detail._lanes[LuneMatch.GROTTO])
|
||
await sync()
|
||
check(str(match_state.players[game.human_slot].draft.roster[0].lane) == LuneMatch.GROTTO and detail._warning.visible and detail._warning.text == LuneUnitDetail.GROTTO_WARNING, "grotto order shows the missing-lane warning")
|
||
match_state.boss.next_available_round = 9
|
||
command({"type": "assign", "uid": entry.uid, "lane": "mid"})
|
||
await sync()
|
||
check((detail._lanes[LuneMatch.GROTTO] as Button).disabled and not (detail._lanes["top"] as Button).disabled, "a closed grotto is not offered; lanes stay active")
|
||
var gold: int = match_state.players[game.human_slot].draft.gold
|
||
await click(detail._upgrade)
|
||
await sync()
|
||
check(str(match_state.players[game.human_slot].draft.roster[0].type_id) == str(data.upgrade_id) and match_state.players[game.human_slot].draft.gold == gold - int(DataCatalog.get_unit(str(data.upgrade_id)).cost), "upgrade through the detail panel")
|
||
# Esc clears the selection.
|
||
await key(KEY_ESCAPE)
|
||
await sync()
|
||
check(not hud().unit_detail.visible, "Esc clears the unit selection")
|
||
|
||
# ---------------------------------------------------------------- D champions and boss
|
||
func _champions_and_boss() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
match_state.round_number = 14
|
||
match_state.players[game.human_slot].draft.gold = 1000
|
||
await sync()
|
||
hud().open_champions()
|
||
await sync()
|
||
var panel: LuneChampionPanel = hud().champion_panel
|
||
check(panel.visible and panel.cards.size() == 3, "champion tab with three champions")
|
||
for id: String in panel.cards:
|
||
var card: LuneUnitCard = panel.cards[id]
|
||
check(card.champion and card.theme_type_variation == "HudChampionCard" and card._tag.visible, "%s card uses the temporary champion look (1 WELLE)" % id)
|
||
await click(panel._lane_buttons["bot"])
|
||
await click(panel.cards["ch_belagerung"])
|
||
await sync()
|
||
var bookings: Array = match_state.players[game.human_slot].draft.new_bookings
|
||
check(bookings.size() == 1 and str(bookings[0].lane) == "bot", "champion booked on the chosen target lane")
|
||
check(texts(panel).contains("Einsatz in Welle 15 – BOTTOM"), "booking says \"Einsatz in Welle 15 – BOTTOM\"")
|
||
check(match_state.players[game.human_slot].draft.roster.is_empty(), "a champion never joins the permanent army")
|
||
hud().open_troops()
|
||
# Boss states.
|
||
var states: Array = []
|
||
match_state.boss = {"next_available_round": 1, "claim": {}}
|
||
await sync()
|
||
states.append(hud().boss_card._badge.text)
|
||
check(hud().boss_card._badge.text == "BOSS VERFÜGBAR", "boss available (%s)" % hud().boss_card._badge.text)
|
||
match_state.boss = {"next_available_round": 16, "claim": {}}
|
||
await sync()
|
||
states.append(hud().boss_card._badge.text)
|
||
check(hud().boss_card._badge.text == "COOLDOWN – 2 RUNDEN", "boss cooldown with rounds left (%s)" % hud().boss_card._badge.text)
|
||
match_state.boss = {"next_available_round": 17, "claim": {"team": 0, "won_round": 13, "deploy_round": 14, "chooser_slot": game.human_slot, "lane": "mid"}}
|
||
await sync()
|
||
states.append(hud().boss_card._badge.text)
|
||
check(hud().boss_card._badge.text == "EINSATZ NÄCHSTE WELLE", "boss deployment announced (%s)" % hud().boss_card._badge.text)
|
||
var banner: String = texts(hud()._banners)
|
||
check(banner.contains("Welle 14") and banner.contains("MID") and banner.contains(LuneUiTheme.number(LuneContent.BOSS_BOUNTY + LuneContent.BOSS_BOUNTY_PER_ROUND * 14)), "banner names wave, lane and the bounty (%s)" % banner)
|
||
check(hud().boss_card._choice.visible, "the boss chooser gets the lane choice")
|
||
await click(hud().boss_card._lane_buttons["bot"])
|
||
await sync()
|
||
check(str(match_state.players[game.human_slot].draft.boss_lane) == "bot" and texts(hud()._banners).contains("BOTTOM"), "chooser moves the boss to BOTTOM; the banner follows")
|
||
# Other seats see the public lane only.
|
||
var other := LuneHudModel.new(match_state, 0)
|
||
check(str(other.boss().lane) == "mid", "the chooser's uncommitted lane change stays private")
|
||
match_state.boss.claim.team = 1
|
||
match_state.boss.claim.chooser_slot = 3
|
||
await sync()
|
||
check(texts(hud()._banners).contains("Kopfgeld für euch"), "enemy boss: banner warns and names the defenders' bounty")
|
||
# Combat: the deployed boss attacks; then the grotto fight is contested.
|
||
game._on_ready()
|
||
await frames(3)
|
||
await sync()
|
||
states.append(hud().boss_card._badge.text)
|
||
check(hud().boss_card._badge.text.begins_with("BOSS GREIFT AN"), "deployed boss: BOSS GREIFT AN (%s)" % hud().boss_card._badge.text)
|
||
var boss_unit: LuneUnit = null
|
||
for unit: LuneUnit in match_state.combat.units:
|
||
if unit.kind == "boss":
|
||
boss_unit = unit
|
||
check(boss_unit != null and boss_unit.team == 1, "the boss fights for its owner")
|
||
var view: Node3D = game._views.get(boss_unit.id) if boss_unit != null else null
|
||
check(view != null and view._owner_ring != null and view._tag.text.contains("für Rot"), "the boss carries an ownership ring and label in the world")
|
||
match_state = await fresh()
|
||
match_state.round_number = 6
|
||
match_state.boss = {"next_available_round": 1, "claim": {}}
|
||
for slot: int in [1, 4]:
|
||
match_state.players[slot].draft.gold = 2400
|
||
LuneBot.plan(match_state, slot, "ausgewogen")
|
||
for entry: Dictionary in match_state.players[slot].draft.roster:
|
||
entry.lane = LuneMatch.GROTTO
|
||
game._on_ready()
|
||
await frames(3)
|
||
await sync()
|
||
states.append(hud().boss_card._badge.text)
|
||
check(hud().boss_card._badge.text == "BOSS UMKÄMPFT", "expeditions in the grotto: BOSS UMKÄMPFT (%s)" % hud().boss_card._badge.text)
|
||
facts.boss_badges = states
|
||
|
||
# ---------------------------------------------------------------- E ready, bots, network
|
||
func _ready_bots_network() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
match_state.round_number = 2
|
||
match_state.players[game.human_slot].draft.gold = 600
|
||
game._starter()
|
||
await sync()
|
||
var members: Array = hud().model.members(hud().model.team())
|
||
var bots: int = 0
|
||
for member: Dictionary in members:
|
||
bots += int(member.status == "bot")
|
||
check(bots == 2, "two bots in the own team")
|
||
var badges: String = texts(hud()._team)
|
||
check(badges.contains("BOT") and badges.contains("PLANT"), "team panel marks bots and the planning human")
|
||
# Ready lock: planning continues (another human would still plan) but the own plan is binding.
|
||
match_state.set_ready(game.human_slot)
|
||
await sync()
|
||
var roster_before: int = match_state.players[game.human_slot].draft.roster.size()
|
||
check(hud()._ready_button.disabled and hud()._ready_check.visible, "Bereit shows as committed")
|
||
var all_locked: bool = true
|
||
for id: String in hud()._shop_buttons:
|
||
all_locked = all_locked and (hud()._shop_buttons[id] as Button).disabled
|
||
check(all_locked, "after Ready every unit card is locked")
|
||
check(hud()._undo.disabled and hud()._starter.disabled, "undo and start army locked after Ready")
|
||
await key(KEY_1)
|
||
await sync()
|
||
check(match_state.players[game.human_slot].draft.roster.size() == roster_before, "key 1 buys nothing after Ready")
|
||
hud().select_unit(str(match_state.players[game.human_slot].draft.roster[0].uid))
|
||
await sync()
|
||
check((hud().unit_detail._lanes["top"] as Button).disabled and hud().unit_detail._upgrade.disabled, "unit orders locked after Ready")
|
||
check(hud()._phase_badge.text == "PLANUNG · BEREIT", "phase badge says BEREIT (%s)" % hud()._phase_badge.text)
|
||
hud().clear_selection()
|
||
# Network: a host with a seated guest, and the guest's own view built from rpc_state.
|
||
var host: LuneSession = LuneSession.new()
|
||
host.name = "HostSession"
|
||
add_child(host)
|
||
host.host_offline(0, {"seed": 7, "human_slot": 0, "bunds": BUNDS})
|
||
host.seat_peer(1, 80, "Dora")
|
||
var hm: LuneMatch = host.match_state
|
||
hm.players[0].draft.gold = 4321
|
||
hm.players[0].gold = 4321
|
||
hm.building_hp["rot_mid_outer"] = 2000.0
|
||
hm.boss = {"next_available_round": 9, "claim": {}}
|
||
var ids: Array[String] = hm.base_ids("wurzelbund")
|
||
host.host_command(80, 1, {"type": "buy", "type_id": ids[0]})
|
||
await _free_game()
|
||
var guest: LuneSession = LuneSession.new()
|
||
guest.name = "GuestSession"
|
||
add_child(guest)
|
||
guest.role = LuneSession.Role.CLIENT
|
||
guest.local_slot = 1
|
||
guest.match_state = LuneMatch.new()
|
||
guest.match_state.setup({"seed": 7, "human_slot": 1, "bunds": BUNDS})
|
||
guest.lobby = host.lobby.duplicate(true)
|
||
guest.rpc_state(host.state_for(1))
|
||
game = Game.new()
|
||
game.session = guest
|
||
add_child(game)
|
||
game.start({"seed": 7, "human_slot": 1})
|
||
await frames(3)
|
||
await sync()
|
||
var guest_text: String = texts(hud())
|
||
check(hud()._gold.amount.text == str(guest.match_state.gold_of(1)), "guest sees its own gold (%s)" % hud()._gold.amount.text)
|
||
check(not guest_text.contains("4321") and not guest_text.contains("4.321"), "guest never sees the host's gold")
|
||
check(hud().model.networked() and hud().model.is_guest(), "guest HUD knows it is a network guest")
|
||
var host_model := LuneHudModel.new(hm, 0, host)
|
||
var guest_digest: Dictionary = hud().public_digest()
|
||
var host_digest: Dictionary = host_model.public_digest()
|
||
check(JSON.stringify(guest_digest) == JSON.stringify(host_digest), "host and guest show the same public information")
|
||
if JSON.stringify(guest_digest) != JSON.stringify(host_digest):
|
||
for key: String in host_digest:
|
||
if JSON.stringify(host_digest[key]) != JSON.stringify(guest_digest.get(key)):
|
||
printerr("G3_DIGEST_DIFF ", key, " host=", host_digest[key], " guest=", guest_digest.get(key))
|
||
var host_row: Dictionary = {}
|
||
for member: Dictionary in hud().model.members(0):
|
||
if int(member.slot) == 0:
|
||
host_row = member
|
||
check(host_row.status == "human" and not host_row.has("gold"), "guest sees the host as a human seat, without gold")
|
||
# The host's HUD: guest ready, then the guest disconnects and a bot takes the seat.
|
||
var host_hud: Control = Hud.new()
|
||
host_hud.match_state = hm
|
||
host_hud.human_slot = 0
|
||
host_hud.session = host
|
||
add_child(host_hud)
|
||
await frames(3)
|
||
host_hud.mark_dirty()
|
||
await frames(2)
|
||
var dora: Dictionary = host_hud.model.members(0)[1]
|
||
check(dora.name == "Dora" and dora.status == "human" and not dora.ready, "host sees Dora planning")
|
||
host.host_ready(1)
|
||
host_hud.mark_dirty()
|
||
await frames(2)
|
||
check(texts(host_hud._team).contains("BEREIT"), "host sees Dora ready")
|
||
host._on_peer_disconnected(80)
|
||
host_hud.mark_dirty()
|
||
await frames(2)
|
||
check(host_hud.model.members(0)[1].status == "offline" and texts(host_hud._team).contains("BOT ÜBERNIMMT"), "after a disconnect the seat shows BOT ÜBERNIMMT")
|
||
host_hud.queue_free()
|
||
await _free_game()
|
||
guest.queue_free()
|
||
host.close()
|
||
host.queue_free()
|
||
await frames(2)
|
||
|
||
# ---------------------------------------------------------------- F input
|
||
func _input_checks() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
get_window().size = Vector2i(1920, 1080)
|
||
match_state.round_number = 3
|
||
match_state.players[game.human_slot].draft.gold = 400
|
||
game._starter()
|
||
await sync()
|
||
await wait(.3)
|
||
var own_team: int = int(match_state.players[game.human_slot].team)
|
||
# Camera hotkeys (existing keys, now listed in the options).
|
||
var keys: Dictionary = {KEY_F2: "top", KEY_F3: "mid", KEY_F4: "bot"}
|
||
for code: int in keys:
|
||
await key(code)
|
||
var lane: String = keys[code]
|
||
var goal: Vector3 = game.camera._goal_focus
|
||
var expected: Vector2 = match_state.map.lane_point(own_team, lane, match_state.map.lanes[lane].length * .2)
|
||
check(Vector2(goal.x, goal.z).distance_to(expected) < .6, "hotkey for %s moves the camera there" % lane)
|
||
await key(KEY_G)
|
||
check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(match_state.map.grotto_center()) < .6, "G: camera to the boss grotto")
|
||
await key(KEY_B)
|
||
check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(match_state.map.core_position(own_team)) < .6, "B: camera to the own Ancient")
|
||
var settings_text: String = ""
|
||
for entry: Array in LuneSettings.KEYS:
|
||
settings_text += str(entry)
|
||
check(settings_text.contains("F2 · F3 · F4") and settings_text.contains("Bossgrotte") and settings_text.contains("eigener Ancient"), "camera hotkeys listed in the options")
|
||
# Lane click.
|
||
await click(hud().lanes["bot"])
|
||
var bot: Vector2 = match_state.map.lane_point(own_team, "bot", match_state.map.lanes["bot"].length * .2)
|
||
check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(bot) < .6, "click on the BOTTOM lane row moves the camera")
|
||
# Minimap click and drag: camera only, never an order.
|
||
var gold: int = match_state.gold_of(game.human_slot)
|
||
var roster: String = str(match_state.players[game.human_slot].draft.roster)
|
||
var map: Control = hud().minimap
|
||
var local := Vector2(map.size.x * .72, map.size.y * .3)
|
||
var target: Vector3 = map.to_world(local)
|
||
await click_at(map.get_global_transform() * local)
|
||
check(Vector2(game.camera._goal_focus.x, game.camera._goal_focus.z).distance_to(Vector2(target.x, target.z)) < 1.0, "minimap click moves the camera to that point")
|
||
check(match_state.gold_of(game.human_slot) == gold and str(match_state.players[game.human_slot].draft.roster) == roster, "minimap click buys and orders nothing")
|
||
# No click passthrough: the wheel over a panel does not zoom the world, over the field it does.
|
||
await frames(10)
|
||
var zoom: float = game.camera._goal_zoom
|
||
var bar_point: Vector2 = hud()._bottom.get_global_rect().get_center()
|
||
await wheel_at(bar_point)
|
||
check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the bottom bar does not reach the world")
|
||
var lane_point: Vector2 = (hud()._left.get_child(0) as Control).get_global_rect().get_center()
|
||
await wheel_at(lane_point)
|
||
check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the lane panel does not reach the world")
|
||
await wheel_at(hud().unit_detail.get_global_rect().get_center() if hud().unit_detail.visible else hud().boss_card.get_global_rect().get_center())
|
||
check(is_equal_approx(game.camera._goal_zoom, zoom), "mouse wheel over the context column does not reach the world")
|
||
var field: Vector2 = get_viewport().get_visible_rect().size * Vector2(.5, .52)
|
||
await wheel_at(field)
|
||
check(game.camera._goal_zoom < zoom, "mouse wheel over the free field zooms")
|
||
var entry: Dictionary = match_state.players[game.human_slot].draft.roster[0]
|
||
hud().select_unit(str(entry.uid))
|
||
await sync()
|
||
await click_at((hud()._team.get_parent() as Control).get_global_rect().get_center())
|
||
await sync()
|
||
check(hud().selected_uid == str(entry.uid), "a click on a HUD panel does not reach the world (selection kept)")
|
||
# World click picks an own unit.
|
||
hud().clear_selection()
|
||
game.focus_lane(str(match_state.players[game.human_slot].lane), true)
|
||
await frames(6)
|
||
var picked: LuneUnit = null
|
||
for view: Node3D in game._views.values():
|
||
if view.unit != null and view.unit.kind == "troop" and view.unit.owner_slot == game.human_slot:
|
||
picked = view.unit
|
||
await click_at(game.camera.unproject_position(view.aim_point()))
|
||
break
|
||
await sync()
|
||
check(picked != null and hud().selected_uid == picked.persistent_uid, "clicking an own figure in the field selects it")
|
||
# Tooltip: appears near the hovered card, stays on screen, disappears when leaving.
|
||
# Synthetic pointer: hover signals come from the OS cursor, so the test raises mouse_entered
|
||
# itself after moving the (synthetic) pointer onto the card.
|
||
var card: Control = hud()._shop_buttons.values()[0]
|
||
await move_to(card.get_global_rect().get_center())
|
||
card.mouse_entered.emit()
|
||
await wait(.5)
|
||
var tip: LuneTooltip = hud().tooltip
|
||
check(tip.visible, "tooltip appears over a unit card")
|
||
check(rect_inside(tip.get_global_rect(), get_viewport().get_visible_rect()), "tooltip stays inside the window")
|
||
check(tip.mouse_filter == Control.MOUSE_FILTER_IGNORE, "tooltip never takes mouse input")
|
||
await move_to(field)
|
||
await frames(2)
|
||
check(not tip.visible, "tooltip hides as soon as the pointer leaves")
|
||
var tip_count: int = 0
|
||
for node: Node in get_tree().root.find_children("*", "", true, false):
|
||
tip_count += int(node is LuneTooltip)
|
||
check(tip_count == 1, "one shared tooltip (%d)" % tip_count)
|
||
|
||
# ---------------------------------------------------------------- G events and motion
|
||
func _events_and_motion() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
armies(14)
|
||
# Only Blau fields an army: its Mid wave reaches Rot's Grenzwacht quickly.
|
||
for slot: int in range(3, 6):
|
||
match_state.players[slot].draft.roster.clear()
|
||
match_state.players[slot].draft.hero = {}
|
||
match_state.building_hp["rot_mid_outer"] = 40.0
|
||
game._on_ready()
|
||
await frames(3)
|
||
var tower: LuneUnit = match_state.combat.buildings["rot_mid_outer"]
|
||
game.paused = false
|
||
while tower.alive and match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat.elapsed < 120.0:
|
||
for i: int in range(20):
|
||
if not tower.alive or match_state.phase != LuneMatch.Phase.COMBAT:
|
||
break
|
||
match_state.step_combat()
|
||
await frames(1)
|
||
game.paused = true
|
||
await wait(.4)
|
||
check(match_state.phase == LuneMatch.Phase.COMBAT and not tower.alive, "fixture: Rot's Mid Grenzwacht fell during the combat")
|
||
var feed: String = texts(hud().feed)
|
||
check(feed.contains("MID – Grenzwacht (Rot) zerstört"), "event: MID – Grenzwacht (Rot) zerstört (%s)" % feed.replace("\n", " | "))
|
||
check(hud().feed.entries.size() <= LuneEventFeed.LIMIT, "at most %d events" % LuneEventFeed.LIMIT)
|
||
# Critical Ancient with and without reduced motion.
|
||
Preferences.reduced_motion = true
|
||
var core: LuneUnit = match_state.combat.buildings["blau_ancient"]
|
||
core.hp = core.max_hp * .2
|
||
await wait(.4)
|
||
check(float(hud()._ancients[0].pulse) == 0.0, "reduced motion: no pulse on the critical Ancient")
|
||
check(texts(hud()._banners).contains("kritisch"), "critical own Ancient: banner shown")
|
||
check(texts(hud().feed).contains("Dein Ancient kritisch"), "critical own Ancient: event")
|
||
Preferences.reduced_motion = false
|
||
var enemy_core: LuneUnit = match_state.combat.buildings["rot_ancient"]
|
||
enemy_core.hp = enemy_core.max_hp * .2
|
||
await wait(.35)
|
||
check(float(hud()._ancients[1].pulse) > 0.0, "without reduced motion the enemy Ancient pulses briefly")
|
||
await wait(1.8)
|
||
check(float(hud()._ancients[1].pulse) == 0.0, "the pulse ends by itself (no permanent pulsing)")
|
||
core.hp = core.max_hp
|
||
enemy_core.hp = enemy_core.max_hp
|
||
|
||
# ---------------------------------------------------------------- H rates and portraits
|
||
func _rates() -> void:
|
||
var match_state: LuneMatch = await fresh()
|
||
match_state.round_number = 4
|
||
game._starter()
|
||
await wait(.5)
|
||
var before: int = hud().syncs
|
||
await wait(1.0)
|
||
var planning_rate: int = hud().syncs - before
|
||
check(planning_rate <= 14, "planning: HUD updates about 10 times a second (%d)" % planning_rate)
|
||
armies(12)
|
||
game._on_ready()
|
||
await frames(3)
|
||
game.paused = false
|
||
game.speed_index = 0
|
||
await wait(.4)
|
||
before = hud().syncs
|
||
var redraws: int = hud().minimap.redraws
|
||
await wait(1.0)
|
||
var combat_rate: int = hud().syncs - before
|
||
var map_rate: int = hud().minimap.redraws - redraws
|
||
check(combat_rate <= 7, "combat: HUD updates about 5 times a second (%d)" % combat_rate)
|
||
check(map_rate <= 12, "combat: minimap redraws about 10 times a second (%d)" % map_rate)
|
||
var markers: Dictionary = hud().minimap.markers
|
||
check(int(markers.get("clusters", 0)) < int(markers.get("units", 0)) and int(markers.get("units", 0)) > 20, "minimap merges armies (%s)" % str(markers))
|
||
check(int(markers.get("heroes", 0)) >= 1, "heroes have their own minimap markers")
|
||
facts.rates = {"planning_syncs_per_s": planning_rate, "combat_syncs_per_s": combat_rate, "minimap_redraws_per_s": map_rate, "hud_sync_usec": hud().sync_usec, "minimap_markers": markers}
|
||
var cache: LunePortraits = LunePortraits.shared()
|
||
var guard: int = 0
|
||
while cache.pending() > 0 and guard < 600:
|
||
await frames(1)
|
||
guard += 1
|
||
var rendered: int = cache.rendered
|
||
var keys: int = LunePortraits.textures.size()
|
||
check(rendered > 0 and rendered <= keys, "portraits come from the real models and render once each (%d renders, %d cached)" % [rendered, keys])
|
||
var viewports: int = hud().find_children("*", "SubViewport", true, false).size()
|
||
check(viewports == 0, "no live portrait viewports inside the HUD (%d)" % viewports)
|
||
await fresh()
|
||
game._starter()
|
||
await wait(.6)
|
||
check(cache.rendered == rendered or cache.rendered - rendered <= 2, "a new match reuses the cached portraits (%d → %d)" % [rendered, cache.rendered])
|
||
facts.portraits = {"rendered": cache.rendered, "cached": LunePortraits.textures.size()}
|
||
game.paused = true
|