diff --git a/CLAUDE.md b/CLAUDE.md index 7a4685e..dd4a052 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | | `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 | -| `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`) | | `Blender/` | Blender source files, animation scripts, FBX exports | | `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) 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/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/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_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 diff --git a/bear.gd b/bear.gd index 5b0ec7b..bd5a4bc 100644 --- a/bear.gd +++ b/bear.gd @@ -116,9 +116,10 @@ func _ready() -> void: # 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. _fx_player.animation_finished.connect(_on_fx_finished) - # Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as - # non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning. - if OS.has_feature("web"): + # Native GPU skinning by default (the Mali/ANGLE invisible-skinning bug is fixed in the 4.7 + # web templates). The bear is a single dense organic mesh, so the rigid-skin fallback tears + # seam holes as it animates — another reason native is the default. See rigid_skin_enabled(). + if Controls.rigid_skin_enabled(): RigidSkin.convert_tree(self) if _anim_player: _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 = hit_dir.normalized() _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)) if _hp <= 0: _die(hit_dir) @@ -918,6 +920,7 @@ func _die(hit_dir: Vector3) -> void: _hit_area.monitoring = false velocity = Vector3.ZERO _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) # 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. diff --git a/blood_decals.gd b/blood_decals.gd new file mode 100644 index 0000000..64e2f5c --- /dev/null +++ b/blood_decals.gd @@ -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) diff --git a/blood_decals.gd.uid b/blood_decals.gd.uid new file mode 100644 index 0000000..261d4e0 --- /dev/null +++ b/blood_decals.gd.uid @@ -0,0 +1 @@ +uid://cs8fhiasw7q7j diff --git a/blood_decals.gdshader b/blood_decals.gdshader new file mode 100644 index 0000000..498f3ea --- /dev/null +++ b/blood_decals.gdshader @@ -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; +} diff --git a/blood_decals.gdshader.uid b/blood_decals.gdshader.uid new file mode 100644 index 0000000..eb93930 --- /dev/null +++ b/blood_decals.gdshader.uid @@ -0,0 +1 @@ +uid://cj5rmaywacvbj diff --git a/controls_manager.gd b/controls_manager.gd index 89a7e73..cf1d413 100644 --- a/controls_manager.gd +++ b/controls_manager.gd @@ -23,36 +23,115 @@ const REBINDABLE_ACTIONS: PackedStringArray = [ func _ready() -> void: load_saved() + _install_web_fullscreen_hook() # ── Web fullscreen + landscape lock ───────────────────────────────────────────── -# Browsers only allow requestFullscreen / screen.orientation.lock from inside a user -# gesture, so we piggyback on the first tap/click of the web build to go fullscreen and -# lock to landscape. One-shot; input still flows to the menu/game normally (never consumed). -var _fs_triggered: bool = false - - -func _input(event: InputEvent) -> void: - if _fs_triggered or not OS.has_feature("web"): +# The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls +# requestFullscreen() synchronously inside the real user-gesture event — the same thing +# three.js games do. Godot dispatches input from its rAF render loop, one hop removed from the +# 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. +# All of it is guarded, so iPhone Safari (no requestFullscreen) and desktop just no-op cleanly. +func _install_web_fullscreen_hook() -> void: + if not OS.has_feature("web"): return - var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \ - or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed) - if gesture: - _fs_triggered = true - request_fullscreen_landscape() + JavaScriptBridge.eval(""" +(function(){ + if (window.__bullFSInit) return; + window.__bullFSInit = true; + 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 -## context (first tap, or a button press). No-op off web. Android Chrome/Brave honour the -## orientation lock; iOS Safari ignores it (unsupported), so the .catch() swallows the reject. +## Ask to enter fullscreen + landscape now (e.g. from WebStartGate's tap). The DOM listener +## installed above normally beats this to it on the same gesture; this is the explicit path and +## is a safe no-op if we're already fullscreen. No-op off web. func request_fullscreen_landscape() -> void: if not OS.has_feature("web"): return - DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN) - JavaScriptBridge.eval( - "(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) + _install_web_fullscreen_hook() + JavaScriptBridge.eval("if (window.__bullFS) window.__bullFS();", 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) @@ -117,6 +196,32 @@ func debug_overlay() -> bool: 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. func get_key_event(action: StringName) -> InputEventKey: for event: InputEvent in InputMap.action_get_events(action): diff --git a/debug_params.gd b/debug_params.gd index a5e1203..1b7e744 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -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 # 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) + # ── 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 ───────────────────────────────────────────────────────────────── # 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) @@ -422,6 +434,14 @@ func _register_all() -> void: # 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. _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, diff --git a/gore.gd b/gore.gd new file mode 100644 index 0000000..26abc3f --- /dev/null +++ b/gore.gd @@ -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")) diff --git a/gore.gd.uid b/gore.gd.uid new file mode 100644 index 0000000..4872c9e --- /dev/null +++ b/gore.gd.uid @@ -0,0 +1 @@ +uid://b3dobof8qule3 diff --git a/hud.gd b/hud.gd index a5d9672..b728292 100644 --- a/hud.gd +++ b/hud.gd @@ -95,6 +95,7 @@ func _ready() -> void: _build_damage_vignette() _build_game_over() _build_touch_controls() + _build_mobile_hud() _update_control_hints() _find_spawner.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()) +# 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 # directly beneath) so both read as one cluster instead of scattered corners. func _build_bottom_hud() -> void: @@ -171,18 +180,29 @@ func _position_bull_bg() -> void: 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: var lbl := Label.new() lbl.text = GameVersion.full() - lbl.add_theme_color_override("font_color", _MUTED) - lbl.add_theme_font_size_override("font_size", 12) - lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN + lbl.add_theme_color_override("font_color", Color(0.90, 0.86, 0.72)) + lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) + lbl.add_theme_constant_override("outline_size", 4) + lbl.add_theme_font_size_override("font_size", 16) lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN - lbl.offset_right = -14.0 lbl.offset_bottom = -10.0 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) @@ -494,7 +514,7 @@ func _build_game_over() -> void: # Buttons overlaid at the bottom of the clip. var row := HBoxContainer.new() 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_right = 0.5 row.anchor_top = 1.0 @@ -504,13 +524,13 @@ func _build_game_over() -> void: row.offset_bottom = -48.0 _over_root.add_child(row) - # Win → Continue to the run map; loss → Go Again from the top. Shown per-result in - # _show_game_over; both share the Enter shortcut. + # Win → continue to the run map; loss → restart from the top. Shown per-result in + # _show_game_over; both share the Enter shortcut and label. _over_continue = _make_button("Continue (Enter)") _over_continue.pressed.connect(_continue_to_lobby) row.add_child(_over_continue) - _over_again = _make_button("Go Again (Enter)") + _over_again = _make_button("Continue (Enter)") _over_again.pressed.connect(_restart) row.add_child(_over_again) @@ -518,6 +538,11 @@ func _build_game_over() -> void: menu.pressed.connect(_return_to_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 ─────────────────────────────────────────────────────────────── # 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 @@ -540,6 +565,9 @@ func _build_health() -> void: "panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0)) panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER 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) var row := HBoxContainer.new() @@ -799,6 +827,10 @@ func _build_controls_panel() -> void: 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: var btn := Button.new() btn.text = label_text @@ -819,6 +851,15 @@ func _make_button(label_text: String) -> Button: 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: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 10) diff --git a/levels/game_shell.gd b/levels/game_shell.gd index 27c9450..62a6ab1 100644 --- a/levels/game_shell.gd +++ b/levels/game_shell.gd @@ -30,6 +30,9 @@ func load_active_level() -> void: var module := level.level_scene.instantiate() _tag_environment_for_corpses(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 diff --git a/main_menu.gd b/main_menu.gd index 3837be4..759a8e9 100644 --- a/main_menu.gd +++ b/main_menu.gd @@ -100,6 +100,12 @@ func _ready() -> void: exit_button.pressed.connect(_on_exit_pressed) options_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() _build_version_label() _build_options_toggles() diff --git a/matador.gd b/matador.gd index 4905aab..36b053f 100644 --- a/matador.gd +++ b/matador.gd @@ -102,9 +102,10 @@ func _ready() -> void: _anim_player = _find_anim_player(_mesh) if _skeleton: _sim = MatadorRagdoll.build(_skeleton) - # Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild as non-skinned - # bone-attached pieces. Runs after the ragdoll sim is built (both drive the same bones). - if OS.has_feature("web"): + # Native GPU skinning by default; rigid_skin is an opt-in fallback now (the Mali/ANGLE + # invisible-skinning bug is fixed in the 4.7 web templates). See Controls.rigid_skin_enabled(). + # When on, it runs after the ragdoll sim is built (both drive the same bones). + if Controls.rigid_skin_enabled(): RigidSkin.convert_tree(self) if _anim_player: 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() _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: _death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.play() diff --git a/mobile_hud.gd b/mobile_hud.gd new file mode 100644 index 0000000..92fd0ec --- /dev/null +++ b/mobile_hud.gd @@ -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 diff --git a/mobile_hud.gd.uid b/mobile_hud.gd.uid new file mode 100644 index 0000000..2d43bd3 --- /dev/null +++ b/mobile_hud.gd.uid @@ -0,0 +1 @@ +uid://p8cqggisskor diff --git a/player.gd b/player.gd index 5f13b49..db05e2a 100644 --- a/player.gd +++ b/player.gd @@ -28,6 +28,11 @@ var _was_on_floor: bool = false var _pre_slide_vel_y: float = 0.0 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 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: add_to_group(&"player") - # Mali/ANGLE mobile GPUs can't run Godot's Compatibility vertex-skinning (transform - # feedback) — skinned meshes render invisible on the web build. Rebuild the bull as - # non-skinned bone-attached pieces there; desktop keeps smooth GPU skinning. - if OS.has_feature("web"): + # Native GPU skinning by default (the old Mali/ANGLE invisible-skinning bug is fixed in the + # 4.7 web templates). rigid_skin is now an opt-in fallback — see Controls.rigid_skin_enabled(). + if Controls.rigid_skin_enabled(): RigidSkin.convert_tree(self) _max_hp = maxi(1, int(DP.f("bull_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; # 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: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - return mat + if _unshaded_mat == null: + _unshaded_mat = StandardMaterial3D.new() + _unshaded_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _unshaded_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _unshaded_mat.vertex_color_use_as_albedo = true + _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: + 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() sphere.radius = radius sphere.height = radius * 2.0 sphere.radial_segments = segments sphere.rings = rings sphere.material = mat + _particle_sphere_cache[key] = 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. if cause == "thrown" and Settings.gore: _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: _huff_player.pitch_scale = randf_range(0.7, 0.9) _huff_player.play() diff --git a/project.godot b/project.godot index 2134610..3abc4b7 100644 --- a/project.godot +++ b/project.godot @@ -28,6 +28,8 @@ DebugDraw="*res://debug_draw.gd" Settings="*res://settings.gd" Run="*res://levels/run_state.gd" OrientationGuard="*res://orientation_guard.gd" +WebStartGate="*res://web_start_gate.gd" +Gore="*res://gore.gd" [display] diff --git a/rigid_skin.gd b/rigid_skin.gd index 90a9302..ef4578a 100644 --- a/rigid_skin.gd +++ b/rigid_skin.gd @@ -43,9 +43,11 @@ static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool: bind_bone.append(bone) bind_pose.append(skin.get_bind_pose(b)) - # Accumulate SurfaceTool geometry per destination bone. - var st_by_bone: Dictionary = {} # bone index -> SurfaceTool - var mat_by_bone: Dictionary = {} # bone index -> Material (first seen) + # Accumulate SurfaceTool geometry per (destination bone, source surface). Keying on the + # surface too — not just the bone — is what preserves materials: a head bone that carries + # 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(): 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 weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS] 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 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 bone := bind_bone[bind] var pose := bind_pose[bind] - var st: SurfaceTool = st_by_bone.get(bone) - if st == null: - st = SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) - st_by_bone[bone] = st - mat_by_bone[bone] = mat + var key := "%d:%d" % [bone, s] + var group: Dictionary = groups.get(key, {}) + if group.is_empty(): + var new_st := SurfaceTool.new() + new_st.begin(Mesh.PRIMITIVE_TRIANGLES) + group = {"st": new_st, "mat": mat, "bone": bone} + groups[key] = group + var st: SurfaceTool = group["st"] for v: int in [a, b, c]: if cols.size() > 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.add_vertex(pose * verts[v]) - if st_by_bone.is_empty(): + if groups.is_empty(): 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() att.bone_name = skel.get_bone_name(bone) skel.add_child(att) var piece := MeshInstance3D.new() - var st: SurfaceTool = st_by_bone[bone] - var m := st.commit() - if mat_by_bone[bone] != null: - m.surface_set_material(0, mat_by_bone[bone]) - piece.mesh = m + var combined := ArrayMesh.new() + for group: Dictionary in by_bone[bone]: + var st: SurfaceTool = group["st"] + st.commit(combined) + if group["mat"] != null: + combined.surface_set_material(combined.get_surface_count() - 1, group["mat"]) + piece.mesh = combined att.add_child(piece) # Hide (don't free) the original: keeps the node for any gameplay code that references diff --git a/run_tests.sh b/run_tests.sh index 65a073c..7c6d409 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -22,10 +22,26 @@ echo "" echo "=== Level switch tests (no cross-level geometry/collider leaks) ===" "$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 "=== Performance tests (frame-budget regression guard) ===" "$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 echo "" echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ===" diff --git a/tests/blood_decals_test.gd b/tests/blood_decals_test.gd new file mode 100644 index 0000000..330a1f2 --- /dev/null +++ b/tests/blood_decals_test.gd @@ -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) diff --git a/tests/blood_decals_test.gd.uid b/tests/blood_decals_test.gd.uid new file mode 100644 index 0000000..f25c170 --- /dev/null +++ b/tests/blood_decals_test.gd.uid @@ -0,0 +1 @@ +uid://b8em8poj26a57 diff --git a/tests/level_switch_test.gd b/tests/level_switch_test.gd index 3d70d62..e09c7a8 100644 --- a/tests/level_switch_test.gd +++ b/tests/level_switch_test.gd @@ -53,7 +53,14 @@ func _run() -> void: # Shell survives and hosts exactly one level module. var level_root: Node = scene.get_node_or_null("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") # The leak we are guarding against: no arena geometry may exist in the bear level. diff --git a/tests/ragdoll_perf_test.gd b/tests/ragdoll_perf_test.gd new file mode 100644 index 0000000..0c40d27 --- /dev/null +++ b/tests/ragdoll_perf_test.gd @@ -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) diff --git a/tests/ragdoll_perf_test.gd.uid b/tests/ragdoll_perf_test.gd.uid new file mode 100644 index 0000000..6202d4a --- /dev/null +++ b/tests/ragdoll_perf_test.gd.uid @@ -0,0 +1 @@ +uid://c8jvxwwmg7cp1 diff --git a/tests/rigid_skin_test.gd b/tests/rigid_skin_test.gd new file mode 100644 index 0000000..ec2dcde --- /dev/null +++ b/tests/rigid_skin_test.gd @@ -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) diff --git a/tests/rigid_skin_test.gd.uid b/tests/rigid_skin_test.gd.uid new file mode 100644 index 0000000..f950052 --- /dev/null +++ b/tests/rigid_skin_test.gd.uid @@ -0,0 +1 @@ +uid://qmf1j0ytjag5 diff --git a/tests/touch_controls_test.gd b/tests/touch_controls_test.gd new file mode 100644 index 0000000..79c9307 --- /dev/null +++ b/tests/touch_controls_test.gd @@ -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) diff --git a/tests/touch_controls_test.gd.uid b/tests/touch_controls_test.gd.uid new file mode 100644 index 0000000..d70cff6 --- /dev/null +++ b/tests/touch_controls_test.gd.uid @@ -0,0 +1 @@ +uid://behtmgyi2ktm0 diff --git a/touch_controls.gd b/touch_controls.gd index 5b1621d..fb2d1f9 100644 --- a/touch_controls.gd +++ b/touch_controls.gd @@ -54,8 +54,13 @@ var _ability_style: Array[StyleBoxFlat] = [] var _menu_style: StyleBoxFlat # Layout rects in screen space — the single source of truth for drawing AND hit-testing. +# Ability buttons are hit-tested against a rect grown by _ability_hit_pad (fills the gaps +# between them) so a near-miss still fires — the visual box stays the smaller drawn rect. +# _slot_pressed drives a brief press highlight so a tap reads as registered. var _stick_zone: Rect2 = Rect2() var _ability_rects: Array[Rect2] = [] +var _ability_hit_pad: float = 0.0 +var _slot_pressed: Array[bool] = [false, false, false, false] var _menu_rect: Rect2 = Rect2() # Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a @@ -65,8 +70,12 @@ var _stick_hint_center: Vector2 = Vector2() var _hint_pulse: float = 0.0 # Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is -# the current thumb position; both in screen coordinates. +# the current thumb position; both in screen coordinates. _stick_index is the finger driving +# the stick (-1 = none): keying release/drag off it (not just the owner dict) lets the latest +# left-zone touch always re-acquire the stick, so a stranded stick — e.g. a touchend lost to a +# resize — self-heals the moment the player touches down again. var _stick_active: bool = false +var _stick_index: int = -1 var _origin: Vector2 = Vector2.ZERO var _knob: Vector2 = Vector2.ZERO var _stick_radius: float = 110.0 @@ -88,6 +97,21 @@ var _pinch_dist: float = 0.0 var _dbg_events: int = 0 var _dbg_last_pos: Vector2 = Vector2.ZERO +# Profiler peak-hold (debug overlay). Godot's game logic — every _process / _physics_process — +# runs on ONE thread on web regardless of thread_support, so a lag spike shows up as a jump in +# process(script) or physics(ragdoll) ms, NOT in FPS alone. We hold the worst value seen in the +# last _PEAK_HOLD_MS so an ability-press spike stays readable instead of flashing by in a frame. +# JS-backed fields (fullscreen status, cross-origin isolation) are cached so the overlay's own +# per-frame JavaScriptBridge.eval doesn't inflate the very process time it's trying to measure. +const _PEAK_HOLD_MS: float = 2000.0 +var _peak_proc_ms: float = 0.0 +var _peak_phys_ms: float = 0.0 +var _peak_proc_t: int = 0 +var _peak_phys_t: int = 0 +var _iso_cached: int = -1 +var _fs_cache: String = "?" +var _fs_cache_t: int = 0 + # Cached real GPU name. RenderingServer.get_video_adapter_name() returns the browser's # privacy-masked "WebKit WebGL" on the web; the true chip (Adreno/Mali/…) — which decides # whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via @@ -143,14 +167,19 @@ func _ready() -> void: # the ability cluster and menu button sit in the right half so they never overlap it. func _relayout() -> void: + # A resize / orientation flip invalidates every rect and can swallow an in-flight touchend + # (the browser drops it mid-transition), which is exactly what used to strand the joystick. + # Clearing touch state here means the layout always comes back to a clean slate. + _reset_touch_state() var vp := get_viewport().get_visible_rect().size var short := minf(vp.x, vp.y) _stick_radius = clampf(short * 0.16, 80.0, 150.0) _stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y)) - var bs := clampf(short * 0.16, 72.0, 120.0) # ability button edge - var gap := bs * 0.18 - var margin := bs * 0.28 + var bs := clampf(short * 0.22, 108.0, 152.0) # ability button edge — bigger, easier thumb target + var gap := bs * 0.22 + var margin := bs * 0.34 + _ability_hit_pad = gap * 0.5 # grow hit rects to meet in the gaps; near-misses still register var right := vp.x - margin var bottom := vp.y - margin var col0 := right - bs * 2.0 - gap @@ -164,8 +193,9 @@ func _relayout() -> void: Rect2(col1, row1, bs, bs), # roll ] - var ms := clampf(short * 0.09, 48.0, 72.0) - _menu_rect = Rect2(vp.x - ms - 16.0, 16.0, ms, ms) + var ms := clampf(short * 0.11, 60.0, 84.0) # bigger menu button + var mm := ms * 0.34 # and pulled further off the corner + _menu_rect = Rect2(vp.x - ms - mm, mm, ms, ms) _stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74) _surface.queue_redraw() @@ -230,14 +260,17 @@ func _on_press(index: int, pos: Vector2) -> void: _touch_owner[index] = "menu" return for slot: int in _ability_rects.size(): - if _ability_rects[slot].has_point(pos): + if _ability_rects[slot].grow(_ability_hit_pad).has_point(pos): _touch_owner[index] = slot + _slot_pressed[slot] = true if _player != null: _player.call(&"try_activate_ability", slot) return - if _stick_zone.has_point(pos) and not _stick_active: + if _stick_zone.has_point(pos): + # Last touch in the zone (re)acquires the stick — this is what recovers a stranded stick + # whose finger's release was never delivered. A prior owner is superseded via _stick_index. _touch_owner[index] = "stick" - _begin_stick(pos) + _begin_stick(index, pos) return _touch_owner[index] = "camera" @@ -245,28 +278,36 @@ func _on_press(index: int, pos: Vector2) -> void: func _on_drag(index: int, pos: Vector2) -> void: match _touch_owner.get(index, null): "stick": - _update_stick(pos) + if index == _stick_index: + _update_stick(pos) "camera": if _pinching: _apply_pinch() func _on_release(index: int, pos: Vector2) -> void: - match _touch_owner.get(index, null): + var owner: Variant = _touch_owner.get(index, null) + match owner: "stick": - _end_stick() + # Only the finger that currently owns the stick ends it; a superseded finger + # (another thumb took over) just drops its own mapping below. + if index == _stick_index: + _end_stick() "menu": if _menu_rect.has_point(pos): _touch_owner.erase(index) _return_to_menu() return + if owner is int: + _slot_pressed[owner] = false _touch_owner.erase(index) # ── Joystick ──────────────────────────────────────────────────────────────── -func _begin_stick(pos: Vector2) -> void: +func _begin_stick(index: int, pos: Vector2) -> void: _stick_active = true + _stick_index = index _origin = pos _knob = pos @@ -283,6 +324,7 @@ func _update_stick(pos: Vector2) -> void: func _end_stick() -> void: _stick_active = false + _stick_index = -1 _release_move() @@ -345,7 +387,11 @@ func _draw_surface() -> void: for slot: int in _ability_rects.size(): var r: Rect2 = _ability_rects[slot] _surface.draw_style_box(_ability_style[slot], r) - var inner := r.grow(-r.size.x * 0.14) + if _slot_pressed[slot]: + var glow := _ABILITY_ACCENT[slot] + glow.a = 0.28 + _surface.draw_rect(r.grow(-r.size.x * 0.10), glow) # press flash so a tap reads as registered + var inner := r.grow(-r.size.x * 0.06) # small inset so the icon nearly fills the button if _ability_tex[slot] != null: _draw_icon_fit(_ability_tex[slot], inner) else: @@ -424,49 +470,105 @@ func _draw_hamburger(r: Rect2) -> void: _surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col) +# Live on-device profiler. Reads Godot's Performance monitors so we can see WHICH stage is slow +# during a lag spike instead of guessing: process(script) is main-thread GDScript, physics is the +# ragdoll/Jolt step, and low-both-but-low-FPS means the GPU (fill rate / resolution) is the wall. +# The verdict line names the bottleneck. Enable on the phone with ?debug=1 in the page URL. func _draw_debug() -> void: var font := ThemeDB.fallback_font if font == null: return - var players := get_tree().get_nodes_in_group(&"player") + + var fps: float = Performance.get_monitor(Performance.TIME_FPS) + var proc_ms: float = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0 + var phys_ms: float = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0 + var draws: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) + var objs: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)) + var prims: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)) + var node_n: int = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)) + var bodies: int = int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)) + var vmem: float = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0 + + var now := Time.get_ticks_msec() + if proc_ms >= _peak_proc_ms or now - _peak_proc_t > _PEAK_HOLD_MS: + _peak_proc_ms = proc_ms + _peak_proc_t = now + if phys_ms >= _peak_phys_ms or now - _peak_phys_t > _PEAK_HOLD_MS: + _peak_phys_ms = phys_ms + _peak_phys_t = now + var mats := get_tree().get_nodes_in_group(&"matador") + var rt := get_viewport().get_texture() + var res: Vector2i = rt.get_size() if rt != null else Vector2i.ZERO + var lines := [ - "GPU: %s" % RenderingServer.get_video_adapter_name(), - "chip: %s" % _unmasked_gpu(), - "API: %s" % RenderingServer.get_video_adapter_api_version(), - "method=%s web=%s touch_ui=%s" % [ - ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"), - OS.has_feature("web"), Controls.use_touch_ui()], - "bull: %s" % _node_report(players), - "matador(%d): %s" % [mats.size(), _node_report(mats)], - "events=%d last=%s touches=%d stick=%s" % [ - _dbg_events, str(_dbg_last_pos.round()), _touches.size(), _stick_active], + "FPS %d frame %.1fms >> %s" % [ + int(round(fps)), 1000.0 / maxf(fps, 1.0), _perf_verdict(fps, proc_ms, phys_ms)], + "process(script) %5.1fms peak %.1f" % [proc_ms, _peak_proc_ms], + "physics(ragdoll) %5.1fms peak %.1f bodies %d" % [phys_ms, _peak_phys_ms, bodies], + "draws %d objs %d prims %s" % [draws, objs, _kfmt(prims)], + "nodes %d matadors %d vmem %.0fMB" % [node_n, mats.size(), vmem], + "render %dx%d threads(iso)=%s" % [res.x, res.y, _iso_str()], + "chip: %s fs: %s" % [_unmasked_gpu(), _fs_status_throttled()], ] - var fs := 18 + + var fsz := 18 var line_h := 24.0 var w := 0.0 for line: String in lines: - w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x) + w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz).x) + var top := clampf(get_viewport().get_visible_rect().size.y * 0.28, 96.0, 220.0) _surface.draw_rect( - Rect2(10.0, 34.0, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.62)) - var y := 34.0 + 22.0 + Rect2(10.0, top, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.72)) + var y := top + 22.0 for line: String in lines: _surface.draw_string( - font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, - Color(1.0, 1.0, 0.3)) + font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz, Color(1.0, 1.0, 0.35)) y += line_h -# One-line "does this character exist / is it drawable / where is it" report for the debug -# overlay, so an invisible bull/matador on-device can be pinned to spawn vs. position vs. -# GPU-draw failure. -func _node_report(nodes: Array) -> String: - if nodes.is_empty(): - return "NONE" - var n := nodes[0] as Node3D - if n == null: - return "not Node3D" - return "vis=%s pos=%s" % [n.is_visible_in_tree(), str(n.global_position.round())] +# Names the current bottleneck from the frame breakdown. Directly tests the "CPU bound to one +# thread" hypothesis: if process or physics ms is high, that's the single main thread saturated +# and threads can't help it; if both are low while FPS is low, the GPU (fill rate) is the wall. +func _perf_verdict(fps: float, proc_ms: float, phys_ms: float) -> String: + if fps >= 55.0: + return "smooth" + if phys_ms >= 6.0 and phys_ms >= proc_ms: + return "PHYSICS-bound (ragdolls, 1 thread)" + if proc_ms >= 6.0: + return "SCRIPT-bound (GDScript, 1 thread)" + if proc_ms + phys_ms < 5.0: + return "GPU/fill-bound (resolution/shaders)" + return "mixed CPU" + + +func _kfmt(n: int) -> String: + if n >= 1000000: + return "%.1fM" % (n / 1000000.0) + if n >= 1000: + return "%.0fk" % (n / 1000.0) + return str(n) + + +# crossOriginIsolated: whether SharedArrayBuffer/threads are even permitted (needs COOP+COEP). Even +# when true, Godot's per-frame game logic stays single-threaded — threads only aid render/audio/load. +func _iso_str() -> String: + if not OS.has_feature("web"): + return "native" + if _iso_cached == -1: + var r: Variant = JavaScriptBridge.eval("window.crossOriginIsolated?1:0", true) + _iso_cached = 1 if ((r is bool and r) or ((r is int or r is float) and int(r) != 0)) else 0 + return str(_iso_cached) + + +# fullscreen_status() does a JavaScriptBridge.eval; throttle it so the overlay isn't paying that +# cost (and skewing the process-ms it reports) every single frame. +func _fs_status_throttled() -> String: + var now := Time.get_ticks_msec() + if now - _fs_cache_t > 500 or _fs_cache == "?": + _fs_cache = Controls.fullscreen_status() + _fs_cache_t = now + return _fs_cache func _button_style(accent: Color) -> StyleBoxFlat: @@ -484,6 +586,9 @@ func _button_style(accent: Color) -> StyleBoxFlat: func _return_to_menu() -> void: get_tree().paused = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + # Stay fullscreen across menu ↔ game so tapping Play never lands you windowed; the player + # leaves fullscreen only via the browser's own back / swipe / Esc, and the start gate then + # re-appears so the next tap goes back in. get_tree().change_scene_to_file("res://MainMenu.tscn") @@ -497,16 +602,55 @@ func _process(delta: float) -> void: if not active: if _surface.visible: _surface.visible = false - if _stick_active: - _end_stick() - _touches.clear() - _touch_owner.clear() - _pinching = false + _reset_touch_state() return _surface.visible = true + _prune_touch_state() if _player == null: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): _player = players[0] _surface.queue_redraw() # cooldown overlays animate every frame + + +# Fully drop every trace of in-flight touch input: release held movement, clear ownership, +# end the stick, cancel any pinch. Used on stand-down, on relayout, and on focus/pause loss. +func _reset_touch_state() -> void: + _end_stick() + _release_move() + _touches.clear() + _touch_owner.clear() + for i: int in _slot_pressed.size(): + _slot_pressed[i] = false + _pinching = false + _pinch_dist = 0.0 + + +# Per-frame self-heal: drop ownership entries whose finger is no longer down, and stand the +# stick down if the finger driving it has lifted. Catches a release that slipped through +# without a matching event so state can't quietly rot into a stuck stick. +func _prune_touch_state() -> void: + if not _touch_owner.is_empty(): + var orphans: Array = [] + for idx: int in _touch_owner: + if not _touches.has(idx): + orphans.append(idx) + for idx: int in orphans: + var owner: Variant = _touch_owner[idx] + if owner is int: + _slot_pressed[owner] = false + if _stick_active and idx == _stick_index: + _end_stick() + _touch_owner.erase(idx) + if _stick_active and not _touches.has(_stick_index): + _end_stick() + + +func _notification(what: int) -> void: + if ( + what == NOTIFICATION_APPLICATION_FOCUS_OUT + or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT + or what == NOTIFICATION_APPLICATION_PAUSED + ): + _reset_touch_state() diff --git a/web_start_gate.gd b/web_start_gate.gd new file mode 100644 index 0000000..af0f364 --- /dev/null +++ b/web_start_gate.gd @@ -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 diff --git a/web_start_gate.gd.uid b/web_start_gate.gd.uid new file mode 100644 index 0000000..9b87285 --- /dev/null +++ b/web_start_gate.gd.uid @@ -0,0 +1 @@ +uid://esiec4is2ap5