diff --git a/Assets/Arena_sand.png.import b/Assets/Arena_sand.png.import index a2b7bff..7095e11 100644 --- a/Assets/Arena_sand.png.import +++ b/Assets/Arena_sand.png.import @@ -4,8 +4,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://dk6xuwwa0mfpe" path.s3tc="res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex" +path.etc2="res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.etc2.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } generator_parameters={ @@ -15,7 +16,7 @@ generator_parameters={ [deps] source_file="res://Assets/Arena_sand.png" -dest_files=["res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex"] +dest_files=["res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex", "res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.etc2.ctex"] [params] diff --git a/Assets/Arena_wall.png.import b/Assets/Arena_wall.png.import index 501ab82..12baa08 100644 --- a/Assets/Arena_wall.png.import +++ b/Assets/Arena_wall.png.import @@ -4,8 +4,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://b4gkpa7jypcsm" path.s3tc="res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex" +path.etc2="res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.etc2.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } generator_parameters={ @@ -15,7 +16,7 @@ generator_parameters={ [deps] source_file="res://Assets/Arena_wall.png" -dest_files=["res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex"] +dest_files=["res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex", "res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.etc2.ctex"] [params] diff --git a/Assets/Arena_wood.png.import b/Assets/Arena_wood.png.import index 1e0169a..0e1fc01 100644 --- a/Assets/Arena_wood.png.import +++ b/Assets/Arena_wood.png.import @@ -4,8 +4,9 @@ importer="texture" type="CompressedTexture2D" uid="uid://yercpq3y2o0x" path.s3tc="res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex" +path.etc2="res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.etc2.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } generator_parameters={ @@ -15,7 +16,7 @@ generator_parameters={ [deps] source_file="res://Assets/Arena_wood.png" -dest_files=["res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex"] +dest_files=["res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex", "res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.etc2.ctex"] [params] diff --git a/Assets/Matador.glb.import b/Assets/Matador.glb.import index db7378c..9b6dc64 100644 --- a/Assets/Matador.glb.import +++ b/Assets/Matador.glb.import @@ -28,7 +28,7 @@ meshes/generate_lods=true meshes/create_shadow_meshes=true meshes/light_baking=1 meshes/lightmap_texel_size=0.2 -meshes/force_disable_compression=false +meshes/force_disable_compression=true skins/use_named_skins=true animation/import=true animation/fps=30 diff --git a/Assets/bull.fbx.import b/Assets/bull.fbx.import index fe498aa..88b3390 100644 --- a/Assets/bull.fbx.import +++ b/Assets/bull.fbx.import @@ -28,7 +28,7 @@ meshes/generate_lods=true meshes/create_shadow_meshes=true meshes/light_baking=1 meshes/lightmap_texel_size=0.2 -meshes/force_disable_compression=false +meshes/force_disable_compression=true skins/use_named_skins=true animation/import=true animation/fps=30 diff --git a/HDRI/citrus_orchard_road_puresky_4k.hdr.import b/HDRI/citrus_orchard_road_puresky_4k.hdr.import index 23b3860..1c51433 100644 --- a/HDRI/citrus_orchard_road_puresky_4k.hdr.import +++ b/HDRI/citrus_orchard_road_puresky_4k.hdr.import @@ -4,15 +4,16 @@ importer="texture" type="CompressedTexture2D" uid="uid://bxm7c6ldrdiq5" path.bptc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex" +path.astc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.astc.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } [deps] source_file="res://HDRI/citrus_orchard_road_puresky_4k.hdr" -dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex"] +dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex", "res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.astc.ctex"] [params] diff --git a/HDRI/kloppenheim_06_puresky_4k.hdr.import b/HDRI/kloppenheim_06_puresky_4k.hdr.import index b148f23..a72ae51 100644 --- a/HDRI/kloppenheim_06_puresky_4k.hdr.import +++ b/HDRI/kloppenheim_06_puresky_4k.hdr.import @@ -4,15 +4,16 @@ importer="texture" type="CompressedTexture2D" uid="uid://3syhacswepyj" path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex" +path.astc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.astc.ctex" metadata={ -"imported_formats": ["s3tc_bptc"], +"imported_formats": ["s3tc_bptc", "etc2_astc"], "vram_texture": true } [deps] source_file="res://HDRI/kloppenheim_06_puresky_4k.hdr" -dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex"] +dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex", "res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.astc.ctex"] [params] diff --git a/camera_iso.gd b/camera_iso.gd index 34c299c..f00ca3f 100644 --- a/camera_iso.gd +++ b/camera_iso.gd @@ -42,6 +42,15 @@ func _unhandled_input(event: InputEvent) -> void: _user_fov = clamp(_user_fov * 1.1, fov_min, fov_max) +# Pinch-to-zoom hook for the touch UI. `ratio` is the two-finger spread this frame over +# last frame: >1 (fingers parting) narrows the FOV to zoom in, <1 widens it to pull back, +# mirroring the scroll-wheel zoom_in/zoom_out feel. +func adjust_zoom(ratio: float) -> void: + if ratio <= 0.0: + return + _user_fov = clampf(_user_fov / ratio, fov_min, fov_max) + + func _process(delta: float) -> void: fov = lerpf(fov, _user_fov, delta * 8.0) diff --git a/controls_manager.gd b/controls_manager.gd index c3ebd80..ac36e3a 100644 --- a/controls_manager.gd +++ b/controls_manager.gd @@ -26,18 +26,65 @@ func _ready() -> void: ## Whether to drive the game with the on-screen touch UI (joystick + buttons) -## instead of keyboard hints. DisplayServer.is_touchscreen_available() can't be -## used here: `emulate_touch_from_mouse` (on for desktop testing) makes it report -## true on every desktop. Gate on the actual runtime platform instead — real -## mobile/native and mobile web browsers — plus the DP force flag for testing. +## 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 +## re-read every call so the console can toggle the layout live on a desktop build. +var _touch_ui_cached: int = -1 # -1 = not yet detected, 0 = false, 1 = true + + func use_touch_ui() -> bool: if DP.b("force_touch_controls"): return true - return ( + if _touch_ui_cached == -1: + _touch_ui_cached = 1 if _detect_touch_ui() else 0 + return _touch_ui_cached == 1 + + +## True only on touch-primary devices (phones, tablets). Detection is deliberately +## conservative so a desktop never gets the on-screen thumb UI. +## +## DisplayServer.is_touchscreen_available() is unusable here on two fronts: on native +## desktop `emulate_touch_from_mouse` (on for testing) forces it true, and on the web build +## it maps to `'ontouchstart' in window`, which desktop Chromium reports true even with no +## touchscreen. So on web we ask the browser two questions instead: does it report touch +## points, AND is the *primary* pointer coarse? A desktop-with-mouse (even a touch laptop) +## answers no to the second and keeps the desktop HUD; a phone/tablet answers yes to both. +## maxTouchPoints also catches iPadOS, which masquerades as desktop Safari. +func _detect_touch_ui() -> bool: + if ( OS.has_feature("mobile") or OS.has_feature("web_android") or OS.has_feature("web_ios") - ) + ): + return true + if not OS.has_feature("web"): + return false + var js := "(navigator.maxTouchPoints > 0) && window.matchMedia('(pointer: coarse)').matches" + var touch_primary: Variant = JavaScriptBridge.eval(js, true) + if touch_primary is bool: + return touch_primary + if touch_primary is int or touch_primary is float: + return int(touch_primary) != 0 + return false + + +## Whether on-screen debug overlays (touch state + render diagnostics) should show. True on +## the DP "touch_debug" flag, or — so it can be switched on from a phone where no console is +## reachable — when the page URL carries a `debug` query param (append `?debug=1`). +var _url_debug: int = -1 + + +func debug_overlay() -> bool: + if DP.b("touch_debug"): + return true + if _url_debug == -1: + _url_debug = 0 + if OS.has_feature("web"): + var has: Variant = JavaScriptBridge.eval( + "String(window.location.search).indexOf('debug') >= 0", true) + if (has is bool and has) or ((has is int or has is float) and int(has) != 0): + _url_debug = 1 + return _url_debug == 1 ## Returns the first keyboard event bound to an action, or null. diff --git a/debug_params.gd b/debug_params.gd index b1017ac..fb95e54 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -299,6 +299,9 @@ func _register_all() -> void: # Force the on-screen touch controls (joystick + ability buttons) on even on a # desktop build — otherwise they appear only when a touchscreen is detected. _reg_b("Touch", "force_touch_controls", false) + # 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) func _reg_f(section: String, key: String, default: float, diff --git a/export_presets.cfg b/export_presets.cfg index afd4268..b29dca2 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -21,7 +21,7 @@ custom_template/release="" variant/extensions_support=false variant/thread_support=true vram_texture_compression/for_desktop=true -vram_texture_compression/for_mobile=false +vram_texture_compression/for_mobile=true html/export_icon=true html/custom_html_shell="" html/head_include="" diff --git a/hud.gd b/hud.gd index b28d67d..c424b7d 100644 --- a/hud.gd +++ b/hud.gd @@ -119,7 +119,11 @@ func _build_bottom_hud() -> void: # Bull-head plate that sits behind the ability + health cluster. It's a sibling of the # cluster (not a parent) so it never constrains the cluster's layout; we size and place # it against the cluster's rect in _position_bull_bg. +# Skipped on touch/phone: the on-screen thumb cluster already crowds the bottom edge, so +# the wide horned plate just adds clutter there. func _build_bull_hud_bg() -> void: + if Controls.use_touch_ui(): + return _bull_bg = TextureRect.new() if ResourceLoader.exists(_BULL_BG): _bull_bg.texture = load(_BULL_BG) @@ -240,7 +244,13 @@ func _toggle_controls() -> void: # joystick and ability buttons instead. Shares Controls.use_touch_ui() with that # overlay so exactly one control scheme shows on any given device. func _update_control_hints() -> void: - if _game_over or Controls.use_touch_ui(): + var touch := Controls.use_touch_ui() + # The desktop tiles carry keyboard keycaps that mean nothing on a phone, and their + # STOP-filtered panels would sit over the joystick zone eating touches — touch_controls + # shows its own ability buttons instead, so drop the desktop bar on touch devices. + if _ability_bar != null: + _ability_bar.visible = not touch + if _game_over or touch: _controls_panel.visible = false _tab_hint.visible = false else: diff --git a/project.godot b/project.godot index d5714f9..8a74e33 100644 --- a/project.godot +++ b/project.godot @@ -117,4 +117,5 @@ pointing/emulate_touch_from_mouse=true rendering_device/driver.windows="d3d12" renderer/rendering_method="gl_compatibility" -anti_aliasing/quality/use_taa=true +textures/vram_compression/import_s3tc_bptc=true +textures/vram_compression/import_etc2_astc=true diff --git a/touch_controls.gd b/touch_controls.gd index 6d0faca..a5db584 100644 --- a/touch_controls.gd +++ b/touch_controls.gd @@ -2,15 +2,23 @@ extends CanvasLayer ## On-screen controls for touch devices: a floating movement joystick on the left ## and a cluster of ability buttons on the right. Instantiated by hud.gd. ## -## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive -## (via Input.action_press with an analog strength), so player.gd needs no touch -## awareness — it just reads Input.get_vector() as always. Ability buttons call the -## player's public try_activate_ability(), which shares the keyboard's cooldown gate. +## All interaction is driven from raw `_input()` with manual hit-testing, NOT from +## Control.gui_input. On the web/wasm build the overlay lives in a CanvasLayer nested +## under the HUD, and GUI touch routing into a nested CanvasLayer renders fine but +## silently drops input — a standard Button here never fires. `_input()` sees every +## InputEventScreenTouch regardless of nesting, mouse_filter, or emulate_mouse_from_touch, +## so the joystick and buttons work reliably across browsers. Everything is drawn in one +## Control (_surface) whose rects are the single source of truth for both drawing and +## hit-testing. +## +## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive (via +## Input.action_press with an analog strength), so player.gd needs no touch awareness — it +## just reads Input.get_vector() as always. Ability buttons call the player's public +## try_activate_ability(), sharing the keyboard's cooldown gate. ## ## Shown only on touch platforms (see Controls.use_touch_ui), or when DP -## "force_touch_controls" is on so the layout can be exercised on a desktop build. -## emulate_touch_from_mouse (project.godot) lets a mouse stand in for a finger while -## testing. +## "force_touch_controls" is on so the layout can be exercised on a desktop build +## (emulate_touch_from_mouse turns a mouse click into a touch event for that). # move_* actions, indexed by the four joystick push directions. const _MOVE_RIGHT: StringName = &"move_right" @@ -33,80 +41,208 @@ const _ABILITY_ACCENT: Array[Color] = [ Color(1.00, 0.82, 0.32), # Roll — gold ] -var _root: Control -var _stick: Control -var _cd_overlays: Array[ColorRect] = [] +# Sentinel touch indices. A real finger uses its own non-negative index; the mouse +# (desktop force-flag testing, when no real touch has been seen) uses _MOUSE. +const _MOUSE: int = -2 +var _surface: Control var _player: Node = null -# Floating-joystick state. _origin is where the thumb first touched (base centre), -# _knob is the current thumb position; both in _stick-local coordinates. _touch_index -# is the finger owning the stick (-1 = none, -2 = mouse), so a second finger tapping -# an ability button never steals the stick. +# Cached textures + styleboxes so the per-frame redraw (cooldowns animate) allocates nothing. +var _ability_tex: Array[Texture2D] = [] +var _ability_style: Array[StyleBoxFlat] = [] +var _menu_style: StyleBoxFlat + +# Layout rects in screen space — the single source of truth for drawing AND hit-testing. +var _stick_zone: Rect2 = Rect2() +var _ability_rects: Array[Rect2] = [] +var _menu_rect: Rect2 = Rect2() + +# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a +# first-time player sees where to plant their thumb instead of guessing. _hint_pulse gives +# it a slow breathing alpha for discoverability. +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. var _stick_active: bool = false var _origin: Vector2 = Vector2.ZERO var _knob: Vector2 = Vector2.ZERO -var _touch_index: int = -3 var _stick_radius: float = 110.0 +# Live fingers by index → screen position, plus what each finger is doing ("stick", a slot +# int, "menu", or "camera"). Ownership keeps a second finger on a button from stealing the +# stick, and keeps steering/ability fingers from being mistaken for a pinch gesture. +var _touches: Dictionary = {} +var _touch_owner: Dictionary = {} +var _saw_real_touch: bool = false + +# Pinch-to-zoom: only "camera" fingers (right side, not on any button) count, so steering +# with one thumb while tapping an ability with the other never reads as a zoom. +var _pinching: bool = false +var _pinch_dist: float = 0.0 + +# Diagnostics surfaced by DP "touch_debug" — lets us see, on the actual device, whether +# events arrive and where, since no console is reachable there. +var _dbg_events: int = 0 +var _dbg_last_pos: Vector2 = Vector2.ZERO + func _ready() -> void: layer = 10 # below the HUD (11) so the game-over screen always draws on top process_mode = Node.PROCESS_MODE_ALWAYS - _root = Control.new() - _root.set_anchors_preset(Control.PRESET_FULL_RECT) - _root.mouse_filter = Control.MOUSE_FILTER_IGNORE - _root.visible = false - add_child(_root) + for slot: int in 4: + var tex: Texture2D = null + if ResourceLoader.exists(_ABILITY_ICONS[slot]): + tex = load(_ABILITY_ICONS[slot]) + _ability_tex.append(tex) + _ability_style.append(_button_style(_ABILITY_ACCENT[slot])) + _menu_style = _button_style(Color(1.00, 0.78, 0.22, 0.8)) + + _surface = Control.new() + _surface.set_anchors_preset(Control.PRESET_FULL_RECT) + _surface.mouse_filter = Control.MOUSE_FILTER_IGNORE + _surface.visible = false + _surface.draw.connect(_draw_surface) + add_child(_surface) - _build_joystick() - _build_ability_buttons() - _build_menu_button() _relayout() get_viewport().size_changed.connect(_relayout) -# ── Joystick ──────────────────────────────────────────────────────────────── -# The stick zone is the whole left half of the screen: touching anywhere in it -# plants the base under the thumb, so the player never has to find a fixed pad. +# ── Layout ──────────────────────────────────────────────────────────────────── +# Left half is the joystick zone (touch anywhere in it to plant the base under the thumb); +# the ability cluster and menu button sit in the right half so they never overlap it. -func _build_joystick() -> void: - _stick = Control.new() - _stick.mouse_filter = Control.MOUSE_FILTER_STOP - _stick.gui_input.connect(_on_stick_input) - _stick.draw.connect(_draw_stick) - _root.add_child(_stick) +func _relayout() -> void: + 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 right := vp.x - margin + var bottom := vp.y - margin + var col0 := right - bs * 2.0 - gap + var col1 := right - bs + var row0 := bottom - bs * 2.0 - gap + var row1 := bottom - bs + _ability_rects = [ + Rect2(col0, row0, bs, bs), # kick + Rect2(col1, row0, bs, bs), # dash + Rect2(col0, row1, bs, bs), # slam + 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) + + _stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74) + _surface.queue_redraw() -func _on_stick_input(event: InputEvent) -> void: +# ── Input ─────────────────────────────────────────────────────────────────── +# Raw events only. Real touches drive the touch path; once any real touch is seen the +# mouse path is disabled (emulate_mouse_from_touch would otherwise double-fire every tap). +# The mouse path stays live for desktop force-flag testing where no touchscreen exists. + +func _input(event: InputEvent) -> void: + if get_tree().paused or not Controls.use_touch_ui(): + return + if event is InputEventScreenTouch: + _saw_real_touch = true var t := event as InputEventScreenTouch - if t.pressed and not _stick_active: - _begin_stick(t.position, t.index) - elif not t.pressed and _touch_index == t.index: - _end_stick() + _dbg_events += 1 + _dbg_last_pos = t.position + if t.pressed: + _touches[t.index] = t.position + _on_press(t.index, t.position) + else: + _on_release(t.index, t.position) + _touches.erase(t.index) + _update_pinch_state() + _surface.queue_redraw() + get_viewport().set_input_as_handled() + elif event is InputEventScreenDrag: var d := event as InputEventScreenDrag - if _touch_index == d.index: - _update_stick(d.position) - elif event is InputEventMouseButton: + _dbg_events += 1 + _dbg_last_pos = d.position + _touches[d.index] = d.position + _on_drag(d.index, d.position) + _surface.queue_redraw() + get_viewport().set_input_as_handled() + + elif not _saw_real_touch and event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.button_index == MOUSE_BUTTON_LEFT: - if mb.pressed and not _stick_active: - _begin_stick(mb.position, -2) - elif not mb.pressed and _touch_index == -2: - _end_stick() - elif event is InputEventMouseMotion and _touch_index == -2: - _update_stick((event as InputEventMouseMotion).position) + _dbg_events += 1 + _dbg_last_pos = mb.position + if mb.pressed: + _touches[_MOUSE] = mb.position + _on_press(_MOUSE, mb.position) + else: + _on_release(_MOUSE, mb.position) + _touches.erase(_MOUSE) + _update_pinch_state() + _surface.queue_redraw() + + elif not _saw_real_touch and event is InputEventMouseMotion: + if _touch_owner.get(_MOUSE, null) == "stick": + _touches[_MOUSE] = (event as InputEventMouseMotion).position + _update_stick(_touches[_MOUSE]) + _surface.queue_redraw() -func _begin_stick(pos: Vector2, index: int) -> void: +func _on_press(index: int, pos: Vector2) -> void: + if _menu_rect.has_point(pos): + _touch_owner[index] = "menu" + return + for slot: int in _ability_rects.size(): + if _ability_rects[slot].has_point(pos): + _touch_owner[index] = slot + if _player != null: + _player.call(&"try_activate_ability", slot) + return + if _stick_zone.has_point(pos) and not _stick_active: + _touch_owner[index] = "stick" + _begin_stick(pos) + return + _touch_owner[index] = "camera" + + +func _on_drag(index: int, pos: Vector2) -> void: + match _touch_owner.get(index, null): + "stick": + _update_stick(pos) + "camera": + if _pinching: + _apply_pinch() + + +func _on_release(index: int, pos: Vector2) -> void: + match _touch_owner.get(index, null): + "stick": + _end_stick() + "menu": + if _menu_rect.has_point(pos): + _touch_owner.erase(index) + _return_to_menu() + return + _touch_owner.erase(index) + + +# ── Joystick ──────────────────────────────────────────────────────────────── + +func _begin_stick(pos: Vector2) -> void: _stick_active = true - _touch_index = index _origin = pos _knob = pos - _stick.queue_redraw() func _update_stick(pos: Vector2) -> void: @@ -117,19 +253,16 @@ func _update_stick(pos: Vector2) -> void: offset = offset.normalized() * _stick_radius _knob = _origin + offset _apply_move(offset / _stick_radius) - _stick.queue_redraw() func _end_stick() -> void: _stick_active = false - _touch_index = -3 _release_move() - _stick.queue_redraw() # Translate a normalised stick offset (screen space: +x right, +y down) into analog -# strengths on the four movement actions. Opposite pairs are mutually exclusive, so -# only one of each axis is ever pressed at a time. +# strengths on the four movement actions. Opposite pairs are mutually exclusive, so only +# one of each axis is ever pressed at a time. func _apply_move(v: Vector2) -> void: Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x)) Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x)) @@ -142,139 +275,184 @@ func _release_move() -> void: Input.action_release(action) -func _draw_stick() -> void: - if not _stick_active: +# ── Pinch-to-zoom ───────────────────────────────────────────────────────────── +# Only "camera" fingers (open right-side area, not on a button) participate, so it can't +# fire while the player is steering or tapping abilities. + +func _camera_touches() -> Array: + var pts: Array = [] + for idx: int in _touches: + if _touch_owner.get(idx, null) == "camera": + pts.append(_touches[idx]) + return pts + + +func _update_pinch_state() -> void: + var pts := _camera_touches() + var was := _pinching + _pinching = pts.size() >= 2 + if _pinching and not was: + if _stick_active: + _end_stick() + _pinch_dist = (pts[0] as Vector2).distance_to(pts[1] as Vector2) + elif was and not _pinching: + _pinch_dist = 0.0 + + +func _apply_pinch() -> void: + var pts := _camera_touches() + if pts.size() < 2: return - var ring := Color(0.93, 0.88, 0.72, 0.30) - var knob := Color(1.00, 0.82, 0.32, 0.55) - _stick.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20)) - _stick.draw_arc(_origin, _stick_radius, 0.0, TAU, 48, ring, 4.0, true) - _stick.draw_circle(_knob, _stick_radius * 0.42, knob) + var dist := (pts[0] as Vector2).distance_to(pts[1] as Vector2) + if _pinch_dist <= 0.0 or dist <= 0.0: + _pinch_dist = dist + return + var cam := get_viewport().get_camera_3d() + if cam != null and cam.has_method(&"adjust_zoom"): + cam.call(&"adjust_zoom", dist / _pinch_dist) + _pinch_dist = dist -# ── Ability buttons ───────────────────────────────────────────────────────── -# A 2×2 cluster in the bottom-right thumb reach. Each button taps the matching -# ability; a dark overlay wipes down over it while that ability is cooling down. +# ── Drawing ─────────────────────────────────────────────────────────────────── -func _build_ability_buttons() -> void: - var grid := GridContainer.new() - grid.name = "AbilityGrid" - grid.columns = 2 - grid.add_theme_constant_override("h_separation", 14) - grid.add_theme_constant_override("v_separation", 14) - grid.mouse_filter = Control.MOUSE_FILTER_IGNORE - grid.anchor_left = 1.0 - grid.anchor_right = 1.0 - grid.anchor_top = 1.0 - grid.anchor_bottom = 1.0 - grid.grow_horizontal = Control.GROW_DIRECTION_BEGIN - grid.grow_vertical = Control.GROW_DIRECTION_BEGIN - grid.offset_left = -220.0 - grid.offset_top = -220.0 - grid.offset_right = -24.0 - grid.offset_bottom = -24.0 - _root.add_child(grid) +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 _ability_tex[slot] != null: + _draw_icon_fit(_ability_tex[slot], inner) + else: + _draw_centered_text(_ABILITY_LETTERS[slot], r, 40, _ABILITY_ACCENT[slot]) + var frac := 0.0 + if _player != null: + frac = clampf(_player.call(&"ability_cooldown_fraction", slot), 0.0, 1.0) + if frac > 0.0: + var ch := r.size.y * frac + _surface.draw_rect( + Rect2(r.position.x, r.position.y + r.size.y - ch, r.size.x, ch), + Color(0.0, 0.0, 0.0, 0.55)) - for slot: int in 4: - grid.add_child(_build_ability_button(slot)) + _surface.draw_style_box(_menu_style, _menu_rect) + _draw_hamburger(_menu_rect) + + if _stick_active: + _surface.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20)) + _surface.draw_arc( + _origin, _stick_radius, 0.0, TAU, 48, Color(0.93, 0.88, 0.72, 0.30), 4.0, true) + _surface.draw_circle(_knob, _stick_radius * 0.42, Color(1.00, 0.82, 0.32, 0.55)) + elif not _pinching: + _draw_stick_hint() + + if Controls.debug_overlay(): + _draw_debug() -func _build_ability_button(slot: int) -> Control: - var panel := PanelContainer.new() - panel.custom_minimum_size = Vector2(96.0, 96.0) - panel.add_theme_stylebox_override("panel", _button_style(slot)) - panel.mouse_filter = Control.MOUSE_FILTER_STOP - - var stack := Control.new() - stack.set_anchors_preset(Control.PRESET_FULL_RECT) - panel.add_child(stack) - - if ResourceLoader.exists(_ABILITY_ICONS[slot]): - var icon := TextureRect.new() - icon.texture = load(_ABILITY_ICONS[slot]) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.set_anchors_preset(Control.PRESET_FULL_RECT) - icon.mouse_filter = Control.MOUSE_FILTER_IGNORE - stack.add_child(icon) - else: - var lbl := Label.new() - lbl.text = _ABILITY_LETTERS[slot] - lbl.set_anchors_preset(Control.PRESET_FULL_RECT) - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE - lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[slot]) - lbl.add_theme_font_size_override("font_size", 40) - stack.add_child(lbl) - - # Cooldown wipe — anchored to the bottom, its top edge driven in _process so it - # shrinks away as the ability comes off cooldown (mirrors the desktop HUD tiles). - var cd := ColorRect.new() - cd.color = Color(0.0, 0.0, 0.0, 0.62) - cd.anchor_left = 0.0 - cd.anchor_right = 1.0 - cd.anchor_top = 1.0 - cd.anchor_bottom = 1.0 - cd.mouse_filter = Control.MOUSE_FILTER_IGNORE - stack.add_child(cd) - _cd_overlays.append(cd) - - # Fire on press (not release) so abilities feel instant under the thumb. - panel.gui_input.connect(_on_ability_input.bind(slot)) - return panel +# A faint "ghost" joystick at rest in the lower-left with a caption, so the movement zone +# is discoverable. Breathes gently via _hint_pulse; vanishes the moment the stick is grabbed. +func _draw_stick_hint() -> void: + var a := 0.16 + 0.12 * (0.5 + 0.5 * sin(_hint_pulse)) + var c := _stick_hint_center + var r := _stick_radius * 0.72 + _surface.draw_circle(c, r, Color(0.0, 0.0, 0.0, a * 0.45)) + _surface.draw_arc(c, r, 0.0, TAU, 40, Color(0.93, 0.88, 0.72, a + 0.14), 3.0, true) + _surface.draw_circle(c, r * 0.32, Color(1.00, 0.82, 0.32, a + 0.16)) + var font := ThemeDB.fallback_font + if font != null: + var txt := "DRAG TO MOVE" + var fs := 18 + var tw := font.get_string_size(txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs) + _surface.draw_string( + font, c + Vector2(-tw.x * 0.5, r + 28.0), txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, + Color(0.93, 0.88, 0.72, a + 0.30)) -func _on_ability_input(event: InputEvent, slot: int) -> void: - var pressed := false - if event is InputEventScreenTouch: - pressed = (event as InputEventScreenTouch).pressed - elif event is InputEventMouseButton: - var mb := event as InputEventMouseButton - pressed = mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT - if pressed and _player != null: - _player.call(&"try_activate_ability", slot) +func _draw_icon_fit(tex: Texture2D, box: Rect2) -> void: + var ts := tex.get_size() + if ts.x <= 0.0 or ts.y <= 0.0: + return + var scale := minf(box.size.x / ts.x, box.size.y / ts.y) + var ds := ts * scale + _surface.draw_texture_rect(tex, Rect2(box.position + (box.size - ds) * 0.5, ds), false) -func _button_style(slot: int) -> StyleBoxFlat: +func _draw_centered_text(text: String, box: Rect2, size: int, color: Color) -> void: + var font := ThemeDB.fallback_font + if font == null: + return + var tw := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, size) + var pos := box.position + (box.size - tw) * 0.5 + Vector2(0.0, tw.y * 0.35) + _surface.draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color) + + +# Three stacked bars — a "☰" glyph drawn by hand, since the fallback font that ships with +# the web build has no U+2630 and renders it as tofu / a codepoint number. +func _draw_hamburger(r: Rect2) -> void: + var col := Color(1.00, 0.78, 0.22) + var bw := r.size.x * 0.5 + var bh := maxf(3.0, r.size.y * 0.09) + var x := r.position.x + (r.size.x - bw) * 0.5 + var cy := r.position.y + r.size.y * 0.5 + var gap := r.size.y * 0.22 + for i: int in [-1, 0, 1]: + _surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col) + + +func _draw_debug() -> void: + var font := ThemeDB.fallback_font + if font == null: + return + var players := get_tree().get_nodes_in_group(&"player") + var mats := get_tree().get_nodes_in_group(&"matador") + var lines := [ + "GPU: %s" % RenderingServer.get_video_adapter_name(), + "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], + ] + var fs := 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) + _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 + 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)) + 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())] + + +func _button_style(accent: Color) -> StyleBoxFlat: var s := StyleBoxFlat.new() s.bg_color = Color(0.10, 0.07, 0.03, 0.80) s.set_corner_radius_all(18) s.corner_detail = 6 s.set_border_width_all(3) - s.border_color = _ABILITY_ACCENT[slot] + s.border_color = accent return s -# ── Menu button ────────────────────────────────────────────────────────────── -# Top-right "☰" — the touch stand-in for the keyboard's Esc-to-menu, so a phone -# player has a way out of a run. - -func _build_menu_button() -> void: - var btn := Button.new() - btn.text = "☰" - btn.custom_minimum_size = Vector2(56.0, 56.0) - btn.focus_mode = Control.FOCUS_NONE - btn.anchor_left = 1.0 - btn.anchor_right = 1.0 - btn.grow_horizontal = Control.GROW_DIRECTION_BEGIN - btn.offset_left = -72.0 - btn.offset_top = 16.0 - btn.offset_right = -16.0 - btn.offset_bottom = 72.0 - var style := StyleBoxFlat.new() - style.bg_color = Color(0.10, 0.07, 0.03, 0.80) - style.set_corner_radius_all(12) - style.set_border_width_all(2) - style.border_color = Color(1.00, 0.78, 0.22, 0.8) - btn.add_theme_stylebox_override("normal", style) - btn.add_theme_stylebox_override("hover", style) - btn.add_theme_stylebox_override("pressed", style) - btn.add_theme_color_override("font_color", Color(1.00, 0.78, 0.22)) - btn.add_theme_font_size_override("font_size", 28) - btn.pressed.connect(_return_to_menu) - _root.add_child(btn) - +# ── Menu ────────────────────────────────────────────────────────────────────── func _return_to_menu() -> void: get_tree().paused = false @@ -282,34 +460,26 @@ func _return_to_menu() -> void: get_tree().change_scene_to_file("res://MainMenu.tscn") -# ── Layout & lifecycle ────────────────────────────────────────────────────── +# ── Lifecycle ───────────────────────────────────────────────────────────────── -func _relayout() -> void: - var vp := get_viewport().get_visible_rect().size - # Left half is the joystick zone; base radius scales with the shorter screen edge. - _stick.position = Vector2.ZERO - _stick.size = Vector2(vp.x * 0.5, vp.y) - _stick_radius = clampf(minf(vp.x, vp.y) * 0.16, 80.0, 150.0) - - -func _process(_delta: float) -> void: - var active := Controls.use_touch_ui() - # The game-over / pause screen owns the display when paused — stand down and drop - # any held movement so the bull doesn't coast on a stuck joystick. - if not active or get_tree().paused: - if _root.visible: - _root.visible = false +func _process(delta: float) -> void: + _hint_pulse = fmod(_hint_pulse + delta * 2.2, TAU) + var active := Controls.use_touch_ui() and not get_tree().paused + # The game-over / pause screen owns the display when paused — stand down and drop any + # held movement so the bull doesn't coast on a stuck joystick. + if not active: + if _surface.visible: + _surface.visible = false if _stick_active: _end_stick() + _touches.clear() + _touch_owner.clear() + _pinching = false return - _root.visible = true + _surface.visible = true if _player == null: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): _player = players[0] - return - - for slot: int in _cd_overlays.size(): - var fraction: float = _player.call(&"ability_cooldown_fraction", slot) - _cd_overlays[slot].anchor_top = 1.0 - clampf(fraction, 0.0, 1.0) + _surface.queue_redraw() # cooldown overlays animate every frame