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:
+190
-46
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user