Add blood decals, gore, mobile HUD, web start gate + touch/perf tests

Remove tools/fstest.html scratch page used to probe browser
fullscreen/orientation APIs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
This commit is contained in:
2026-09-04 14:07:57 +03:00
parent 01d6ecf475
commit 981ebf1910
32 changed files with 1536 additions and 113 deletions
+190 -46
View File
@@ -54,8 +54,13 @@ var _ability_style: Array[StyleBoxFlat] = []
var _menu_style: StyleBoxFlat
# Layout rects in screen space — the single source of truth for drawing AND hit-testing.
# Ability buttons are hit-tested against a rect grown by _ability_hit_pad (fills the gaps
# between them) so a near-miss still fires — the visual box stays the smaller drawn rect.
# _slot_pressed drives a brief press highlight so a tap reads as registered.
var _stick_zone: Rect2 = Rect2()
var _ability_rects: Array[Rect2] = []
var _ability_hit_pad: float = 0.0
var _slot_pressed: Array[bool] = [false, false, false, false]
var _menu_rect: Rect2 = Rect2()
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a
@@ -65,8 +70,12 @@ var _stick_hint_center: Vector2 = Vector2()
var _hint_pulse: float = 0.0
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is
# the current thumb position; both in screen coordinates.
# the current thumb position; both in screen coordinates. _stick_index is the finger driving
# the stick (-1 = none): keying release/drag off it (not just the owner dict) lets the latest
# left-zone touch always re-acquire the stick, so a stranded stick — e.g. a touchend lost to a
# resize — self-heals the moment the player touches down again.
var _stick_active: bool = false
var _stick_index: int = -1
var _origin: Vector2 = Vector2.ZERO
var _knob: Vector2 = Vector2.ZERO
var _stick_radius: float = 110.0
@@ -88,6 +97,21 @@ var _pinch_dist: float = 0.0
var _dbg_events: int = 0
var _dbg_last_pos: Vector2 = Vector2.ZERO
# Profiler peak-hold (debug overlay). Godot's game logic — every _process / _physics_process —
# runs on ONE thread on web regardless of thread_support, so a lag spike shows up as a jump in
# process(script) or physics(ragdoll) ms, NOT in FPS alone. We hold the worst value seen in the
# last _PEAK_HOLD_MS so an ability-press spike stays readable instead of flashing by in a frame.
# JS-backed fields (fullscreen status, cross-origin isolation) are cached so the overlay's own
# per-frame JavaScriptBridge.eval doesn't inflate the very process time it's trying to measure.
const _PEAK_HOLD_MS: float = 2000.0
var _peak_proc_ms: float = 0.0
var _peak_phys_ms: float = 0.0
var _peak_proc_t: int = 0
var _peak_phys_t: int = 0
var _iso_cached: int = -1
var _fs_cache: String = "?"
var _fs_cache_t: int = 0
# Cached real GPU name. RenderingServer.get_video_adapter_name() returns the browser's
# privacy-masked "WebKit WebGL" on the web; the true chip (Adreno/Mali/…) — which decides
# whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via
@@ -143,14 +167,19 @@ func _ready() -> void:
# the ability cluster and menu button sit in the right half so they never overlap it.
func _relayout() -> void:
# A resize / orientation flip invalidates every rect and can swallow an in-flight touchend
# (the browser drops it mid-transition), which is exactly what used to strand the joystick.
# Clearing touch state here means the layout always comes back to a clean slate.
_reset_touch_state()
var vp := get_viewport().get_visible_rect().size
var short := minf(vp.x, vp.y)
_stick_radius = clampf(short * 0.16, 80.0, 150.0)
_stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y))
var bs := clampf(short * 0.16, 72.0, 120.0) # ability button edge
var gap := bs * 0.18
var margin := bs * 0.28
var bs := clampf(short * 0.22, 108.0, 152.0) # ability button edge — bigger, easier thumb target
var gap := bs * 0.22
var margin := bs * 0.34
_ability_hit_pad = gap * 0.5 # grow hit rects to meet in the gaps; near-misses still register
var right := vp.x - margin
var bottom := vp.y - margin
var col0 := right - bs * 2.0 - gap
@@ -164,8 +193,9 @@ func _relayout() -> void:
Rect2(col1, row1, bs, bs), # roll
]
var ms := clampf(short * 0.09, 48.0, 72.0)
_menu_rect = Rect2(vp.x - ms - 16.0, 16.0, ms, ms)
var ms := clampf(short * 0.11, 60.0, 84.0) # bigger menu button
var mm := ms * 0.34 # and pulled further off the corner
_menu_rect = Rect2(vp.x - ms - mm, mm, ms, ms)
_stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74)
_surface.queue_redraw()
@@ -230,14 +260,17 @@ func _on_press(index: int, pos: Vector2) -> void:
_touch_owner[index] = "menu"
return
for slot: int in _ability_rects.size():
if _ability_rects[slot].has_point(pos):
if _ability_rects[slot].grow(_ability_hit_pad).has_point(pos):
_touch_owner[index] = slot
_slot_pressed[slot] = true
if _player != null:
_player.call(&"try_activate_ability", slot)
return
if _stick_zone.has_point(pos) and not _stick_active:
if _stick_zone.has_point(pos):
# Last touch in the zone (re)acquires the stick — this is what recovers a stranded stick
# whose finger's release was never delivered. A prior owner is superseded via _stick_index.
_touch_owner[index] = "stick"
_begin_stick(pos)
_begin_stick(index, pos)
return
_touch_owner[index] = "camera"
@@ -245,28 +278,36 @@ func _on_press(index: int, pos: Vector2) -> void:
func _on_drag(index: int, pos: Vector2) -> void:
match _touch_owner.get(index, null):
"stick":
_update_stick(pos)
if index == _stick_index:
_update_stick(pos)
"camera":
if _pinching:
_apply_pinch()
func _on_release(index: int, pos: Vector2) -> void:
match _touch_owner.get(index, null):
var owner: Variant = _touch_owner.get(index, null)
match owner:
"stick":
_end_stick()
# Only the finger that currently owns the stick ends it; a superseded finger
# (another thumb took over) just drops its own mapping below.
if index == _stick_index:
_end_stick()
"menu":
if _menu_rect.has_point(pos):
_touch_owner.erase(index)
_return_to_menu()
return
if owner is int:
_slot_pressed[owner] = false
_touch_owner.erase(index)
# ── Joystick ────────────────────────────────────────────────────────────────
func _begin_stick(pos: Vector2) -> void:
func _begin_stick(index: int, pos: Vector2) -> void:
_stick_active = true
_stick_index = index
_origin = pos
_knob = pos
@@ -283,6 +324,7 @@ func _update_stick(pos: Vector2) -> void:
func _end_stick() -> void:
_stick_active = false
_stick_index = -1
_release_move()
@@ -345,7 +387,11 @@ func _draw_surface() -> void:
for slot: int in _ability_rects.size():
var r: Rect2 = _ability_rects[slot]
_surface.draw_style_box(_ability_style[slot], r)
var inner := r.grow(-r.size.x * 0.14)
if _slot_pressed[slot]:
var glow := _ABILITY_ACCENT[slot]
glow.a = 0.28
_surface.draw_rect(r.grow(-r.size.x * 0.10), glow) # press flash so a tap reads as registered
var inner := r.grow(-r.size.x * 0.06) # small inset so the icon nearly fills the button
if _ability_tex[slot] != null:
_draw_icon_fit(_ability_tex[slot], inner)
else:
@@ -424,49 +470,105 @@ func _draw_hamburger(r: Rect2) -> void:
_surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col)
# Live on-device profiler. Reads Godot's Performance monitors so we can see WHICH stage is slow
# during a lag spike instead of guessing: process(script) is main-thread GDScript, physics is the
# ragdoll/Jolt step, and low-both-but-low-FPS means the GPU (fill rate / resolution) is the wall.
# The verdict line names the bottleneck. Enable on the phone with ?debug=1 in the page URL.
func _draw_debug() -> void:
var font := ThemeDB.fallback_font
if font == null:
return
var players := get_tree().get_nodes_in_group(&"player")
var fps: float = Performance.get_monitor(Performance.TIME_FPS)
var proc_ms: float = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
var phys_ms: float = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
var draws: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
var objs: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME))
var prims: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
var node_n: int = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
var bodies: int = int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
var vmem: float = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0
var now := Time.get_ticks_msec()
if proc_ms >= _peak_proc_ms or now - _peak_proc_t > _PEAK_HOLD_MS:
_peak_proc_ms = proc_ms
_peak_proc_t = now
if phys_ms >= _peak_phys_ms or now - _peak_phys_t > _PEAK_HOLD_MS:
_peak_phys_ms = phys_ms
_peak_phys_t = now
var mats := get_tree().get_nodes_in_group(&"matador")
var rt := get_viewport().get_texture()
var res: Vector2i = rt.get_size() if rt != null else Vector2i.ZERO
var lines := [
"GPU: %s" % RenderingServer.get_video_adapter_name(),
"chip: %s" % _unmasked_gpu(),
"API: %s" % RenderingServer.get_video_adapter_api_version(),
"method=%s web=%s touch_ui=%s" % [
ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"),
OS.has_feature("web"), Controls.use_touch_ui()],
"bull: %s" % _node_report(players),
"matador(%d): %s" % [mats.size(), _node_report(mats)],
"events=%d last=%s touches=%d stick=%s" % [
_dbg_events, str(_dbg_last_pos.round()), _touches.size(), _stick_active],
"FPS %d frame %.1fms >> %s" % [
int(round(fps)), 1000.0 / maxf(fps, 1.0), _perf_verdict(fps, proc_ms, phys_ms)],
"process(script) %5.1fms peak %.1f" % [proc_ms, _peak_proc_ms],
"physics(ragdoll) %5.1fms peak %.1f bodies %d" % [phys_ms, _peak_phys_ms, bodies],
"draws %d objs %d prims %s" % [draws, objs, _kfmt(prims)],
"nodes %d matadors %d vmem %.0fMB" % [node_n, mats.size(), vmem],
"render %dx%d threads(iso)=%s" % [res.x, res.y, _iso_str()],
"chip: %s fs: %s" % [_unmasked_gpu(), _fs_status_throttled()],
]
var fs := 18
var fsz := 18
var line_h := 24.0
var w := 0.0
for line: String in lines:
w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x)
w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz).x)
var top := clampf(get_viewport().get_visible_rect().size.y * 0.28, 96.0, 220.0)
_surface.draw_rect(
Rect2(10.0, 34.0, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.62))
var y := 34.0 + 22.0
Rect2(10.0, top, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.72))
var y := top + 22.0
for line: String in lines:
_surface.draw_string(
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
Color(1.0, 1.0, 0.3))
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz, Color(1.0, 1.0, 0.35))
y += line_h
# One-line "does this character exist / is it drawable / where is it" report for the debug
# overlay, so an invisible bull/matador on-device can be pinned to spawn vs. position vs.
# GPU-draw failure.
func _node_report(nodes: Array) -> String:
if nodes.is_empty():
return "NONE"
var n := nodes[0] as Node3D
if n == null:
return "not Node3D"
return "vis=%s pos=%s" % [n.is_visible_in_tree(), str(n.global_position.round())]
# Names the current bottleneck from the frame breakdown. Directly tests the "CPU bound to one
# thread" hypothesis: if process or physics ms is high, that's the single main thread saturated
# and threads can't help it; if both are low while FPS is low, the GPU (fill rate) is the wall.
func _perf_verdict(fps: float, proc_ms: float, phys_ms: float) -> String:
if fps >= 55.0:
return "smooth"
if phys_ms >= 6.0 and phys_ms >= proc_ms:
return "PHYSICS-bound (ragdolls, 1 thread)"
if proc_ms >= 6.0:
return "SCRIPT-bound (GDScript, 1 thread)"
if proc_ms + phys_ms < 5.0:
return "GPU/fill-bound (resolution/shaders)"
return "mixed CPU"
func _kfmt(n: int) -> String:
if n >= 1000000:
return "%.1fM" % (n / 1000000.0)
if n >= 1000:
return "%.0fk" % (n / 1000.0)
return str(n)
# crossOriginIsolated: whether SharedArrayBuffer/threads are even permitted (needs COOP+COEP). Even
# when true, Godot's per-frame game logic stays single-threaded — threads only aid render/audio/load.
func _iso_str() -> String:
if not OS.has_feature("web"):
return "native"
if _iso_cached == -1:
var r: Variant = JavaScriptBridge.eval("window.crossOriginIsolated?1:0", true)
_iso_cached = 1 if ((r is bool and r) or ((r is int or r is float) and int(r) != 0)) else 0
return str(_iso_cached)
# fullscreen_status() does a JavaScriptBridge.eval; throttle it so the overlay isn't paying that
# cost (and skewing the process-ms it reports) every single frame.
func _fs_status_throttled() -> String:
var now := Time.get_ticks_msec()
if now - _fs_cache_t > 500 or _fs_cache == "?":
_fs_cache = Controls.fullscreen_status()
_fs_cache_t = now
return _fs_cache
func _button_style(accent: Color) -> StyleBoxFlat:
@@ -484,6 +586,9 @@ func _button_style(accent: Color) -> StyleBoxFlat:
func _return_to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# Stay fullscreen across menu ↔ game so tapping Play never lands you windowed; the player
# leaves fullscreen only via the browser's own back / swipe / Esc, and the start gate then
# re-appears so the next tap goes back in.
get_tree().change_scene_to_file("res://MainMenu.tscn")
@@ -497,16 +602,55 @@ func _process(delta: float) -> void:
if not active:
if _surface.visible:
_surface.visible = false
if _stick_active:
_end_stick()
_touches.clear()
_touch_owner.clear()
_pinching = false
_reset_touch_state()
return
_surface.visible = true
_prune_touch_state()
if _player == null:
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_player = players[0]
_surface.queue_redraw() # cooldown overlays animate every frame
# Fully drop every trace of in-flight touch input: release held movement, clear ownership,
# end the stick, cancel any pinch. Used on stand-down, on relayout, and on focus/pause loss.
func _reset_touch_state() -> void:
_end_stick()
_release_move()
_touches.clear()
_touch_owner.clear()
for i: int in _slot_pressed.size():
_slot_pressed[i] = false
_pinching = false
_pinch_dist = 0.0
# Per-frame self-heal: drop ownership entries whose finger is no longer down, and stand the
# stick down if the finger driving it has lifted. Catches a release that slipped through
# without a matching event so state can't quietly rot into a stuck stick.
func _prune_touch_state() -> void:
if not _touch_owner.is_empty():
var orphans: Array = []
for idx: int in _touch_owner:
if not _touches.has(idx):
orphans.append(idx)
for idx: int in orphans:
var owner: Variant = _touch_owner[idx]
if owner is int:
_slot_pressed[owner] = false
if _stick_active and idx == _stick_index:
_end_stick()
_touch_owner.erase(idx)
if _stick_active and not _touches.has(_stick_index):
_end_stick()
func _notification(what: int) -> void:
if (
what == NOTIFICATION_APPLICATION_FOCUS_OUT
or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT
or what == NOTIFICATION_APPLICATION_PAUSED
):
_reset_touch_state()