diff --git a/bear.gd b/bear.gd index 4f00bba..dc380af 100644 --- a/bear.gd +++ b/bear.gd @@ -78,6 +78,9 @@ var _claw_l: CPUParticles3D = null var _claw_r: CPUParticles3D = null +const RigidSkin = preload("res://rigid_skin.gd") + + func _ready() -> void: add_to_group(&"matador") # so every existing bull ability already targets the bear add_to_group(&"bear") @@ -85,6 +88,10 @@ func _ready() -> void: health_changed.emit(_hp, _hp) _skeleton = _find_skeleton(_mesh) _anim_player = _find_anim_player(_mesh) + # 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"): + RigidSkin.convert_tree(self) if _anim_player: _anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"]) _anim_run = _resolve_anim(["RUN_ON4LEGS", "RUN"]) diff --git a/controls_manager.gd b/controls_manager.gd index ac36e3a..89a7e73 100644 --- a/controls_manager.gd +++ b/controls_manager.gd @@ -25,6 +25,36 @@ func _ready() -> void: load_saved() +# ── 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"): + 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() + + +## 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. +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) + + ## Whether to drive the game with the on-screen touch UI (joystick + buttons) ## instead of keyboard hints. Platform detection is cached (it can't change mid-run) ## so the per-frame/per-input callers don't repay the cost — but the DP force flag is diff --git a/matador.gd b/matador.gd index c8d5a69..06cd584 100644 --- a/matador.gd +++ b/matador.gd @@ -93,12 +93,19 @@ const _TURN_FACE: float = 16.0 # face the bull while engaging const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction +const RigidSkin = preload("res://rigid_skin.gd") + + func _ready() -> void: add_to_group(&"matador") _skeleton = _find_skeleton(_mesh) _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"): + RigidSkin.convert_tree(self) if _anim_player: for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS: _ensure_loop(anim) diff --git a/orientation_guard.gd b/orientation_guard.gd new file mode 100644 index 0000000..1b5a412 --- /dev/null +++ b/orientation_guard.gd @@ -0,0 +1,63 @@ +extends CanvasLayer +## Universal "rotate to landscape" prompt (autoload `OrientationGuard`). The cross-device +## half of landscape enforcement: screen.orientation.lock (controls_manager.gd) handles +## Android, but iOS Safari has no lock API, and some browsers only lock in fullscreen — so +## wherever the device ends up portrait, we simply ask the player to turn it. No canvas +## rotation or input remapping (those break touch coordinates), so it works on every phone +## and browser cleanly. Only ever shows on touch-primary devices, only while portrait. + +var _panel: ColorRect + + +func _ready() -> void: + layer = 128 # above HUD, touch UI, everything + process_mode = Node.PROCESS_MODE_ALWAYS + + _panel = ColorRect.new() + _panel.color = Color(0.05, 0.03, 0.02, 0.97) + _panel.set_anchors_preset(Control.PRESET_FULL_RECT) + _panel.mouse_filter = Control.MOUSE_FILTER_STOP # swallow taps to the game behind it + _panel.visible = false + 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", 14) + center.add_child(box) + + var title := Label.new() + title.text = "Rotate your device" + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 44) + title.add_theme_color_override("font_color", Color(1.0, 0.78, 0.22)) + box.add_child(title) + + var sub := Label.new() + sub.text = "Turn it sideways to play in 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) + + get_viewport().size_changed.connect(_update) + _update.call_deferred() + + +func _update() -> void: + _panel.visible = _should_guard() and _is_portrait() + + +func _is_portrait() -> bool: + var s := get_viewport().get_visible_rect().size + return s.y > s.x + + +# Touch-primary devices only — never a desktop browser with a tall window (they can't rotate +# a monitor). Reuses the same conservative detection the touch UI uses. +func _should_guard() -> bool: + return Controls.use_touch_ui() diff --git a/orientation_guard.gd.uid b/orientation_guard.gd.uid new file mode 100644 index 0000000..2b094ec --- /dev/null +++ b/orientation_guard.gd.uid @@ -0,0 +1 @@ +uid://cmh7kd85wi5gx diff --git a/player.gd b/player.gd index d0c0bb4..977cdb9 100644 --- a/player.gd +++ b/player.gd @@ -79,8 +79,16 @@ var _walk_dust_ramp: Gradient = null var _charge_dust_ramp: Gradient = null +const RigidSkin = preload("res://rigid_skin.gd") + + 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"): + RigidSkin.convert_tree(self) _max_hp = maxi(1, int(DP.f("bull_max_hp"))) _hp = _max_hp _cube_scale = cube_guy.scale diff --git a/project.godot b/project.godot index c4dfcb2..d200742 100644 --- a/project.godot +++ b/project.godot @@ -27,10 +27,12 @@ Controls="*res://controls_manager.gd" DebugDraw="*res://debug_draw.gd" Settings="*res://settings.gd" Run="*res://levels/run_state.gd" +OrientationGuard="*res://orientation_guard.gd" [display] window/size/mode=3 +window/handheld/orientation=4 [editor_plugins] diff --git a/rigid_skin.gd b/rigid_skin.gd new file mode 100644 index 0000000..90a9302 --- /dev/null +++ b/rigid_skin.gd @@ -0,0 +1,128 @@ +extends RefCounted +## Converts Skeleton3D-skinned meshes into NON-skinned mesh pieces parented to +## BoneAttachment3D nodes, so rendering never uses GPU vertex-skinning (transform +## feedback). Required on ANGLE/Vulkan/Mali mobile GPUs (e.g. Mali-G720 in mobile +## Chrome/Brave), where Godot's Compatibility skinning path renders skinned meshes +## invisible while non-skinned meshes draw fine. +## +## Each triangle is assigned wholesale to its dominant bone (highest summed weight), +## so segments stay watertight; joints become rigid (no smooth bending), which suits +## the low-poly look. Call RigidSkin.convert_tree(character_root) once after the scene +## is instanced (bones don't need to be posed yet — pieces are baked in bind space). + +static func convert_tree(root: Node) -> int: + var converted := 0 + for skel: Skeleton3D in _find(root, "Skeleton3D", []): + for mi: Node in skel.get_children(): + if mi is MeshInstance3D and (mi as MeshInstance3D).skin != null \ + and (mi as MeshInstance3D).mesh is ArrayMesh: + if _convert(skel, mi as MeshInstance3D): + converted += 1 + return converted + + +static func _find(n: Node, klass: String, acc: Array) -> Array: + if n.is_class(klass): + acc.append(n) + for c: Node in n.get_children(): + _find(c, klass, acc) + return acc + + +static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool: + var mesh := mi.mesh as ArrayMesh + var skin := mi.skin + + # bind index -> (bone index, bind pose = mesh-space -> bone-local) + var bind_bone: PackedInt32Array = [] + var bind_pose: Array[Transform3D] = [] + for b: int in skin.get_bind_count(): + var bone := skin.get_bind_bone(b) + if bone < 0: + bone = skel.find_bone(skin.get_bind_name(b)) + 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) + + for s: int in mesh.get_surface_count(): + var arr := mesh.surface_get_arrays(s) + var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX] + var norms: PackedVector3Array = arr[Mesh.ARRAY_NORMAL] + var uvs: PackedVector2Array = arr[Mesh.ARRAY_TEX_UV] if arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array() + var cols: PackedColorArray = arr[Mesh.ARRAY_COLOR] if arr[Mesh.ARRAY_COLOR] != null else PackedColorArray() + 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) + var infl := 8 if (mesh.surface_get_format(s) & Mesh.ARRAY_FLAG_USE_8_BONE_WEIGHTS) else 4 + + var tri := PackedInt32Array() + if idx != null and idx.size() > 0: + tri = idx + else: + tri.resize(verts.size()) + for i: int in verts.size(): + tri[i] = i + + for t: int in range(0, tri.size(), 3): + var a := tri[t] + var b := tri[t + 1] + var c := tri[t + 2] + 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 + for v: int in [a, b, c]: + if cols.size() > v: + st.set_color(cols[v]) + if uvs.size() > v: + st.set_uv(uvs[v]) + if norms.size() > v: + st.set_normal((pose.basis * norms[v]).normalized()) + st.add_vertex(pose * verts[v]) + + if st_by_bone.is_empty(): + return false + + for bone: int in st_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 + att.add_child(piece) + + # Hide (don't free) the original: keeps the node for any gameplay code that references + # it, and a hidden skinned mesh isn't drawn so it never hits the transform-feedback path. + mi.visible = false + return true + + +static func _dominant_bind(vs: Array, bones: PackedInt32Array, weights: PackedFloat32Array, infl: int) -> int: + var acc: Dictionary = {} + for v: int in vs: + for k: int in infl: + var w := weights[v * infl + k] + if w <= 0.0: + continue + var bind := bones[v * infl + k] + acc[bind] = float(acc.get(bind, 0.0)) + w + var best := 0 + var best_w := -1.0 + for bind: int in acc: + if acc[bind] > best_w: + best_w = acc[bind] + best = bind + return best diff --git a/rigid_skin.gd.uid b/rigid_skin.gd.uid new file mode 100644 index 0000000..3288f7b --- /dev/null +++ b/rigid_skin.gd.uid @@ -0,0 +1 @@ +uid://dymd8vcfmtjts diff --git a/tests/mobile_render_repro.gd b/tests/mobile_render_repro.gd new file mode 100644 index 0000000..8f57285 --- /dev/null +++ b/tests/mobile_render_repro.gd @@ -0,0 +1,66 @@ +extends SceneTree +## Repro harness for the "bull + matador invisible on phone" bug. Loads the shell with the +## arena level, waits for spawn, prints vis/pos for the skinned characters and dumps a real +## screenshot so the Compatibility (mobile/web) render path can be compared to Forward+. +## godot --rendering-method gl_compatibility --script tests/mobile_render_repro.gd +## godot --rendering-method forward_plus --script tests/mobile_render_repro.gd + +func _init() -> void: + _run.call_deferred() + + +func _report(nodes: Array, label: String) -> void: + if nodes.is_empty(): + print(" ", label, ": NONE") + return + var n := nodes[0] as Node3D + print(" ", label, ": vis=", n.is_visible_in_tree(), " pos=", str(n.global_position.round())) + # Walk to the skinned MeshInstance3D and report its state + AABB. + _dump_meshes(n, " ") + + +func _dump_meshes(node: Node, indent: String) -> void: + if node is MeshInstance3D: + var mi := node as MeshInstance3D + var aabb := mi.get_aabb() + var lod_count := 0 + var surf := 0 + if mi.mesh != null: + surf = mi.mesh.get_surface_count() + print(indent, "MeshInstance3D '", mi.name, "' vis=", mi.visible, + " skin=", mi.skin != null, " gscale=", str(mi.global_transform.basis.get_scale()), + "\n", indent, " aabb_pos=", aabb.position, " aabb_size=", aabb.size, + " custom_aabb=", mi.custom_aabb.size, " surfaces=", surf) + for c: Node in node.get_children(): + _dump_meshes(c, indent) + + +func _run() -> void: + # Compatibility (OpenGL) has no RenderingDevice; Forward+/Mobile do. This reflects the + # renderer actually chosen by --rendering-method, unlike the static project setting. + var method := "gl_compatibility" if RenderingServer.get_rendering_device() == null else "forward_plus" + print("=== RENDER REPRO method=", method, " gpu=", RenderingServer.get_video_adapter_name(), " ===") + + var run: Node = root.get_node("Run") + run.ensure_run() + run.debug_level_override = run.get_level(&"arena") + + var scene: Node = load("res://scene.tscn").instantiate() + root.add_child(scene) + current_scene = scene + + await create_timer(1.2).timeout + + print("bull + matador state:") + _report(get_nodes_in_group(&"player"), "bull") + _report(get_nodes_in_group(&"matador"), "matador") + + # Force a couple more draw frames, then grab the framebuffer. + for i in 4: + await process_frame + var img := root.get_viewport().get_texture().get_image() + var out := "tests/output/render_%s.png" % method + DirAccess.make_dir_recursive_absolute("res://tests/output") + img.save_png("res://" + out) + print("screenshot -> ", out, " (", img.get_width(), "x", img.get_height(), ")") + quit() diff --git a/tests/mobile_render_repro.gd.uid b/tests/mobile_render_repro.gd.uid new file mode 100644 index 0000000..fe0d6e2 --- /dev/null +++ b/tests/mobile_render_repro.gd.uid @@ -0,0 +1 @@ +uid://dvkw2a6cf7848 diff --git a/tests/web_probe.gd b/tests/web_probe.gd new file mode 100644 index 0000000..e9f51f2 --- /dev/null +++ b/tests/web_probe.gd @@ -0,0 +1,32 @@ +extends Node3D +## Minimal skinned-mesh probe for the web/mobile invisibility repro. Boots straight to a +## single bull (Skeleton3D-skinned) framed by a camera + light, no menus, no autoload deps. +## Set as main_scene, exported to web, and screenshotted in a browser to test whether the +## skinned mesh draws under a given engine build. + +func _ready() -> void: + var bull := (load("res://Assets/bull.fbx") as PackedScene).instantiate() as Node3D + bull.scale = Vector3(100.0, 100.0, 100.0) + add_child(bull) + + var key := DirectionalLight3D.new() + key.rotation_degrees = Vector3(-50.0, -40.0, 0.0) + key.light_energy = 1.5 + add_child(key) + + var we := WorldEnvironment.new() + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.15, 0.35, 0.65) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.6, 0.6, 0.6) + env.ambient_light_energy = 1.0 + we.environment = env + add_child(we) + + var cam := Camera3D.new() + cam.position = Vector3(0.0, 2.0, 6.0) + cam.fov = 55.0 + add_child(cam) + cam.look_at(Vector3(0.0, 1.2, 0.0), Vector3.UP) + cam.current = true diff --git a/tests/web_probe.gd.uid b/tests/web_probe.gd.uid new file mode 100644 index 0000000..01fd3b4 --- /dev/null +++ b/tests/web_probe.gd.uid @@ -0,0 +1 @@ +uid://dhu2og7i2gdfa diff --git a/tests/web_probe.tscn b/tests/web_probe.tscn new file mode 100644 index 0000000..a3c53a9 --- /dev/null +++ b/tests/web_probe.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://cwebprobe0001"] + +[ext_resource type="Script" path="res://tests/web_probe.gd" id="1"] + +[node name="WebProbe" type="Node3D"] +script = ExtResource("1") diff --git a/touch_controls.gd b/touch_controls.gd index a5db584..5b1621d 100644 --- a/touch_controls.gd +++ b/touch_controls.gd @@ -88,6 +88,32 @@ var _pinch_dist: float = 0.0 var _dbg_events: int = 0 var _dbg_last_pos: Vector2 = Vector2.ZERO +# 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 +# the WEBGL_debug_renderer_info extension on Godot's own canvas context. "" = not yet probed. +var _gpu_unmasked: String = "" + + +func _unmasked_gpu() -> String: + if _gpu_unmasked != "": + return _gpu_unmasked + _gpu_unmasked = "n/a" + if OS.has_feature("web"): + var js := """(function(){try{ +var c=document.getElementById('canvas'); +var gl=c?c.getContext('webgl2'):null; +if(!gl){var t=document.createElement('canvas');gl=t.getContext('webgl2')||t.getContext('webgl');} +if(!gl)return 'no-gl'; +var e=gl.getExtension('WEBGL_debug_renderer_info'); +if(!e)return 'masked'; +return String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL)); +}catch(err){return 'err';}})()""" + var r: Variant = JavaScriptBridge.eval(js, true) + if r is String and (r as String) != "": + _gpu_unmasked = r + return _gpu_unmasked + func _ready() -> void: layer = 10 # below the HUD (11) so the game-over screen always draws on top @@ -406,6 +432,7 @@ func _draw_debug() -> void: var mats := get_tree().get_nodes_in_group(&"matador") 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", "?"),