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
+8 -1
View File
@@ -33,7 +33,11 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level | | `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
| `crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group | | `crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
| `crowd_billboards.gd` / `crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed | | `crowd_billboards.gd` / `crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) | | `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
| `touch_controls.gd` | On-screen thumb UI for touch/web (`CanvasLayer`, instanced by `hud.gd`, self-gating on `Controls.use_touch_ui()`): a floating left-side joystick feeding the `move_*` actions + a right-side ability cluster, drawn/hit-tested in one IGNORE-filtered Control from raw `_input()`. The joystick self-heals — the latest left-zone touch re-acquires it (`_stick_index`), and a relayout / focus-out / vanished touch resets state so a dropped touchend can't strand a stuck stick. Ability buttons use grown hit-rects + a press flash. `_draw_debug()` (gated on `Controls.debug_overlay()` — DP `touch_debug` or `?debug=1`) is a live on-device profiler reading Godot's `Performance` monitors: FPS/frame-ms with a bottleneck verdict, `process`(main-thread GDScript) vs `physics`(ragdoll/Jolt) ms with 2 s peak-hold, draw calls/objects/primitives, node count, render resolution, and `crossOriginIsolated` — so a lag spike is pinned to CPU-script vs CPU-physics vs GPU-fill instead of guessed |
| `web_start_gate.gd` | Autoload `WebStartGate` — "TAP TO PLAY" panel shown on web+touch before play; the tap drives `Controls.request_fullscreen_landscape()` so the fullscreen/orientation churn happens with no in-flight touch to lose. Re-armable: polls browser fullscreen state and returns if the player leaves it (iPhone Safari can't fullscreen, so it stops nagging once a request never takes) |
| `mobile_hud.gd` | Mobile-only health readouts (`CanvasLayer`, instanced by `hud.gd` only on touch): big bull pip row top-left + a Souls-style bear boss bar top-centre (discovered via group `&"bear"`, tracks `health_changed`). The desktop bottom pip cluster is hidden on touch |
| `orientation_guard.gd` | Autoload `OrientationGuard` — full-screen "rotate your device" prompt shown on touch devices while portrait |
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) | | `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
| `Blender/` | Blender source files, animation scripts, FBX exports | | `Blender/` | Blender source files, animation scripts, FBX exports |
| `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX | | `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
@@ -105,8 +109,11 @@ bash run_tests.sh
gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv) gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
godot --headless --script tests/logic_test.gd # pure logic, ~2 s godot --headless --script tests/logic_test.gd # pure logic, ~2 s
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
godot --headless --script tests/ragdoll_perf_test.gd # on-hit spike breakdown: ragdoll build / sim-start / blood / mass hit, ~90 s
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
godot --headless --script tests/touch_controls_test.gd # joystick self-heals: relayout/focus-out/vanished-touch never strand a stuck stick, ~2 s
godot --headless --script tests/rigid_skin_test.gd # web mesh conversion keeps every material (no bald / recoloured matadors), ~2 s
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s
+6 -3
View File
@@ -116,9 +116,10 @@ func _ready() -> void:
# The smash/leap clips end held at full scale (Siim only ramped the claw clips back # The smash/leap clips end held at full scale (Siim only ramped the claw clips back
# down), so snap each effect back to 0 when its clip finishes — keeps them one-shot. # down), so snap each effect back to 0 when its clip finishes — keeps them one-shot.
_fx_player.animation_finished.connect(_on_fx_finished) _fx_player.animation_finished.connect(_on_fx_finished)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as # Native GPU skinning by default (the Mali/ANGLE invisible-skinning bug is fixed in the 4.7
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning. # web templates). The bear is a single dense organic mesh, so the rigid-skin fallback tears
if OS.has_feature("web"): # seam holes as it animates — another reason native is the default. See rigid_skin_enabled().
if Controls.rigid_skin_enabled():
RigidSkin.convert_tree(self) RigidSkin.convert_tree(self)
if _anim_player: if _anim_player:
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"]) _anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
@@ -883,6 +884,7 @@ func _take_hit(hit_dir: Vector3, strength: float) -> void:
hit_dir.y = 0.0 hit_dir.y = 0.0
hit_dir = hit_dir.normalized() hit_dir = hit_dir.normalized()
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
Gore.splat(global_position, hit_dir, 1.1)
_shake(clampf(strength / 15.0, 0.4, 1.0)) _shake(clampf(strength / 15.0, 0.4, 1.0))
if _hp <= 0: if _hp <= 0:
_die(hit_dir) _die(hit_dir)
@@ -918,6 +920,7 @@ func _die(hit_dir: Vector3) -> void:
_hit_area.monitoring = false _hit_area.monitoring = false
velocity = Vector3.ZERO velocity = Vector3.ZERO
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
Gore.splat(global_position, hit_dir, 1.4)
_play(_anim_death) _play(_anim_death)
# No death clip on the rig — topple the beast onto its side (about the hit direction, # No death clip on the rig — topple the beast onto its side (about the hit direction,
# so it falls the way it was struck) and sink it away, then free. # so it falls the way it was struck) and sink it away, then free.
+194
View File
@@ -0,0 +1,194 @@
extends MultiMeshInstance3D
## A whole level's worth of persistent blood splats rendered in one draw call. Each stain
## is a flat quad laid on the surface it hit — floor or a nearby wall — picked from a
## procedurally-baked atlas of splat shapes and jittered per instance (random shape, yaw,
## size, brightness) so no two read alike.
##
## The pool is a fixed ring buffer: new splats overwrite the oldest, so the instance count
## and fill cost stay bounded no matter how long a fight runs — the property that keeps it
## cheap on the web/mobile target. The game shell rebuilds this node into LevelRoot on every
## level load, so the stains die with the level (exactly one level's worth, never leaked
## into the next). Fire splats through the `Gore` autoload, not this node directly.
const PhysicsLayers = preload("res://physics_layers.gd")
const SHADER_PATH := "res://blood_decals.gdshader"
const GROUP := &"blood_decals"
# Atlas: ATLAS_CELLS distinct splat shapes packed into one row, CELL_PX square each.
const ATLAS_CELLS := 8
const CELL_PX := 64
var _rng := RandomNumberGenerator.new()
var _mm: MultiMesh
var _cap: int = 0
var _next: int = 0 # ring-buffer write cursor
var _filled: int = 0 # how many ring-buffer slots have ever been stamped, capped at _cap
func _ready() -> void:
add_to_group(GROUP)
_rng.randomize()
_cap = maxi(int(DP.f("blood_cap")), 0)
_build()
func _build() -> void:
var quad := QuadMesh.new()
quad.size = Vector2.ONE # unit quad; the instance basis carries the real size
var mat := ShaderMaterial.new()
mat.shader = load(SHADER_PATH) as Shader
mat.set_shader_parameter("atlas", _bake_atlas())
mat.set_shader_parameter("cells", ATLAS_CELLS)
material_override = mat
_mm = MultiMesh.new()
_mm.transform_format = MultiMesh.TRANSFORM_3D
_mm.use_custom_data = true
_mm.mesh = quad
_mm.instance_count = _cap
# Godot renders only the first `visible_instance_count` instances, so unstained slots simply
# aren't drawn — no need to park them off-map. (Parking them via set_instance_transform doesn't
# work anyway: MultiMesh's per-instance buffer lives server-side and isn't reliably readable
# back through get_instance_transform, so a "hide by moving far away" scheme can't even be
# verified, let alone trusted.) Starts at 0 and grows as splats land.
_mm.visible_instance_count = 0
multimesh = _mm
## Stain the world at `world_pos`: one splat cluster on the floor beneath it, plus a splat
## on any wall within `blood_wall_reach`. `dir` is the spray heading (used to seed the wall
## fan); `size` is the base quad size in metres.
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
if not is_instance_valid(_mm) or _cap <= 0:
return
var space := get_world_3d().direct_space_state
if space == null:
return
# Floor directly under the hit.
var floor_hit := _ray(space, world_pos + Vector3.UP * 0.5, world_pos + Vector3.DOWN * 4.0)
if not floor_hit.is_empty():
_stamp_cluster(floor_hit.position, floor_hit.normal, size)
# Walls near the hit: a fan of outward rays, seeded on the spray heading. Only surfaces
# within reach catch blood, so an open-arena hit stains nothing but the floor.
var rays := maxi(int(DP.f("blood_wall_rays")), 0)
if rays <= 0:
return
var reach := DP.f("blood_wall_reach")
var seed_dir := Vector3(dir.x, 0.0, dir.z)
seed_dir = seed_dir.normalized() if seed_dir.length_squared() > 0.01 else Vector3.FORWARD
var origin := world_pos + Vector3.UP * 0.6
for k in rays:
var ang := TAU * (float(k) + 0.5) / float(rays)
var out := seed_dir.rotated(Vector3.UP, ang)
var wall_hit := _ray(space, origin, origin + out * reach)
# Only stain near-vertical surfaces here; the floor is already handled above.
if not wall_hit.is_empty() and absf((wall_hit.normal as Vector3).y) < 0.6:
_stamp(wall_hit.position, wall_hit.normal, size * 0.85)
func _ray(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3) -> Dictionary:
var q := PhysicsRayQueryParameters3D.create(
from, to, PhysicsLayers.WORLD | PhysicsLayers.CORPSE
)
q.collide_with_bodies = true
return space.intersect_ray(q)
# A main splat plus a scatter of smaller droplets around it, all lying on the same surface.
func _stamp_cluster(pos: Vector3, normal: Vector3, size: float) -> void:
_stamp(pos, normal, size)
var n := normal.normalized()
if n.length_squared() < 0.5:
n = Vector3.UP
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
var tx := up.cross(n).normalized()
var ty := n.cross(tx).normalized()
var count := maxi(int(DP.f("blood_satellites")), 0)
for s in count:
var rad := size * _rng.randf_range(0.4, 1.2)
var a := _rng.randf() * TAU
var off := tx * (cos(a) * rad) + ty * (sin(a) * rad)
_stamp(pos + off, n, size * _rng.randf_range(0.25, 0.5))
# Write one splat into the ring buffer, oriented flat on the surface and lifted a hair along
# its normal (layered by write index) so coplanar quads don't z-fight the surface or each other.
func _stamp(pos: Vector3, normal: Vector3, size: float) -> void:
var n := normal.normalized()
if n.length_squared() < 0.5:
n = Vector3.UP
var i := _next
_next = (_next + 1) % _cap
var eps := 0.015 + float(i) * 0.00015
var yaw := _rng.randf() * TAU
_mm.set_instance_transform(i, Transform3D(_surface_basis(n, yaw, size), pos + n * eps))
var cell := float(_rng.randi_range(0, ATLAS_CELLS - 1))
var shade := _rng.randf_range(0.6, 1.0)
_mm.set_instance_custom_data(i, Color(cell, shade, 0.0, 0.0))
_filled = mini(_filled + 1, _cap)
_mm.visible_instance_count = _filled
# Basis for a QuadMesh (face along local +Z) lying flat on a surface with the given normal,
# spun by `yaw` about that normal and uniformly scaled to `size`.
func _surface_basis(n: Vector3, yaw: float, size: float) -> Basis:
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
var x := up.cross(n).normalized()
var y := n.cross(x).normalized()
var c := cos(yaw)
var s := sin(yaw)
var b := Basis()
b.x = (x * c + y * s) * size
b.y = (y * c - x * s) * size
b.z = n
return b
# ── Atlas baking ──────────────────────────────────────────────────────────────
# Draw ATLAS_CELLS irregular blood shapes into one row. Each shape is a metaball field —
# a big central blob, a few overlapping lobes and some flung droplets — thresholded to an
# organic silhouette. Baked once per pool (once per level load, ~ms) with a fixed seed so
# the shape set is deterministic and every level's stains match.
func _bake_atlas() -> ImageTexture:
var img := Image.create(ATLAS_CELLS * CELL_PX, CELL_PX, false, Image.FORMAT_RGBA8)
img.fill(Color(0.0, 0.0, 0.0, 0.0))
var rng := RandomNumberGenerator.new()
rng.seed = hash("bullosseum-blood")
for c in ATLAS_CELLS:
_draw_splat(img, c * CELL_PX, rng)
return ImageTexture.create_from_image(img)
func _draw_splat(img: Image, ox: int, rng: RandomNumberGenerator) -> void:
var px := float(CELL_PX)
var mid := px * 0.5
# blobs are (centre_x, centre_y, radius) — a central mass, lobes, then far droplets.
var blobs: Array[Vector3] = []
blobs.append(Vector3(mid, mid, px * rng.randf_range(0.22, 0.30)))
for i in rng.randi_range(3, 6):
var a := rng.randf() * TAU
var d := px * rng.randf_range(0.10, 0.34)
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.06, 0.16)))
for i in rng.randi_range(2, 5):
var a := rng.randf() * TAU
var d := px * rng.randf_range(0.30, 0.46)
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.02, 0.05)))
for y in CELL_PX:
for x in CELL_PX:
var field := 0.0
for b in blobs:
var dx := float(x) - b.x
var dy := float(y) - b.y
field += (b.z * b.z) / (dx * dx + dy * dy + 1.0)
var a := smoothstep(0.75, 1.15, field)
if a <= 0.004:
continue
# Denser field (splat interior) reads a touch richer/darker than the thin edges.
var t := clampf(field * 0.5, 0.0, 1.0)
var col := Color(0.62 - 0.16 * t, 0.05 - 0.03 * t, 0.04 - 0.02 * t, a)
img.set_pixel(ox + x, y, col)
+1
View File
@@ -0,0 +1 @@
uid://cs8fhiasw7q7j
+26
View File
@@ -0,0 +1,26 @@
shader_type spatial;
// Persistent blood splats, drawn as one MultiMesh of flat quads (see blood_decals.gd).
// Unshaded and nearest-filtered to sit inside the PS1 look; alpha-blended so overlapping
// splats pool darker. depth_draw_never + the small normal offset the pool bakes into each
// transform keep the coplanar floor/wall quads from z-fighting the surface they stain.
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_never, blend_mix;
uniform sampler2D atlas : source_color, filter_nearest;
// Number of splat shapes packed side-by-side in the atlas (one row).
uniform int cells = 8;
varying flat float v_cell;
varying flat float v_shade;
void vertex() {
// x = which atlas shape this instance uses; y = per-instance brightness (fresh vs old).
v_cell = INSTANCE_CUSTOM.x;
v_shade = INSTANCE_CUSTOM.y;
}
void fragment() {
float u = (UV.x + v_cell) / float(cells);
vec4 c = texture(atlas, vec2(u, UV.y));
ALBEDO = c.rgb * v_shade;
ALPHA = c.a;
}
+1
View File
@@ -0,0 +1 @@
uid://cj5rmaywacvbj
+126 -21
View File
@@ -23,36 +23,115 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
func _ready() -> void: func _ready() -> void:
load_saved() load_saved()
_install_web_fullscreen_hook()
# ── Web fullscreen + landscape lock ───────────────────────────────────────────── # ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# Browsers only allow requestFullscreen / screen.orientation.lock from inside a user # The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls
# gesture, so we piggyback on the first tap/click of the web build to go fullscreen and # requestFullscreen() synchronously inside the real user-gesture event — the same thing
# lock to landscape. One-shot; input still flows to the menu/game normally (never consumed). # three.js games do. Godot dispatches input from its rAF render loop, one hop removed from the
var _fs_triggered: bool = false # DOM event, so both Godot's window_set_mode and a JS call made from _input() run outside the
# gesture's activation and some mobile browsers reject them. The listener below sidesteps that
# entirely, and — left attached — re-enters fullscreen on the next tap if the player leaves it.
func _input(event: InputEvent) -> void: # All of it is guarded, so iPhone Safari (no requestFullscreen) and desktop just no-op cleanly.
if _fs_triggered or not OS.has_feature("web"): func _install_web_fullscreen_hook() -> void:
if not OS.has_feature("web"):
return return
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \ JavaScriptBridge.eval("""
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed) (function(){
if gesture: if (window.__bullFSInit) return;
_fs_triggered = true window.__bullFSInit = true;
request_fullscreen_landscape() window.__bull_fs = 'idle';
// Armed = we should grab fullscreen on the next tap. Starts true so the first tap enters,
// then disarms — so once the player is in (or deliberately leaves via Esc / back / swipe)
// ordinary gameplay taps never yank them back in. WebStartGate re-arms via arm_fullscreen().
window.__bullFSArmed = true;
window.__bullFS = function(){
try {
if (document.fullscreenElement || document.webkitFullscreenElement) {
window.__bull_fs = 'ok'; window.__bullFSArmed = false; return;
}
if (!window.__bullFSArmed) return;
var c = document.getElementById('canvas') || document.querySelector('canvas')
|| document.documentElement;
var rf = c.requestFullscreen || c.webkitRequestFullscreen
|| c.msRequestFullscreen || c.mozRequestFullScreen;
if (!rf) { window.__bull_fs = 'no-api'; lock(); return; }
window.__bull_fs = 'req';
var p = rf.call(c);
if (p && p.then) {
p.then(function(){ window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); },
function(e){ window.__bull_fs = 'err:' + (e && e.name || e); });
} else { window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); }
} catch(e){ window.__bull_fs = 'ex:' + (e && e.name || e); }
function lock(){ try { if (screen.orientation && screen.orientation.lock)
screen.orientation.lock('landscape').catch(function(){}); } catch(e){} }
};
// pointerup / touchend / click grant transient activation (pointerdown/touchstart do not).
['pointerup','touchend','click'].forEach(function(ev){
window.addEventListener(ev, function(){ window.__bullFS(); }, true);
});
})();
""", true)
## Enter browser fullscreen and lock to landscape. Must be called from a user-gesture ## Ask to enter fullscreen + landscape now (e.g. from WebStartGate's tap). The DOM listener
## context (first tap, or a button press). No-op off web. Android Chrome/Brave honour the ## installed above normally beats this to it on the same gesture; this is the explicit path and
## orientation lock; iOS Safari ignores it (unsupported), so the .catch() swallows the reject. ## is a safe no-op if we're already fullscreen. No-op off web.
func request_fullscreen_landscape() -> void: func request_fullscreen_landscape() -> void:
if not OS.has_feature("web"): if not OS.has_feature("web"):
return return
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) _install_web_fullscreen_hook()
JavaScriptBridge.eval( JavaScriptBridge.eval("if (window.__bullFS) window.__bullFS();", true)
"(function(){function l(){try{if(screen.orientation&&screen.orientation.lock)"
+ "screen.orientation.lock('landscape').catch(function(){});}catch(e){}}"
+ "l();document.addEventListener('fullscreenchange',l,{once:true});})()", true) ## Re-arm the "grab fullscreen on next tap" hook, so the next tap re-enters. WebStartGate calls
## this whenever it shows itself (boot, or after the player left fullscreen). No-op off web.
func arm_fullscreen() -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval("window.__bullFSArmed = true;", true)
## Whether the browser can go fullscreen at all. False on iPhone Safari (no requestFullscreen),
## which lets WebStartGate stop prompting there instead of nagging forever. True off web (n/a).
func fullscreen_supported() -> bool:
if not OS.has_feature("web"):
return true
var r: Variant = JavaScriptBridge.eval(
"(function(){var c=document.getElementById('canvas')||document.documentElement;"
+ "return !!(c.requestFullscreen||c.webkitRequestFullscreen"
+ "||c.msRequestFullscreen||c.mozRequestFullScreen)?1:0;})()", true)
if r is bool:
return r
if r is int or r is float:
return int(r) != 0
return false
## Is the browser currently fullscreen? No-op-ish off web (returns false).
func is_browser_fullscreen() -> bool:
if not OS.has_feature("web"):
return false
var r: Variant = JavaScriptBridge.eval(
"(document.fullscreenElement||document.webkitFullscreenElement)?1:0", true)
if r is bool:
return r
if r is int or r is float:
return int(r) != 0
return false
## One-line web diagnostic for the on-device debug overlay: secure context, cross-origin
## isolation (threads), last fullscreen attempt result, and whether we're fullscreen now.
func fullscreen_status() -> String:
if not OS.has_feature("web"):
return "native"
var r: Variant = JavaScriptBridge.eval(
"'sec=' + (window.isSecureContext ? 1 : 0) + ' iso=' + (window.crossOriginIsolated ? 1 : 0)"
+ " + ' fs=' + (window.__bull_fs || '?')"
+ " + ' cur=' + ((document.fullscreenElement || document.webkitFullscreenElement) ? 1 : 0)",
true)
return str(r) if r != null else "?"
## Whether to drive the game with the on-screen touch UI (joystick + buttons) ## Whether to drive the game with the on-screen touch UI (joystick + buttons)
@@ -117,6 +196,32 @@ func debug_overlay() -> bool:
return _url_debug == 1 return _url_debug == 1
## Whether to apply the rigid-skin workaround (rigid_skin.gd) instead of native GPU skinning.
## Defaults to the DP "use_rigid_skin" flag (on for web), but a page URL param overrides it so
## native skinning can be A/B'd on the actual phone with no console reachable: append `?noskin=1`
## to force it OFF (does the device render skinned meshes natively now — e.g. after a Godot web
## template update? — and do the bear's rigid-skin seam holes go away), or `?skin=1` to force ON.
## -1 = not read yet, 0 = url forces off, 1 = url forces on, 2 = no url override (use DP flag).
var _url_skin: int = -1
func rigid_skin_enabled() -> bool:
if _url_skin == -1:
_url_skin = 2
if OS.has_feature("web"):
var q: Variant = JavaScriptBridge.eval("String(window.location.search)", true)
var s: String = str(q) if q != null else ""
if s.find("noskin") >= 0:
_url_skin = 0
elif s.find("skin") >= 0:
_url_skin = 1
if _url_skin == 0:
return false
if _url_skin == 1:
return true
return DP.b("use_rigid_skin")
## Returns the first keyboard event bound to an action, or null. ## Returns the first keyboard event bound to an action, or null.
func get_key_event(action: StringName) -> InputEventKey: func get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action): for event: InputEvent in InputMap.action_get_events(action):
+20
View File
@@ -306,6 +306,18 @@ func _register_all() -> void:
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then # it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire). # rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05) _reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
# ── Blood ─────────────────────────────────────────────────────────────────
# Persistent floor/wall splats (blood_decals.gd), fired via the Gore autoload and
# gated by the player's Settings.gore. blood_cap is the ring-buffer size: past it,
# new splats overwrite the oldest so fill/memory stay bounded. blood_scale is a
# global size multiplier over each hit's own size; satellites are extra droplets
# scattered per floor cluster; the wall fan casts blood_wall_rays outward and stains
# any wall within blood_wall_reach metres of the hit.
_reg_f("Blood", "blood_cap", 192.0, 0.0, 512.0, 16.0)
_reg_f("Blood", "blood_scale", 1.0, 0.1, 4.0, 0.05)
_reg_f("Blood", "blood_satellites", 3.0, 0.0, 8.0, 1.0)
_reg_f("Blood", "blood_wall_reach", 2.5, 0.0, 6.0, 0.1)
_reg_f("Blood", "blood_wall_rays", 8.0, 0.0, 16.0, 1.0)
# ── Sword ───────────────────────────────────────────────────────────────── # ── Sword ─────────────────────────────────────────────────────────────────
# Local seating of the sword in the right hand (drawn / fighting). # Local seating of the sword in the right hand (drawn / fighting).
# The blade model runs along its local +Z, but weapon_bone (like every rig bone) # The blade model runs along its local +Z, but weapon_bone (like every rig bone)
@@ -422,6 +434,14 @@ func _register_all() -> void:
# On-screen readout of live touch state (event count, last position, active zone) so # On-screen readout of live touch state (event count, last position, active zone) so
# touch problems can be diagnosed on the actual device, where no console is reachable. # touch problems can be diagnosed on the actual device, where no console is reachable.
_reg_b("Touch", "touch_debug", false) _reg_b("Touch", "touch_debug", false)
# Rebuild skinned characters as rigid, bone-attached mesh pieces instead of GPU vertex
# skinning. This was a workaround for older Mali/ANGLE web GPUs where Compatibility skinning
# rendered characters invisible — but the Godot 4.7 web templates fixed that (confirmed
# on-device), so native skinning is now the default everywhere: it renders the bear's dense
# mesh without the rigid-seam holes AND drops the per-spawn SurfaceTool rebuild that was
# spiking the arena on matador spawn. Kept as an opt-in fallback (DP toggle, or ?skin=1 in
# the URL) in case a device is found where Compatibility skinning still fails.
_reg_b("Render", "use_rigid_skin", false)
func _reg_f(section: String, key: String, default: float, func _reg_f(section: String, key: String, default: float,
+15
View File
@@ -0,0 +1,15 @@
extends Node
## Stateless façade (autoload `Gore`) for spawning persistent blood splats. Callers just say
## Gore.splat(pos, dir, size) at each wound; this routes to the level's BloodDecals pool
## (group &"blood_decals"), which the game shell rebuilds into LevelRoot on every load so the
## stains die with the level. No-ops when there's no pool (menus, headless tests without a
## level) or when the player has turned gore off — so the gate lives in one place.
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
if not Settings.gore:
return
var pool := get_tree().get_first_node_in_group(&"blood_decals")
if pool == null:
return
pool.call(&"splat", world_pos, dir, size * DP.f("blood_scale"))
+1
View File
@@ -0,0 +1 @@
uid://b3dobof8qule3
+51 -10
View File
@@ -95,6 +95,7 @@ func _ready() -> void:
_build_damage_vignette() _build_damage_vignette()
_build_game_over() _build_game_over()
_build_touch_controls() _build_touch_controls()
_build_mobile_hud()
_update_control_hints() _update_control_hints()
_find_spawner.call_deferred() _find_spawner.call_deferred()
_watch_barrels.call_deferred() _watch_barrels.call_deferred()
@@ -107,6 +108,14 @@ func _build_touch_controls() -> void:
add_child((load("res://touch_controls.gd") as Script).new()) add_child((load("res://touch_controls.gd") as Script).new())
# Mobile-only health readouts (bull pips top-left + bear boss bar top-centre). On touch the
# bottom pip cluster is hidden (see _build_health), so this is the only health display there;
# on desktop it's never created.
func _build_mobile_hud() -> void:
if Controls.use_touch_ui():
add_child((load("res://mobile_hud.gd") as Script).new())
# Bottom-centre column that groups the gameplay HUD (abilities on top, bull health # Bottom-centre column that groups the gameplay HUD (abilities on top, bull health
# directly beneath) so both read as one cluster instead of scattered corners. # directly beneath) so both read as one cluster instead of scattered corners.
func _build_bottom_hud() -> void: func _build_bottom_hud() -> void:
@@ -171,18 +180,29 @@ func _position_bull_bg() -> void:
vp_h - bh) vp_h - bh)
# Unobtrusive early-build tag in the bottom-right corner. # Early-build tag. Bottom-right on desktop; on touch it moves to the bottom centre, clear of
# the ability cluster + joystick that own the bottom corners, and reads with an outline.
func _build_version_label() -> void: func _build_version_label() -> void:
var lbl := Label.new() var lbl := Label.new()
lbl.text = GameVersion.full() lbl.text = GameVersion.full()
lbl.add_theme_color_override("font_color", _MUTED) lbl.add_theme_color_override("font_color", Color(0.90, 0.86, 0.72))
lbl.add_theme_font_size_override("font_size", 12) lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) lbl.add_theme_constant_override("outline_size", 4)
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN lbl.add_theme_font_size_override("font_size", 16)
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
lbl.offset_right = -14.0
lbl.offset_bottom = -10.0 lbl.offset_bottom = -10.0
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
if Controls.use_touch_ui():
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.anchor_left = 0.5
lbl.anchor_right = 0.5
lbl.anchor_top = 1.0
lbl.anchor_bottom = 1.0
lbl.grow_horizontal = Control.GROW_DIRECTION_BOTH
else:
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
lbl.offset_right = -20.0
add_child(lbl) add_child(lbl)
@@ -494,7 +514,7 @@ func _build_game_over() -> void:
# Buttons overlaid at the bottom of the clip. # Buttons overlaid at the bottom of the clip.
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 16) row.add_theme_constant_override("separation", 32 if Controls.use_touch_ui() else 16)
row.anchor_left = 0.5 row.anchor_left = 0.5
row.anchor_right = 0.5 row.anchor_right = 0.5
row.anchor_top = 1.0 row.anchor_top = 1.0
@@ -504,13 +524,13 @@ func _build_game_over() -> void:
row.offset_bottom = -48.0 row.offset_bottom = -48.0
_over_root.add_child(row) _over_root.add_child(row)
# Win → Continue to the run map; loss → Go Again from the top. Shown per-result in # Win → continue to the run map; loss → restart from the top. Shown per-result in
# _show_game_over; both share the Enter shortcut. # _show_game_over; both share the Enter shortcut and label.
_over_continue = _make_button("Continue (Enter)") _over_continue = _make_button("Continue (Enter)")
_over_continue.pressed.connect(_continue_to_lobby) _over_continue.pressed.connect(_continue_to_lobby)
row.add_child(_over_continue) row.add_child(_over_continue)
_over_again = _make_button("Go Again (Enter)") _over_again = _make_button("Continue (Enter)")
_over_again.pressed.connect(_restart) _over_again.pressed.connect(_restart)
row.add_child(_over_again) row.add_child(_over_again)
@@ -518,6 +538,11 @@ func _build_game_over() -> void:
menu.pressed.connect(_return_to_menu) menu.pressed.connect(_return_to_menu)
row.add_child(menu) row.add_child(menu)
# Thumbs need a far bigger target than a mouse pointer, and the outcome clip has room
# for it — grow the whole row on touch only.
for btn: Button in [_over_continue, _over_again, menu]:
_grow_for_touch(btn)
# ── Bull health ─────────────────────────────────────────────────────────────── # ── Bull health ───────────────────────────────────────────────────────────────
# A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or # A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or
# lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward # lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward
@@ -540,6 +565,9 @@ func _build_health() -> void:
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0)) "panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0))
panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
# On touch the bottom edge is crowded by the joystick + ability cluster and mobile_hud.gd
# paints health up top instead, so drop this bottom pip cluster there.
panel.visible = not Controls.use_touch_ui()
_bottom_col.add_child(panel) _bottom_col.add_child(panel)
var row := HBoxContainer.new() var row := HBoxContainer.new()
@@ -799,6 +827,10 @@ func _build_controls_panel() -> void:
vbox.add_child(_toggle_btn) vbox.add_child(_toggle_btn)
const _TOUCH_BTN_FONT: int = 34
const _TOUCH_BTN_MIN: Vector2 = Vector2(320.0, 110.0)
func _make_button(label_text: String) -> Button: func _make_button(label_text: String) -> Button:
var btn := Button.new() var btn := Button.new()
btn.text = label_text btn.text = label_text
@@ -819,6 +851,15 @@ func _make_button(label_text: String) -> Button:
return btn return btn
# Scales a game-over button up to a comfortable thumb target on touch devices; a no-op
# with mouse/gamepad, where the desktop sizing already reads fine.
func _grow_for_touch(btn: Button) -> void:
if not Controls.use_touch_ui():
return
btn.add_theme_font_size_override("font_size", _TOUCH_BTN_FONT)
btn.custom_minimum_size = _TOUCH_BTN_MIN
func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -> void: func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -> void:
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 10)
+3
View File
@@ -30,6 +30,9 @@ func load_active_level() -> void:
var module := level.level_scene.instantiate() var module := level.level_scene.instantiate()
_tag_environment_for_corpses(module) _tag_environment_for_corpses(module)
_level_root.add_child(module) _level_root.add_child(module)
# A fresh blood-splat pool for this level, alongside the module under LevelRoot so it's
# torn down with everything else on the next load — stains never leak into the next fight.
_level_root.add_child(preload("res://blood_decals.gd").new())
## Ground and walls stay on WORLD so the bull and live matadors collide with them; here ## Ground and walls stay on WORLD so the bull and live matadors collide with them; here
+6
View File
@@ -100,6 +100,12 @@ func _ready() -> void:
exit_button.pressed.connect(_on_exit_pressed) exit_button.pressed.connect(_on_exit_pressed)
options_back.pressed.connect(_close_subpanels) options_back.pressed.connect(_close_subpanels)
credits_back.pressed.connect(_close_subpanels) credits_back.pressed.connect(_close_subpanels)
# The Options panel is keyboard rebinds + desktop-shaped audio rows — meaningless on a
# phone, so drop the entry point on touch devices.
options_button.visible = not Controls.use_touch_ui()
# On the web build get_tree().quit() just freezes the canvas (you can't close a browser
# tab from script), so Exit has nowhere to go — hide it. Native desktop keeps it.
exit_button.visible = not OS.has_feature("web")
_apply_theme() _apply_theme()
_build_version_label() _build_version_label()
_build_options_toggles() _build_options_toggles()
+5 -3
View File
@@ -102,9 +102,10 @@ func _ready() -> void:
_anim_player = _find_anim_player(_mesh) _anim_player = _find_anim_player(_mesh)
if _skeleton: if _skeleton:
_sim = MatadorRagdoll.build(_skeleton) _sim = MatadorRagdoll.build(_skeleton)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild as non-skinned # Native GPU skinning by default; rigid_skin is an opt-in fallback now (the Mali/ANGLE
# bone-attached pieces. Runs after the ragdoll sim is built (both drive the same bones). # invisible-skinning bug is fixed in the 4.7 web templates). See Controls.rigid_skin_enabled().
if OS.has_feature("web"): # When on, it runs after the ragdoll sim is built (both drive the same bones).
if Controls.rigid_skin_enabled():
RigidSkin.convert_tree(self) RigidSkin.convert_tree(self)
if _anim_player: if _anim_player:
for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS: for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
@@ -973,6 +974,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0)
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir) _blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
Gore.splat(global_position, hit_dir, 0.55)
if _death_player: if _death_player:
_death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.pitch_scale = randf_range(0.9, 1.1)
_death_player.play() _death_player.play()
+164
View File
@@ -0,0 +1,164 @@
extends CanvasLayer
## Mobile-only health readouts (instanced by hud.gd only when Controls.use_touch_ui()).
## The desktop HUD's bull pips are tiny and, on a phone, the on-screen thumb cluster crowds
## the bottom edge — so on touch we hide that cluster (hud.gd) and paint health up top where
## it's clear of the joystick and abilities:
## • bull health — a big pip row, top-left
## • boss health — a wide Souls-style bar, top-centre, shown only while a bear is alive
##
## Everything is drawn in one IGNORE-filtered Control (never eats a touch), same immediate-mode
## approach as touch_controls.gd. Cached values are refreshed from each character's
## health_changed signal; the bear is discovered by group (&"bear") because it spawns after the
## HUD, and a level swap frees it so we re-acquire the next one.
const _HP_FULL: Color = Color(0.82, 0.16, 0.12)
const _HP_BONUS: Color = Color(1.00, 0.84, 0.18)
const _SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92)
const _PANEL_BG: Color = Color(0.06, 0.04, 0.02, 0.82)
const _PIP_W: float = 42.0
const _PIP_H: float = 28.0
const _PIP_GAP: float = 7.0
var _surface: Control
var _player: Node = null
var _bull_cur: int = 0
var _bull_max: int = 0
var _bull_bonus: int = 0
var _boss: Node = null
var _boss_alive: bool = false
var _boss_cur: int = 0
var _boss_max: int = 1
func _ready() -> void:
layer = 9 # below touch UI (10) and the HUD/game-over (11) so the outcome screen covers it
process_mode = Node.PROCESS_MODE_ALWAYS
_surface = Control.new()
_surface.set_anchors_preset(Control.PRESET_FULL_RECT)
_surface.mouse_filter = Control.MOUSE_FILTER_IGNORE
_surface.draw.connect(_draw_surface)
add_child(_surface)
get_viewport().size_changed.connect(_surface.queue_redraw)
func _process(_delta: float) -> void:
if not Controls.use_touch_ui():
return
if _player == null:
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_player = players[0]
_player.health_changed.connect(_on_bull_health)
_on_bull_health(_player.call(&"get_hp"), _player.call(&"get_max_hp"))
# A level swap frees the old bear; drop the stale ref so the next level's bear is picked up.
if _boss != null and not is_instance_valid(_boss):
_boss = null
_boss_alive = false
_surface.queue_redraw()
if _boss == null:
var bears := get_tree().get_nodes_in_group(&"bear")
if not bears.is_empty():
_boss = bears[0]
_boss_alive = true
_boss.health_changed.connect(_on_boss_health)
if _boss.has_signal(&"killed"):
_boss.killed.connect(_on_boss_killed)
_boss_cur = int(_boss.call(&"get_hp"))
_boss_max = maxi(_boss_cur, int(DP.f("bear_max_hp")))
_surface.queue_redraw()
func _on_bull_health(current: int, max_hp: int) -> void:
_bull_cur = maxi(current, 0)
_bull_max = maxi(max_hp, 0)
_bull_bonus = 0
if _player != null and _player.has_method(&"get_bonus_hp"):
_bull_bonus = int(_player.call(&"get_bonus_hp"))
_surface.queue_redraw()
func _on_boss_health(current: int, max_hp: int) -> void:
_boss_cur = maxi(current, 0)
_boss_max = maxi(max_hp, maxi(_boss_cur, 1))
if _boss_cur <= 0:
_boss_alive = false
_surface.queue_redraw()
func _on_boss_killed() -> void:
_boss_alive = false
_surface.queue_redraw()
# ── Drawing ─────────────────────────────────────────────────────────────────────
func _draw_surface() -> void:
if not Controls.use_touch_ui():
return
var boss_showing := _boss != null and is_instance_valid(_boss) and _boss_alive and _boss_cur > 0
# The boss bar is centred at the very top; the bull pip row is top-left. On a narrow landscape
# phone a long pip row reaches the centre and collides with the boss bar, so when the bar is up
# the bull row drops to a second line just beneath it (guaranteed clear, whatever the HP count).
var bull_top := 22.0
if boss_showing:
bull_top = _draw_boss_bar() + 12.0
_draw_bull_health(bull_top)
# Big pip row in the top-left. Bonus pips (barrel reward) sit at the high end and light gold,
# matching the desktop HUD; base pips light red; spent pips read as dark sockets.
func _draw_bull_health(top: float) -> void:
var font := UiFonts.body()
var x := 24.0
var y := top
if font != null:
var label := "BULL"
var fs := 20
_surface.draw_string(
font, Vector2(x, y + _PIP_H * 0.85), label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
UiTheme.GOLD)
x += font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x + 12.0
var base_max := _bull_max - _bull_bonus
for i: int in _bull_max:
var r := Rect2(x + i * (_PIP_W + _PIP_GAP), y, _PIP_W, _PIP_H)
_surface.draw_rect(r, _SOCKET)
if i < _bull_cur:
var c := _HP_BONUS if i >= base_max else _HP_FULL
_surface.draw_rect(r.grow(-2.0), c)
_surface.draw_rect(r, Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.45), false, 2.0)
# Wide boss bar centred at the top: dark trough, red fill scaled to remaining HP, gold trim,
# name centred above it. Returns the bar's bottom Y so the bull pip row can sit clear beneath it.
func _draw_boss_bar() -> float:
var vp := get_viewport().get_visible_rect().size
var bw := clampf(vp.x * 0.55, 280.0, 620.0)
var bh := 30.0
var bx := (vp.x - bw) * 0.5
var by := 20.0
var bar := Rect2(bx, by, bw, bh)
_surface.draw_rect(bar, _PANEL_BG)
var frac := clampf(float(_boss_cur) / float(_boss_max), 0.0, 1.0)
var fill := bar.grow(-3.0)
fill.size.x *= frac
_surface.draw_rect(fill, _HP_FULL)
_surface.draw_rect(bar, UiTheme.BORDER, false, 2.0)
var font := UiFonts.body()
if font != null:
var boss_name := "BEAR"
var fs := 20
var tw := font.get_string_size(boss_name, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
_surface.draw_string(
font, Vector2(bx + (bw - tw.x) * 0.5, by - 6.0), boss_name,
HORIZONTAL_ALIGNMENT_LEFT, -1, fs, UiTheme.GOLD)
return by + bh
+1
View File
@@ -0,0 +1 @@
uid://p8cqggisskor
+28 -11
View File
@@ -28,6 +28,11 @@ var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0 var _pre_slide_vel_y: float = 0.0
var _slam_ring_mesh: ArrayMesh = null var _slam_ring_mesh: ArrayMesh = null
# Attack-FX resources are identical every swing, so build them once and reuse. Recreating a fresh
# StandardMaterial3D / SphereMesh per attack makes a mobile WebGL driver recompile the shader and
# re-upload the mesh each time — a main-thread hitch "when attacking" that never shows on desktop.
var _unshaded_mat: StandardMaterial3D = null
var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared)
# Ability system — kick=0, dash=1, slam=2, roll=3 # Ability system — kick=0, dash=1, slam=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0] var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
@@ -89,10 +94,9 @@ var _hit_flash_tween: Tween
func _ready() -> void: func _ready() -> void:
add_to_group(&"player") add_to_group(&"player")
# Mali/ANGLE mobile GPUs can't run Godot's Compatibility vertex-skinning (transform # Native GPU skinning by default (the old Mali/ANGLE invisible-skinning bug is fixed in the
# feedback) — skinned meshes render invisible on the web build. Rebuild the bull as # 4.7 web templates). rigid_skin is now an opt-in fallback — see Controls.rigid_skin_enabled().
# non-skinned bone-attached pieces there; desktop keeps smooth GPU skinning. if Controls.rigid_skin_enabled():
if OS.has_feature("web"):
RigidSkin.convert_tree(self) RigidSkin.convert_tree(self)
_max_hp = maxi(1, int(DP.f("bull_max_hp"))) _max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp _hp = _max_hp
@@ -681,23 +685,33 @@ func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles; # All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
# these three helpers remove the boilerplate each spawn function used to repeat. # these three helpers remove the boilerplate each spawn function used to repeat.
# One shared unshaded material for every particle FX (per-particle colour comes from the
# CPUParticles3D colour_ramp via vertex colour, not the material) — built once so the mobile
# driver compiles this shader a single time instead of on every attack.
func _unshaded_material() -> StandardMaterial3D: func _unshaded_material() -> StandardMaterial3D:
var mat := StandardMaterial3D.new() if _unshaded_mat == null:
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED _unshaded_mat = StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA _unshaded_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true _unshaded_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED _unshaded_mat.vertex_color_use_as_albedo = true
return mat _unshaded_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return _unshaded_mat
# Low-poly sphere sized for a particle mesh (height is always the diameter). # Low-poly sphere sized for a particle mesh (height is always the diameter). Cached per size so
# repeat attacks reuse the same mesh (one GPU upload) instead of generating + uploading a new one.
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh: func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
var key := "%.4f:%d:%d" % [radius, segments, rings]
var cached: SphereMesh = _particle_sphere_cache.get(key)
if cached != null:
return cached
var sphere := SphereMesh.new() var sphere := SphereMesh.new()
sphere.radius = radius sphere.radius = radius
sphere.height = radius * 2.0 sphere.height = radius * 2.0
sphere.radial_segments = segments sphere.radial_segments = segments
sphere.rings = rings sphere.rings = rings
sphere.material = mat sphere.material = mat
_particle_sphere_cache[key] = sphere
return sphere return sphere
@@ -1191,6 +1205,9 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void
# bull). Melee gores pass no position and skip the spurt; gore can be turned off. # bull). Melee gores pass no position and skip the spurt; gore can be turned off.
if cause == "thrown" and Settings.gore: if cause == "thrown" and Settings.gore:
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position) _spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
# Any hit — melee or thrown — leaves a lasting stain on the ground the bull bled on.
var splat_dir := global_position - hit_pos if hit_pos != Vector3.ZERO else Vector3.ZERO
Gore.splat(global_position, splat_dir, 0.5)
if _huff_player: if _huff_player:
_huff_player.pitch_scale = randf_range(0.7, 0.9) _huff_player.pitch_scale = randf_range(0.7, 0.9)
_huff_player.play() _huff_player.play()
+2
View File
@@ -28,6 +28,8 @@ DebugDraw="*res://debug_draw.gd"
Settings="*res://settings.gd" Settings="*res://settings.gd"
Run="*res://levels/run_state.gd" Run="*res://levels/run_state.gd"
OrientationGuard="*res://orientation_guard.gd" OrientationGuard="*res://orientation_guard.gd"
WebStartGate="*res://web_start_gate.gd"
Gore="*res://gore.gd"
[display] [display]
+40 -17
View File
@@ -43,9 +43,11 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
bind_bone.append(bone) bind_bone.append(bone)
bind_pose.append(skin.get_bind_pose(b)) bind_pose.append(skin.get_bind_pose(b))
# Accumulate SurfaceTool geometry per destination bone. # Accumulate SurfaceTool geometry per (destination bone, source surface). Keying on the
var st_by_bone: Dictionary = {} # bone index -> SurfaceTool # surface too — not just the bone — is what preserves materials: a head bone that carries
var mat_by_bone: Dictionary = {} # bone index -> Material (first seen) # both the skin and hair surfaces must stay two pieces with two materials, otherwise every
# surface funnelled to a bone collapses onto one material (bald matadors, wrong uniforms).
var groups: Dictionary = {} # "bone:surface" -> {"st": SurfaceTool, "mat": Material, "bone": int}
for s: int in mesh.get_surface_count(): for s: int in mesh.get_surface_count():
var arr := mesh.surface_get_arrays(s) var arr := mesh.surface_get_arrays(s)
@@ -56,7 +58,13 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
var bones: PackedInt32Array = arr[Mesh.ARRAY_BONES] var bones: PackedInt32Array = arr[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS] var weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS]
var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX] var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX]
var mat := mesh.surface_get_material(s) # Honour a per-instance override (whole mesh) or per-surface override the scene set,
# falling back to the material baked into the mesh surface.
var mat: Material = mi.material_override
if mat == null:
mat = mi.get_surface_override_material(s)
if mat == null:
mat = mesh.surface_get_material(s)
var infl := 8 if (mesh.surface_get_format(s) & Mesh.ARRAY_FLAG_USE_8_BONE_WEIGHTS) else 4 var infl := 8 if (mesh.surface_get_format(s) & Mesh.ARRAY_FLAG_USE_8_BONE_WEIGHTS) else 4
var tri := PackedInt32Array() var tri := PackedInt32Array()
@@ -74,12 +82,14 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
var bind := _dominant_bind([a, b, c], bones, weights, infl) var bind := _dominant_bind([a, b, c], bones, weights, infl)
var bone := bind_bone[bind] var bone := bind_bone[bind]
var pose := bind_pose[bind] var pose := bind_pose[bind]
var st: SurfaceTool = st_by_bone.get(bone) var key := "%d:%d" % [bone, s]
if st == null: var group: Dictionary = groups.get(key, {})
st = SurfaceTool.new() if group.is_empty():
st.begin(Mesh.PRIMITIVE_TRIANGLES) var new_st := SurfaceTool.new()
st_by_bone[bone] = st new_st.begin(Mesh.PRIMITIVE_TRIANGLES)
mat_by_bone[bone] = mat group = {"st": new_st, "mat": mat, "bone": bone}
groups[key] = group
var st: SurfaceTool = group["st"]
for v: int in [a, b, c]: for v: int in [a, b, c]:
if cols.size() > v: if cols.size() > v:
st.set_color(cols[v]) st.set_color(cols[v])
@@ -89,19 +99,32 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
st.set_normal((pose.basis * norms[v]).normalized()) st.set_normal((pose.basis * norms[v]).normalized())
st.add_vertex(pose * verts[v]) st.add_vertex(pose * verts[v])
if st_by_bone.is_empty(): if groups.is_empty():
return false return false
for bone: int in st_by_bone: # Combine all of a bone's surface-groups into ONE mesh under ONE BoneAttachment (each group
# stays its own surface + material). Same draw calls as before, but roughly half the nodes to
# transform/cull every frame — which is what a phone feels while ragdolls drive the skeleton.
var by_bone: Dictionary = {} # bone:int -> Array[Dictionary]
for key: String in groups:
var group: Dictionary = groups[key]
var bone: int = group["bone"]
if not by_bone.has(bone):
by_bone[bone] = []
(by_bone[bone] as Array).append(group)
for bone: int in by_bone:
var att := BoneAttachment3D.new() var att := BoneAttachment3D.new()
att.bone_name = skel.get_bone_name(bone) att.bone_name = skel.get_bone_name(bone)
skel.add_child(att) skel.add_child(att)
var piece := MeshInstance3D.new() var piece := MeshInstance3D.new()
var st: SurfaceTool = st_by_bone[bone] var combined := ArrayMesh.new()
var m := st.commit() for group: Dictionary in by_bone[bone]:
if mat_by_bone[bone] != null: var st: SurfaceTool = group["st"]
m.surface_set_material(0, mat_by_bone[bone]) st.commit(combined)
piece.mesh = m if group["mat"] != null:
combined.surface_set_material(combined.get_surface_count() - 1, group["mat"])
piece.mesh = combined
att.add_child(piece) att.add_child(piece)
# Hide (don't free) the original: keeps the node for any gameplay code that references # Hide (don't free) the original: keeps the node for any gameplay code that references
+16
View File
@@ -22,10 +22,26 @@ echo ""
echo "=== Level switch tests (no cross-level geometry/collider leaks) ===" echo "=== Level switch tests (no cross-level geometry/collider leaks) ==="
"$GODOT" --headless --script tests/level_switch_test.gd "$GODOT" --headless --script tests/level_switch_test.gd
echo ""
echo "=== Touch control tests (joystick self-heals, never strands) ==="
"$GODOT" --headless --script tests/touch_controls_test.gd
echo ""
echo "=== Blood decal tests (floor+wall stamps, ring-buffer cap, gore gate) ==="
"$GODOT" --headless --script tests/blood_decals_test.gd
echo ""
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
"$GODOT" --headless --script tests/rigid_skin_test.gd
echo "" echo ""
echo "=== Performance tests (frame-budget regression guard) ===" echo "=== Performance tests (frame-budget regression guard) ==="
"$GODOT" --headless --script tests/performance_test.gd "$GODOT" --headless --script tests/performance_test.gd
echo ""
echo "=== Ragdoll perf (on-hit spike breakdown: build / sim-start / burst / mass hit) ==="
"$GODOT" --headless --script tests/ragdoll_perf_test.gd
if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then
echo "" echo ""
echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ===" echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ==="
+97
View File
@@ -0,0 +1,97 @@
extends SceneTree
## Persistent blood splats (blood_decals.gd + the Gore autoload). Guards four contracts:
## • a splat stamps quads onto the floor and onto a wall within reach of the hit;
## • the ring buffer caps the instance count no matter how many hits land;
## • Settings.gore = false spawns nothing;
## • the pool is a plain node, so it frees with its parent (a level swap clears the stains).
## blood_decals.gd references the DP autoload, so it's load()ed at runtime here (not a
## top-level preload) — under --script a preload compiles before autoloads register.
## Run: godot --headless --script tests/blood_decals_test.gd
var _pass := 0
var _fail := 0
func _init() -> void:
_run.call_deferred()
func _ok(c: bool, m: String) -> void:
if c:
_pass += 1
print(" PASS: ", m)
else:
_fail += 1
print(" FAIL: ", m)
# Count instances actually placed on the map. Only the first `visible_instance_count` slots of
# the ring buffer are drawn, so that's the live count once the buffer has wrapped.
func _live_instances(pool: Node) -> int:
var mm: MultiMesh = pool.multimesh
if mm == null:
return 0
return mm.visible_instance_count
func _static_box(size: Vector3, pos: Vector3) -> StaticBody3D:
var body := StaticBody3D.new()
body.collision_layer = 1 # PhysicsLayers.WORLD
body.position = pos
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape.shape = box
body.add_child(shape)
return body
func _run() -> void:
var dp: Node = root.get_node("DP")
var settings: Node = root.get_node("Settings")
var cap := int(dp.f("blood_cap"))
# A minimal world: a floor plane and one vertical wall, both on WORLD so the splat rays hit.
var world := Node3D.new()
root.add_child(world)
world.add_child(_static_box(Vector3(40, 1, 40), Vector3(0, -0.5, 0))) # floor
world.add_child(_static_box(Vector3(1, 6, 40), Vector3(1.4, 3, 0))) # wall at x≈1.4
var pool: Node = (load("res://blood_decals.gd") as GDScript).new()
world.add_child(pool)
await create_timer(0.4).timeout
# Baked atlas exists and is one row of 8 shapes.
var atlas: Texture2D = pool.material_override.get_shader_parameter("atlas")
_ok(atlas != null and atlas.get_width() == 8 * 64, "splat atlas baked (8 cells)")
# A hit next to the wall stains both the floor beneath it and the wall.
settings.gore = true
pool.splat(Vector3(0.8, 0.1, 0.0), Vector3(1, 0, 0), 0.6)
var after_one := _live_instances(pool)
_ok(after_one > 0, "a splat stamps the floor (got %d quads)" % after_one)
# Floor cluster is 1 + satellites; anything beyond that came from the wall fan.
var floor_max := 1 + int(dp.f("blood_satellites"))
_ok(
after_one > floor_max,
"a nearby wall also catches blood (%d > floor-only %d)" % [after_one, floor_max]
)
# Ring buffer: hammer far past the cap; live count never exceeds it.
for i in cap * 2:
pool.splat(Vector3(0.0, 0.1, 0.0), Vector3(1, 0, 0), 0.4)
_ok(_live_instances(pool) <= cap, "ring buffer holds at the cap (%d)" % cap)
# Gore off: no new stains. Route through the Gore autoload to prove the gate lives there.
var before_off := _live_instances(pool)
settings.gore = false
root.get_node("Gore").splat(Vector3(5, 0.1, 0.0), Vector3.ZERO, 0.6)
_ok(_live_instances(pool) == before_off, "gore off spawns nothing")
# Lifetime: the pool is a plain node, so freeing its parent (a level swap) clears it.
world.queue_free()
await create_timer(0.2).timeout
_ok(not is_instance_valid(pool), "pool frees with its parent (no leak into next level)")
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://b8em8poj26a57
+8 -1
View File
@@ -53,7 +53,14 @@ func _run() -> void:
# Shell survives and hosts exactly one level module. # Shell survives and hosts exactly one level module.
var level_root: Node = scene.get_node_or_null("LevelRoot") var level_root: Node = scene.get_node_or_null("LevelRoot")
_ok(level_root != null, "shell has a LevelRoot") _ok(level_root != null, "shell has a LevelRoot")
_ok(level_root != null and level_root.get_child_count() == 1, "exactly one level module loaded") # LevelRoot holds the module plus the level's blood-splat pool; exactly one of them
# is the geometry module (the other is BloodDecals).
var module_children := 0
if level_root != null:
for child: Node in level_root.get_children():
if not child.is_in_group(&"blood_decals"):
module_children += 1
_ok(module_children == 1, "exactly one level module loaded")
_ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present") _ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
# The leak we are guarding against: no arena geometry may exist in the bear level. # The leak we are guarding against: no arena geometry may exist in the bear level.
+201
View File
@@ -0,0 +1,201 @@
extends SceneTree
## Micro-benchmark for the on-hit ragdoll spike.
##
## On mobile the profiler reads SCRIPT-bound (main-thread GDScript, one thread) exactly when the
## bull's attack connects and a matador is hit. This times the pieces of that synchronous path so
## we can see which op eats the milliseconds instead of guessing:
## • spawn — _matador.instantiate() + add_child (_ready builds the 21-bone ragdoll rig)
## • sim start — PhysicalBoneSimulator3D.physical_bones_start_simulation() (Jolt makes bodies)
## • blood burst — the CPUParticles3D one-shot fired on death
## • full hit — apply_ability_hit() end to end (what the player actually triggers)
## • MASS hit — hitting N matadors in ONE frame (what a slam / roll does — the real spike)
##
## Run: godot --headless --script res://tests/ragdoll_perf_test.gd
## It PRINTS per-op avg/worst ms and names the dominant cost; it also fails (exit 1) if a single
## hit or a mass-hit frame blows a generous budget, so it doubles as a regression guard.
# Loaded at runtime (not preload): a preload here would compile matador.gd at this entry
# script's parse time, before the autoloads (DP / Controls) register as global identifiers,
# so matador.gd's `Controls.rigid_skin_enabled()` would fail to resolve. load() in _run runs
# after the tree — and its autoloads — are up.
var _matador: PackedScene = null
const N := 8 # matadors sampled per op
const MASS := 6 # matadors hit in one frame (a slam catching a cluster)
const SINGLE_HIT_BUDGET_MS := 12.0
const MASS_HIT_BUDGET_MS := 33.0 # two 60fps frames — a slam may cost a hitch, not a freeze
var _fail := 0
func _init() -> void:
_run.call_deferred()
func _us() -> int:
return Time.get_ticks_usec()
func _stats(us: Array) -> Dictionary:
var total := 0
var worst := 0
for v: int in us:
total += v
worst = maxi(worst, v)
var avg := (total / us.size()) if us.size() > 0 else 0
return {"avg_ms": avg / 1000.0, "max_ms": worst / 1000.0}
func _sample(frames: int) -> Dictionary:
var proc_sum := 0.0
var phys_sum := 0.0
for f: int in frames:
await process_frame
proc_sum += Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
phys_sum += Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
return {"proc_ms": proc_sum / frames, "phys_ms": phys_sum / frames}
func _spawn_one() -> Node3D:
var m: Node3D = _matador.instantiate()
m.position = Vector3(randf_range(-20.0, 20.0), 0.0, randf_range(-20.0, 20.0))
root.add_child(m)
return m
func _run() -> void:
# Warm up: first instance pays one-time import/JIT/shader costs that would skew sample 1.
_matador = load("res://Matador.tscn") as PackedScene
var warm := _spawn_one()
await process_frame
warm.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
await process_frame
warm.free()
await process_frame
print("[stage] warmup done")
# ── spawn: instantiate() vs add_child(_ready = ragdoll build) ───────────────
var inst_us: Array = []
var ready_us: Array = []
for i: int in N:
var t0 := _us()
var m: Node3D = _matador.instantiate()
var t1 := _us()
root.add_child(m) # _ready runs synchronously → MatadorRagdoll.build (21 bodies + shapes)
var t2 := _us()
inst_us.append(t1 - t0)
ready_us.append(t2 - t1)
m.free()
await process_frame
print("[stage] spawn done")
# ── component: sim start (Jolt body creation) and blood burst, in isolation ──
var simstart_us: Array = []
var burst_us: Array = []
for i: int in N:
var m := _spawn_one()
await process_frame
var sim: Node = m.get(&"_sim")
if sim != null:
sim.set("active", true)
var t0 := _us()
sim.call(&"physical_bones_start_simulation")
simstart_us.append(_us() - t0)
var blood: Node = m.get(&"_blood_burst")
if blood != null:
var t2 := _us()
blood.call(&"burst", m.global_position + Vector3(0, 0.9, 0), Vector3(1, 0, 0))
burst_us.append(_us() - t2)
m.free()
await process_frame
print("[stage] components done")
# ── full hit: apply_ability_hit end to end (state WANDER → RAGDOLL) ──────────
var hit_us: Array = []
for i: int in N:
var m := _spawn_one()
await process_frame
var t0 := _us()
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
hit_us.append(_us() - t0)
await process_frame
m.free()
await process_frame
print("[stage] full-hit done")
# ── MASS hit: MASS matadors ragdolled in ONE frame (a slam catching a cluster) ─
var cluster: Array = []
for i: int in MASS:
cluster.append(_spawn_one())
await process_frame
await process_frame
var mt0 := _us()
for m: Node3D in cluster:
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
var mass_ms := (_us() - mt0) / 1000.0
for m: Node3D in cluster:
m.free()
await process_frame
print("[stage] mass done")
# ── STEADY load: per-frame cost of live_n ragdolls ALIVE at once (they live ~4 s each) ─
# The hit is instantaneous; the drag is every ragdoll still simulating afterward. Sample
# the frame cost with live_n matadors idle, then with all live_n ragdolling, and report the delta —
# split process (idle-frame GDScript = the mobile "SCRIPT" bucket) vs physics (the Jolt step).
var live_n := 10
var steady: Array = []
for i: int in live_n:
steady.append(_spawn_one())
for f: int in 15:
await process_frame
var idle: Dictionary = await _sample(20)
for m: Node3D in steady:
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
for f: int in 3:
await process_frame
var active: Dictionary = await _sample(20)
for m: Node3D in steady:
if is_instance_valid(m):
m.free()
# ── report ──────────────────────────────────────────────────────────────────
var inst := _stats(inst_us)
var rdy := _stats(ready_us)
var ss := _stats(simstart_us)
var bu := _stats(burst_us)
var hit := _stats(hit_us)
print("\n==== on-hit ragdoll cost (per matador, avg / worst) ====")
print(" instantiate() %6.2f / %6.2f ms" % [inst["avg_ms"], inst["max_ms"]])
print(" add_child (_ready build)%6.2f / %6.2f ms" % [rdy["avg_ms"], rdy["max_ms"]])
print(" sim start (Jolt bodies) %6.2f / %6.2f ms" % [ss["avg_ms"], ss["max_ms"]])
print(" blood burst %6.2f / %6.2f ms" % [bu["avg_ms"], bu["max_ms"]])
print(" FULL apply_ability_hit %6.2f / %6.2f ms" % [hit["avg_ms"], hit["max_ms"]])
print(" MASS hit (%d in 1 frame) %6.2f ms total" % [MASS, mass_ms])
print("---- steady per-frame cost, %d matadors idle vs ragdolling ----" % live_n)
print(" idle: process %5.2f ms physics %5.2f ms" % [idle["proc_ms"], idle["phys_ms"]])
print(" ragdolling: process %5.2f ms physics %5.2f ms" % [active["proc_ms"], active["phys_ms"]])
print(" delta/%d ragdolls: process +%5.2f ms physics +%5.2f ms (per ragdoll ~%.2f / %.2f ms)" % [
live_n, active["proc_ms"] - idle["proc_ms"], active["phys_ms"] - idle["phys_ms"],
(active["proc_ms"] - idle["proc_ms"]) / live_n, (active["phys_ms"] - idle["phys_ms"]) / live_n])
# Name the dominant component of the full hit so the fix target is obvious.
var parts := {"sim start": ss["avg_ms"], "blood burst": bu["avg_ms"]}
var worst_name := "sim start"
var worst_val := -1.0
for k: String in parts:
if parts[k] > worst_val:
worst_val = parts[k]
worst_name = k
print(" >> dominant hit cost: %s (%.2f ms of the %.2f ms hit)" % [
worst_name, worst_val, hit["avg_ms"]])
if hit["max_ms"] > SINGLE_HIT_BUDGET_MS:
_fail += 1
print(" FAIL: worst single hit %.2f ms > %.1f ms budget" % [hit["max_ms"], SINGLE_HIT_BUDGET_MS])
if mass_ms > MASS_HIT_BUDGET_MS:
_fail += 1
print(" FAIL: mass hit %.2f ms > %.1f ms budget" % [mass_ms, MASS_HIT_BUDGET_MS])
print("Results: %s" % ("FAIL (%d)" % _fail if _fail > 0 else "PASS"))
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://c8jvxwwmg7cp1
+93
View File
@@ -0,0 +1,93 @@
extends SceneTree
## Guard for the web/mobile rigid-skin conversion (rigid_skin.gd). The bug it protects against:
## the converter used to key geometry by destination bone only, so every surface funnelled to a
## bone collapsed onto one material — matadors came out bald with the wrong uniform colour on the
## web build. It now keys per (bone, surface), so all source materials survive. This asserts the
## matador keeps every distinct material after conversion, produces bone-attached pieces, and
## hides the original skinned meshes (so they never hit the invisible-on-Mali skinning path).
## Run: godot --headless --script res://tests/rigid_skin_test.gd
const RigidSkin = preload("res://rigid_skin.gd")
var _pass := 0
var _fail := 0
func _init() -> void:
_run.call_deferred()
func _ok(c: bool, m: String) -> void:
if c:
_pass += 1
print(" PASS: ", m)
else:
_fail += 1
print(" FAIL: ", m)
func _surface_mats(mi: MeshInstance3D) -> Array:
var out: Array = []
var mesh := mi.mesh
if mesh == null:
return out
for s: int in mesh.get_surface_count():
var mat: Material = mi.material_override
if mat == null:
mat = mi.get_surface_override_material(s)
if mat == null:
mat = mesh.surface_get_material(s)
if mat != null and not out.has(mat):
out.append(mat)
return out
func _run() -> void:
var inst: Node = (load("res://Assets/Matador.glb") as PackedScene).instantiate()
root.add_child(inst)
# Distinct materials on the skinned source meshes, before conversion.
var source: Array = []
var skinned: Array = []
for mi: MeshInstance3D in inst.find_children("*", "MeshInstance3D", true, false):
if mi.skin != null:
skinned.append(mi)
for mat: Material in _surface_mats(mi):
if not source.has(mat):
source.append(mat)
_ok(source.size() >= 2, "matador source has multiple distinct materials (%d)" % source.size())
var converted := RigidSkin.convert_tree(inst)
_ok(converted > 0, "convert_tree rebuilt %d skinned mesh(es)" % converted)
# Distinct materials on the generated bone-attached pieces.
var pieces := 0
var piece_mats: Array = []
for att: BoneAttachment3D in inst.find_children("*", "BoneAttachment3D", true, false):
for mi: MeshInstance3D in att.find_children("*", "MeshInstance3D", true, false):
pieces += 1
var mesh := mi.mesh
if mesh == null:
continue
# Pieces are multi-surface now (a bone's materials each stay their own surface), so
# scan every surface — not just surface 0 — to confirm none were dropped.
for s: int in mesh.get_surface_count():
var mat := mesh.surface_get_material(s)
if mat != null and not piece_mats.has(mat):
piece_mats.append(mat)
_ok(pieces > 0, "conversion produced bone-attached pieces (%d)" % pieces)
_ok(piece_mats.size() >= source.size(),
"every source material survives conversion (%d of %d kept)" % [piece_mats.size(), source.size()])
var all_hidden := true
for mi: MeshInstance3D in skinned:
if mi.visible:
all_hidden = false
_ok(all_hidden, "original skinned meshes are hidden (never drawn on the failing path)")
_finish()
func _finish() -> void:
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://qmf1j0ytjag5
+123
View File
@@ -0,0 +1,123 @@
extends SceneTree
## Guardrail for the on-screen joystick's self-healing. The reported bug is a stick that
## "disappears / goes non-responsive": a touchend dropped during a resize/orientation flip (or
## an app backgrounding) leaves _stick_active stuck true, so the ghost hint hides and no new
## stick can start — and the bull keeps coasting on the held move actions. touch_controls.gd
## now resets on relayout + focus-out, prunes orphaned touches, and lets the latest left-zone
## touch re-acquire the stick. This drives synthetic touch events and asserts the stick never
## stays stranded and never leaves movement held.
## Run: godot --headless --script res://tests/touch_controls_test.gd
const _MOVE := [&"move_forward", &"move_back", &"move_left", &"move_right"]
var _pass := 0
var _fail := 0
var _tc: CanvasLayer
func _init() -> void:
_run.call_deferred()
func _ok(c: bool, m: String) -> void:
if c:
_pass += 1
print(" PASS: ", m)
else:
_fail += 1
print(" FAIL: ", m)
func _press(index: int, pos: Vector2) -> void:
var e := InputEventScreenTouch.new()
e.index = index
e.position = pos
e.pressed = true
_tc._input(e)
func _release(index: int, pos: Vector2) -> void:
var e := InputEventScreenTouch.new()
e.index = index
e.position = pos
e.pressed = false
_tc._input(e)
func _drag(index: int, pos: Vector2) -> void:
var e := InputEventScreenDrag.new()
e.index = index
e.position = pos
_tc._input(e)
func _any_move_held() -> bool:
for a: StringName in _MOVE:
if Input.is_action_pressed(a):
return true
return false
# A point safely inside the left-hand joystick zone (independent of viewport size), plus the
# same point pushed a full radius "up" so the stick engages move_forward.
func _stick_origin() -> Vector2:
return _tc._stick_zone.position + _tc._stick_zone.size * Vector2(0.25, 0.5)
func _run() -> void:
var dp: Node = root.get_node("/root/DP")
var controls: Node = root.get_node("/root/Controls")
dp.call("set_value", "force_touch_controls", true)
_ok(controls.call("use_touch_ui"), "force flag makes use_touch_ui() true (touch UI live)")
# Headless boots a 64x64 root viewport, which collapses every UI rect on top of each other.
# Give it a real phone-ish landscape size so the joystick zone / buttons lay out sanely.
root.size = Vector2i(1152, 648)
_tc = (load("res://touch_controls.gd") as Script).new()
root.add_child(_tc)
await process_frame # _ready builds _surface and runs the first _relayout
_tc._relayout() # ensure the layout reflects the size set above
var o := _stick_origin()
var up := o + Vector2(0.0, -_tc._stick_radius)
# (a) A resize mid-drag (dropped touchend) must not strand the stick or keep the bull moving.
_tc._reset_touch_state()
_press(0, o)
_drag(0, up)
_ok(_tc._stick_active and _any_move_held(), "drag engages the stick and holds movement")
_tc._relayout() # the resize/orientation flip that used to swallow the release
_ok(not _tc._stick_active, "a relayout mid-drag stands the stick down (not stranded)")
_ok(not _any_move_held(), "movement is released on relayout (bull doesn't coast)")
# (b) App backgrounded / focus lost — same self-heal via the notification hook.
_tc._reset_touch_state()
_press(0, o)
_drag(0, up)
_tc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_OUT)
_ok(not _tc._stick_active and not _any_move_held(), "focus-out clears the stick and movement")
# (c) A stranded stick self-heals: with finger 0's release lost, a fresh finger 1 in the zone
# re-acquires the stick; finger 0's late release must NOT kill finger 1's stick.
_tc._reset_touch_state()
_press(0, o) # finger 0 owns the stick
_press(1, o + Vector2(20, 10)) # finger 1 lands while 0 is still (wrongly) held → takes over
_ok(_tc._stick_active and _tc._stick_index == 1, "latest left-zone touch re-acquires the stick")
_release(0, o) # the stranded finger finally reports up
_ok(_tc._stick_active and _tc._stick_index == 1, "a superseded finger's release doesn't end the stick")
_release(1, o)
_ok(not _tc._stick_active, "releasing the owning finger ends the stick")
# (d) Watchdog: a touch that vanishes from tracking (no release event at all) is pruned.
_tc._reset_touch_state()
_press(0, o)
_tc._touches.erase(0) # simulate the finger silently disappearing
_tc._process(0.016) # prune runs each frame
_ok(not _tc._stick_active and not _any_move_held(), "watchdog prunes a vanished touch and frees the stick")
_finish()
func _finish() -> void:
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://behtmgyi2ktm0
+188 -44
View File
@@ -54,8 +54,13 @@ var _ability_style: Array[StyleBoxFlat] = []
var _menu_style: StyleBoxFlat var _menu_style: StyleBoxFlat
# Layout rects in screen space — the single source of truth for drawing AND hit-testing. # 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 _stick_zone: Rect2 = Rect2()
var _ability_rects: Array[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() var _menu_rect: Rect2 = Rect2()
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a # 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 var _hint_pulse: float = 0.0
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is # 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_active: bool = false
var _stick_index: int = -1
var _origin: Vector2 = Vector2.ZERO var _origin: Vector2 = Vector2.ZERO
var _knob: Vector2 = Vector2.ZERO var _knob: Vector2 = Vector2.ZERO
var _stick_radius: float = 110.0 var _stick_radius: float = 110.0
@@ -88,6 +97,21 @@ var _pinch_dist: float = 0.0
var _dbg_events: int = 0 var _dbg_events: int = 0
var _dbg_last_pos: Vector2 = Vector2.ZERO 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 # 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 # 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 # 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. # the ability cluster and menu button sit in the right half so they never overlap it.
func _relayout() -> void: 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 vp := get_viewport().get_visible_rect().size
var short := minf(vp.x, vp.y) var short := minf(vp.x, vp.y)
_stick_radius = clampf(short * 0.16, 80.0, 150.0) _stick_radius = clampf(short * 0.16, 80.0, 150.0)
_stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y)) _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 bs := clampf(short * 0.22, 108.0, 152.0) # ability button edge — bigger, easier thumb target
var gap := bs * 0.18 var gap := bs * 0.22
var margin := bs * 0.28 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 right := vp.x - margin
var bottom := vp.y - margin var bottom := vp.y - margin
var col0 := right - bs * 2.0 - gap var col0 := right - bs * 2.0 - gap
@@ -164,8 +193,9 @@ func _relayout() -> void:
Rect2(col1, row1, bs, bs), # roll Rect2(col1, row1, bs, bs), # roll
] ]
var ms := clampf(short * 0.09, 48.0, 72.0) var ms := clampf(short * 0.11, 60.0, 84.0) # bigger menu button
_menu_rect = Rect2(vp.x - ms - 16.0, 16.0, ms, ms) 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) _stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74)
_surface.queue_redraw() _surface.queue_redraw()
@@ -230,14 +260,17 @@ func _on_press(index: int, pos: Vector2) -> void:
_touch_owner[index] = "menu" _touch_owner[index] = "menu"
return return
for slot: int in _ability_rects.size(): 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 _touch_owner[index] = slot
_slot_pressed[slot] = true
if _player != null: if _player != null:
_player.call(&"try_activate_ability", slot) _player.call(&"try_activate_ability", slot)
return 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" _touch_owner[index] = "stick"
_begin_stick(pos) _begin_stick(index, pos)
return return
_touch_owner[index] = "camera" _touch_owner[index] = "camera"
@@ -245,6 +278,7 @@ func _on_press(index: int, pos: Vector2) -> void:
func _on_drag(index: int, pos: Vector2) -> void: func _on_drag(index: int, pos: Vector2) -> void:
match _touch_owner.get(index, null): match _touch_owner.get(index, null):
"stick": "stick":
if index == _stick_index:
_update_stick(pos) _update_stick(pos)
"camera": "camera":
if _pinching: if _pinching:
@@ -252,21 +286,28 @@ func _on_drag(index: int, pos: Vector2) -> void:
func _on_release(index: int, pos: Vector2) -> void: 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": "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() _end_stick()
"menu": "menu":
if _menu_rect.has_point(pos): if _menu_rect.has_point(pos):
_touch_owner.erase(index) _touch_owner.erase(index)
_return_to_menu() _return_to_menu()
return return
if owner is int:
_slot_pressed[owner] = false
_touch_owner.erase(index) _touch_owner.erase(index)
# ── Joystick ──────────────────────────────────────────────────────────────── # ── Joystick ────────────────────────────────────────────────────────────────
func _begin_stick(pos: Vector2) -> void: func _begin_stick(index: int, pos: Vector2) -> void:
_stick_active = true _stick_active = true
_stick_index = index
_origin = pos _origin = pos
_knob = pos _knob = pos
@@ -283,6 +324,7 @@ func _update_stick(pos: Vector2) -> void:
func _end_stick() -> void: func _end_stick() -> void:
_stick_active = false _stick_active = false
_stick_index = -1
_release_move() _release_move()
@@ -345,7 +387,11 @@ func _draw_surface() -> void:
for slot: int in _ability_rects.size(): for slot: int in _ability_rects.size():
var r: Rect2 = _ability_rects[slot] var r: Rect2 = _ability_rects[slot]
_surface.draw_style_box(_ability_style[slot], r) _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: if _ability_tex[slot] != null:
_draw_icon_fit(_ability_tex[slot], inner) _draw_icon_fit(_ability_tex[slot], inner)
else: 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) _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: func _draw_debug() -> void:
var font := ThemeDB.fallback_font var font := ThemeDB.fallback_font
if font == null: if font == null:
return 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 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 := [ var lines := [
"GPU: %s" % RenderingServer.get_video_adapter_name(), "FPS %d frame %.1fms >> %s" % [
"chip: %s" % _unmasked_gpu(), int(round(fps)), 1000.0 / maxf(fps, 1.0), _perf_verdict(fps, proc_ms, phys_ms)],
"API: %s" % RenderingServer.get_video_adapter_api_version(), "process(script) %5.1fms peak %.1f" % [proc_ms, _peak_proc_ms],
"method=%s web=%s touch_ui=%s" % [ "physics(ragdoll) %5.1fms peak %.1f bodies %d" % [phys_ms, _peak_phys_ms, bodies],
ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"), "draws %d objs %d prims %s" % [draws, objs, _kfmt(prims)],
OS.has_feature("web"), Controls.use_touch_ui()], "nodes %d matadors %d vmem %.0fMB" % [node_n, mats.size(), vmem],
"bull: %s" % _node_report(players), "render %dx%d threads(iso)=%s" % [res.x, res.y, _iso_str()],
"matador(%d): %s" % [mats.size(), _node_report(mats)], "chip: %s fs: %s" % [_unmasked_gpu(), _fs_status_throttled()],
"events=%d last=%s touches=%d stick=%s" % [
_dbg_events, str(_dbg_last_pos.round()), _touches.size(), _stick_active],
] ]
var fs := 18
var fsz := 18
var line_h := 24.0 var line_h := 24.0
var w := 0.0 var w := 0.0
for line: String in lines: 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( _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)) Rect2(10.0, top, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.72))
var y := 34.0 + 22.0 var y := top + 22.0
for line: String in lines: for line: String in lines:
_surface.draw_string( _surface.draw_string(
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz, Color(1.0, 1.0, 0.35))
Color(1.0, 1.0, 0.3))
y += line_h y += line_h
# One-line "does this character exist / is it drawable / where is it" report for the debug # Names the current bottleneck from the frame breakdown. Directly tests the "CPU bound to one
# overlay, so an invisible bull/matador on-device can be pinned to spawn vs. position vs. # thread" hypothesis: if process or physics ms is high, that's the single main thread saturated
# GPU-draw failure. # and threads can't help it; if both are low while FPS is low, the GPU (fill rate) is the wall.
func _node_report(nodes: Array) -> String: func _perf_verdict(fps: float, proc_ms: float, phys_ms: float) -> String:
if nodes.is_empty(): if fps >= 55.0:
return "NONE" return "smooth"
var n := nodes[0] as Node3D if phys_ms >= 6.0 and phys_ms >= proc_ms:
if n == null: return "PHYSICS-bound (ragdolls, 1 thread)"
return "not Node3D" if proc_ms >= 6.0:
return "vis=%s pos=%s" % [n.is_visible_in_tree(), str(n.global_position.round())] 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: func _button_style(accent: Color) -> StyleBoxFlat:
@@ -484,6 +586,9 @@ func _button_style(accent: Color) -> StyleBoxFlat:
func _return_to_menu() -> void: func _return_to_menu() -> void:
get_tree().paused = false get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE 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") get_tree().change_scene_to_file("res://MainMenu.tscn")
@@ -497,16 +602,55 @@ func _process(delta: float) -> void:
if not active: if not active:
if _surface.visible: if _surface.visible:
_surface.visible = false _surface.visible = false
if _stick_active: _reset_touch_state()
_end_stick()
_touches.clear()
_touch_owner.clear()
_pinching = false
return return
_surface.visible = true _surface.visible = true
_prune_touch_state()
if _player == null: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
_player = players[0] _player = players[0]
_surface.queue_redraw() # cooldown overlays animate every frame _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()
+105
View File
@@ -0,0 +1,105 @@
extends CanvasLayer
## "TAP TO PLAY" gate for the web/touch build (autoload `WebStartGate`). Browsers only allow
## requestFullscreen / screen.orientation.lock from inside a user gesture, so instead of
## piggybacking the first *gameplay* tap (which used to race the resize/orientation flip and
## strand the joystick — a dropped touchend left the stick stuck), we capture a dedicated tap
## on a full-screen panel BEFORE play. The tap drives Controls.request_fullscreen_landscape()
## and the gate dismisses, so all the fullscreen/orientation churn happens with no in-flight
## touch to lose.
##
## Re-armable, so the player is never stuck windowed: whenever we're NOT fullscreen (boot, or
## after they left via Esc / Android back / edge swipe) the gate re-appears and its tap goes
## back in — fullscreen otherwise persists across menu ↔ game, so tapping Play never lands you
## windowed. Where fullscreen is impossible (iPhone Safari has no requestFullscreen) it shows
## once and then stays out of the way instead of nagging. Only active on web touch; no-op else.
const _POLL: float = 0.4 # seconds between browser fullscreen-state polls
var _panel: ColorRect
var _active: bool = false # web + touch: the only case this gate does anything
var _fs_supported: bool = true
var _dismissed_unsupported: bool = false # iPhone: tapped once, don't show again
var _poll_accum: float = 0.0
func _ready() -> void:
layer = 127 # above HUD/touch UI, just below OrientationGuard (128) so "rotate" wins in portrait
process_mode = Node.PROCESS_MODE_ALWAYS
_active = OS.has_feature("web") and Controls.use_touch_ui()
if not _active:
return
_fs_supported = Controls.fullscreen_supported()
_panel = ColorRect.new()
_panel.color = Color(0.05, 0.03, 0.02, 0.94)
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_panel.mouse_filter = Control.MOUSE_FILTER_STOP # swallow the tap from the game behind it
add_child(_panel)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_child(center)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 16)
center.add_child(box)
var title := Label.new()
title.text = "TAP TO PLAY"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 52)
title.add_theme_color_override("font_color", Color(1.0, 0.78, 0.22))
box.add_child(title)
var sub := Label.new()
sub.text = "Fullscreen, landscape"
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
sub.add_theme_font_size_override("font_size", 22)
sub.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85))
box.add_child(sub)
func _input(event: InputEvent) -> void:
if not _active or not _panel.visible:
return
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed)
if gesture:
# The native DOM listener (controls_manager.gd) enters fullscreen synchronously on this
# same tap; this is the belt-and-suspenders explicit call.
Controls.request_fullscreen_landscape()
_panel.visible = false
if not _fs_supported:
_dismissed_unsupported = true # iPhone: don't reappear, there's nothing to enter
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
if not _active:
return
_poll_accum += delta
if _poll_accum < _POLL:
return
_poll_accum = 0.0
# Portrait: OrientationGuard owns the screen — stand down until they turn the device.
if _is_portrait():
_panel.visible = false
return
if not _fs_supported:
# Can't ever enter fullscreen (iPhone) — offer the tap once, then get out of the way.
_panel.visible = not _dismissed_unsupported
return
# Show the gate whenever we're not fullscreen, and arm so the next tap (re-)enters.
var fs := Controls.is_browser_fullscreen()
_panel.visible = not fs
if not fs:
Controls.arm_fullscreen()
func _is_portrait() -> bool:
var s := get_viewport().get_visible_rect().size
return s.y > s.x
+1
View File
@@ -0,0 +1 @@
uid://esiec4is2ap5