diff --git a/README.md b/README.md index af29cbc..2b36341 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ detail. ## Authorship and assets -NETfishing is human-directed and uses a mixture of human-authored assets and +NETfishing uses a mixture of original assets and software-development assistance. The project records this plainly rather than claiming that repository history can prove how each line was produced. See [`CREDITS.md`](CREDITS.md) and diff --git a/docs/PORTMASTER-PERFORMANCE.md b/docs/PORTMASTER-PERFORMANCE.md new file mode 100644 index 0000000..8c53f34 --- /dev/null +++ b/docs/PORTMASTER-PERFORMANCE.md @@ -0,0 +1,28 @@ +# PortMaster performance profile + +The PortMaster launcher keeps the normal game profile on unknown and stronger +hardware. It enables the low-end profile only when the Linux device tree +reports one of the identifiers used by the Allwinner H616/H700 XX handhelds: + +- `allwinner,h616` +- `sun50iw9p1` +- `allwinner,sun50i-h700` + +The low-end launch profile passes these options to Godot: + +- `--single-window` +- `--disable-vsync` +- `--max-fps 30` +- `--audio-output-latency 40` + +It also passes `NETFISHING_LOW_END=1`. The game responds by rendering the 3D +world at 75 percent linear resolution with nearest-neighbor scaling. This +reduces 3D pixel work by about 44 percent while the separately rendered UI +retains its canonical resolution. + +Do not use Godot's low processor mode for this profile. It reduces idle CPU +usage by sleeping between updates and is not a game-performance optimization. + +When adding another device family, record its exact NUL-separated device-tree +`compatible` value from the hardware before extending the launcher match. Do +not infer detection from a retail product name alone. diff --git a/docs/PORTMASTER-RELEASE.md b/docs/PORTMASTER-RELEASE.md new file mode 100644 index 0000000..3bfae87 --- /dev/null +++ b/docs/PORTMASTER-RELEASE.md @@ -0,0 +1,137 @@ +# PortMaster release builds + +NETfishing's PortMaster release is a canonical PortMaster package, not a +version-named generic ZIP. The installer-facing archive must be named +`netfishing.zip`, and its root must contain exactly: + +```text +NETfishing.sh +netfishing/ +port.json +``` + +Do not derive a release package from an older local `portmaster-stage` +directory. The templates in `scripts/portmaster/` are authoritative. + +## Release contract + +- `port.json` uses schema version 4 and names `netfishing.zip`. +- `NETfishing.sh` starts with `# PORTMASTER: netfishing.zip, NETfishing.sh`. +- The executable is `netfishing/NETfishing.aarch64`. +- The package declares AArch64, two analog sticks, GLIBC 2.28, and + `weston_pkg_0.2.squashfs`. +- The launcher must not start GPTOKEYB. Godot and NETfishing's controller + mapping manager handle controller input directly. +- Persistent device data remains under `netfishing/conf/data`, + `netfishing/conf/config`, and `netfishing/conf/cache`. +- The archive must not contain `conf/`, saves, identities, logs, source files, + `.git`, or `.godot` content. +- Release downloads must publish `netfishing.zip`. A renamed versioned ZIP is + not a substitute because HarbourMaster identifies the canonical port by + archive name. + +## Build + +Run this only after the release commit and annotated `v` tag +are pinned to the same clean `main` commit: + +```bash +bash scripts/build_portmaster.sh +``` + +The script deletes and regenerates the Linux ARM64 export, stages the package, +validates its structure, and writes: + +```text +builds/v/netfishing.zip +``` + +`--package-only` is reserved for repackaging an already validated ARM64 export. +It does not rebuild game content. + +## Local muOS installation + +The muOS auto-install directory is: + +```text +/mnt/mmc/MUOS/PortMaster/autoinstall/ +``` + +For an explicit offline installation test, copy `netfishing.zip` to a temporary +device directory and run: + +```bash +/mnt/mmc/MUOS/PortMaster/harbourmaster \ + --offline --no-check install ./netfishing.zip +``` + +Before upgrading an existing installation, preserve +`/mnt/mmc/ports/netfishing/conf/` on the device. After installation: + +1. Confirm the installed executable and PCK hashes match the staged release. +2. Confirm the installed launcher contains the canonical PortMaster header and + does not contain `GPTOKEYB`. +3. Confirm `conf/` was not replaced or removed. +4. Launch the installed port through the normal muOS menu. +5. Verify the displayed game version and controller face-button mapping. + +Installing the public PortMaster catalog entry can still install an older +catalog build until the upstream `netfishing.zip` is updated. A local release +test must install the generated local archive explicitly. + +## Template provenance + +The current catalog screenshot is a 640x480 PNG copied +byte-for-byte into the package template: + +```text +f24daae48f543b3a270a5aa46b5e73a31904bf0c7216d4c8a9e15a8dc48a5eed screenshot.png +``` + +The remaining catalog metadata assets were copied from the installed official +`netfishing.zip` package and are intentionally preserved byte-for-byte: + +```text +e2a9d132744684c67865c01eca027a8c9946b4c5a19da57c82f1af0373dc83b7 gameinfo.xml +ff08aacc52bbdc95616320800da3eaee0c0ba5fafdd962bf04f9655859409764 licenses/CREDITS.md +b84fdd2c3da5db56385cdbb639795e90aa3e035c53bc3591135f18df3331451f licenses/Tuffy-LICENSE.txt +``` + +The original catalog porter credit, `Voyager`, remains in `port.json`. + +## muOS H700 controller mapping + +Godot 4.7.1 and the SDL2 utilities shipped by muOS identify the same virtual +controller differently. The PortMaster SDL2 database uses GUID +`19000000010000000100000000010000`, while Godot reports GUID +`19004ca6010000000100000000010000`. Passing the SDL2 entry unchanged leaves +most controls unmapped in Godot. + +The device-verified Godot mapping is: + +- A `b0`, B `b1`, Y `b2`, X `b3` +- left bumper `b4`, right bumper `b5` +- select `b6`, start `b7`, guide `b8` +- left-stick click `b9`, left trigger `b10`, right trigger `b11`, right-stick click `b12` +- D-pad `h0`; left and right sticks `a0..a3` + +`scripts/portmaster/NETfishing.sh` must replace the incompatible SDL2 entry +with the Godot entry when that SDL2 GUID is selected. Do not append the two +entries with a newline: WestonPack evaluates launcher arguments through a +shell and treats the second line as a command. Pass the single selected mapping +as `SDL_GAMECONTROLLERCONFIG` in the game command after Weston initializes; +Weston sources PortMaster's control file internally and otherwise restores the +SDL2 value. + +Do not call `Input.add_joy_mapping(..., true)` for a recognized connected muOS +controller. Updating this virtual controller after connection can stop Godot +from delivering its standardized controller events until restart. + +Before shipping a PortMaster build on H700 hardware, verify all of the +following in the installed game: + +- A, B, X, and Y each register independently. +- The D-pad navigates every menu direction. +- Both sticks, both bumpers, both triggers, Select, Start, L3, and R3 register. +- A single button never cancels auto-map. +- Holding both bumpers together for 1.25 seconds cancels auto-map. diff --git a/main/main.gd b/main/main.gd index 685420f..e62acfb 100644 --- a/main/main.gd +++ b/main/main.gd @@ -380,6 +380,9 @@ func _initialize_after_data_root() -> void: _shop_interaction.local_player_range_changed.connect( _on_shop_range_changed ) + _game_ui.set_shop_npc_player_in_range( + _shop_interaction.is_local_player_in_range() + ) _save_manager.setup( _player.inventory, _player.collection_log, @@ -1099,6 +1102,9 @@ func _unhandled_input(event: InputEvent) -> void: ): _game_ui.set_shop_prompt_visible(false) get_viewport().set_input_as_handled() + elif event is InputEventJoypadButton and _player != null: + _player.hotbar.clear_slot(_player.hotbar.get_selected_slot()) + get_viewport().set_input_as_handled() func _process(_delta: float) -> void: @@ -1119,6 +1125,9 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: return _apply_world_pixelation(settings.world_pixel_size) _ui_pixelation.set_pixel_size(settings.ui_pixel_size) + _ui_pixelation.set_on_screen_keyboard_enabled( + settings.on_screen_keyboard_enabled + ) _game_ui.get_title_screen().set_world_pixelation( settings.world_pixel_size ) @@ -1141,7 +1150,11 @@ func _apply_runtime_settings(settings: PlayerSettingsType) -> void: func _apply_world_pixelation(pixel_size: int) -> void: var root_viewport: Viewport = get_viewport() root_viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_NEAREST - root_viewport.scaling_3d_scale = 1.0 + root_viewport.scaling_3d_scale = ( + 0.75 + if OS.get_environment("NETFISHING_LOW_END") == "1" + else 1.0 + ) root_viewport.msaa_3d = Viewport.MSAA_DISABLED root_viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED root_viewport.use_taa = false @@ -1670,6 +1683,7 @@ func _exit_tree() -> void: func _on_shop_range_changed(in_range: bool) -> void: + _game_ui.set_shop_npc_player_in_range(in_range) if not in_range: _game_ui.get_fishing_shop().close_for_range_exit() _game_ui.set_shop_prompt_visible(_can_show_shop_prompt()) diff --git a/scripts/build_portmaster.sh b/scripts/build_portmaster.sh new file mode 100755 index 0000000..cea49cd --- /dev/null +++ b/scripts/build_portmaster.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +readonly TEMPLATE_ROOT="${SCRIPT_DIR}/portmaster" +readonly GODOT_BIN="${GODOT_BIN:-godot}" + +PACKAGE_ONLY=0 +if [[ "${1:-}" == "--package-only" ]]; then + PACKAGE_ONLY=1 + shift +fi +if [[ $# -ne 0 ]]; then + echo "Usage: $0 [--package-only]" >&2 + exit 2 +fi + +readonly PROJECT_VERSION="$( + sed -n 's/^config\/version="\([^"]*\)"/\1/p' "${PROJECT_ROOT}/project.godot" +)" +if [[ -z "${PROJECT_VERSION}" ]]; then + echo "Could not read application/config/version from project.godot." >&2 + exit 1 +fi + +readonly RELEASE_TAG="v${PROJECT_VERSION}" +readonly RELEASE_ROOT="${PROJECT_ROOT}/builds/${RELEASE_TAG}" +readonly ARM64_ROOT="${RELEASE_ROOT}/linux-arm64" +readonly ARM64_EXECUTABLE="${ARM64_ROOT}/NETfishing.arm64" +readonly ARM64_PCK="${ARM64_ROOT}/NETfishing.pck" +readonly STAGE_ROOT="${RELEASE_ROOT}/portmaster-stage" +readonly GAME_ROOT="${STAGE_ROOT}/netfishing" +readonly ARCHIVE="${RELEASE_ROOT}/netfishing.zip" +readonly LEGACY_ARCHIVE="${RELEASE_ROOT}/netfishing-${RELEASE_TAG}-portmaster-arm64.zip" + +readonly HEAD_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" +readonly TAG_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse "${RELEASE_TAG}^{commit}")" +if [[ "${HEAD_COMMIT}" != "${TAG_COMMIT}" ]]; then + echo "HEAD does not match ${RELEASE_TAG}. Refusing to package mutable source." >&2 + exit 1 +fi +if [[ "${NETFISHING_ALLOW_DIRTY_RELEASE:-0}" != "1" ]]; then + dirty_tree=0 + git -C "${PROJECT_ROOT}" diff --quiet || dirty_tree=1 + git -C "${PROJECT_ROOT}" diff --cached --quiet || dirty_tree=1 + if [[ -n "$(git -C "${PROJECT_ROOT}" status --porcelain --untracked-files=normal)" ]]; then + dirty_tree=1 + fi + if [[ ${dirty_tree} -ne 0 ]]; then + echo "The repository is not clean. Refusing to build a release package." >&2 + exit 1 + fi +fi + +for command_name in git file zip unzip sha256sum; do + command -v "${command_name}" >/dev/null 2>&1 || { + echo "Required command not found: ${command_name}" >&2 + exit 1 + } +done + +mkdir -p -- "${ARM64_ROOT}" "${RELEASE_ROOT}" +if [[ ${PACKAGE_ONLY} -eq 0 ]]; then + rm -f -- "${ARM64_EXECUTABLE}" "${ARM64_PCK}" + "${GODOT_BIN}" \ + --headless \ + --path "${PROJECT_ROOT}" \ + --export-release "Linux ARM64" +fi + +test -s "${ARM64_EXECUTABLE}" +test -s "${ARM64_PCK}" +file "${ARM64_EXECUTABLE}" | grep -q "ARM aarch64" + +rm -rf -- "${STAGE_ROOT}" +rm -f -- "${ARCHIVE}" "${LEGACY_ARCHIVE}" +mkdir -p -- "${GAME_ROOT}/licenses" + +install -m 0755 "${TEMPLATE_ROOT}/NETfishing.sh" "${STAGE_ROOT}/NETfishing.sh" +install -m 0644 "${TEMPLATE_ROOT}/port.json" "${STAGE_ROOT}/port.json" +install -m 0755 "${ARM64_EXECUTABLE}" "${GAME_ROOT}/NETfishing.aarch64" +install -m 0644 "${ARM64_PCK}" "${GAME_ROOT}/NETfishing.pck" +install -m 0644 "${TEMPLATE_ROOT}/gameinfo.xml" "${GAME_ROOT}/gameinfo.xml" +install -m 0644 "${TEMPLATE_ROOT}/screenshot.png" "${GAME_ROOT}/screenshot.png" +install -m 0644 \ + "${TEMPLATE_ROOT}/licenses/CREDITS.md" \ + "${GAME_ROOT}/licenses/CREDITS.md" +install -m 0644 \ + "${TEMPLATE_ROOT}/licenses/Tuffy-LICENSE.txt" \ + "${GAME_ROOT}/licenses/Tuffy-LICENSE.txt" + +cat >"${GAME_ROOT}/BUILD-INFO.txt" <"${GAME_ROOT}/README.md" </dev/null +readonly TOP_LEVELS="$( + unzip -Z1 "${ARCHIVE}" | cut -d/ -f1 | sort -u +)" +readonly EXPECTED_TOP_LEVELS="$(printf '%s\n' NETfishing.sh netfishing port.json)" +if [[ "${TOP_LEVELS}" != "${EXPECTED_TOP_LEVELS}" ]]; then + echo "Unexpected PortMaster archive roots:" >&2 + printf '%s\n' "${TOP_LEVELS}" >&2 + exit 1 +fi +grep -q '"version": 4' "${STAGE_ROOT}/port.json" +grep -q '"name": "netfishing.zip"' "${STAGE_ROOT}/port.json" +grep -q '^# PORTMASTER: netfishing.zip, NETfishing.sh$' \ + "${STAGE_ROOT}/NETfishing.sh" +if grep -q 'GPTOKEYB' "${STAGE_ROOT}/NETfishing.sh"; then + echo "GPTOKEYB must not be enabled for NETfishing." >&2 + exit 1 +fi +if unzip -Z1 "${ARCHIVE}" | grep -E \ + '(^|/)(conf|\.git|\.godot|logs?|saves?|identities?)(/|$)|\.(gd|tscn|blend)$' \ + >/dev/null; then + echo "The PortMaster archive contains prohibited release content." >&2 + exit 1 +fi + +sha256sum "${ARCHIVE}" +echo "PortMaster package created at ${ARCHIVE}" diff --git a/scripts/portmaster/NETfishing.sh b/scripts/portmaster/NETfishing.sh new file mode 100755 index 0000000..58fbfbd --- /dev/null +++ b/scripts/portmaster/NETfishing.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# PORTMASTER: netfishing.zip, NETfishing.sh + +XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share} + +if [ -d "/opt/system/Tools/PortMaster/" ]; then + controlfolder="/opt/system/Tools/PortMaster" +elif [ -d "/opt/tools/PortMaster/" ]; then + controlfolder="/opt/tools/PortMaster" +elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then + controlfolder="$XDG_DATA_HOME/PortMaster" +else + controlfolder="/roms/ports/PortMaster" +fi + +if [ ! -f "$controlfolder/control.txt" ]; then + echo "PortMaster control.txt was not found at $controlfolder" >&2 + exit 1 +fi + +source "$controlfolder/control.txt" +[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt" +get_controls + +GAMEDIR="/${directory}/ports/netfishing" +if [ ! -d "$GAMEDIR" ] && [ -d "/mnt/mmc/ports/netfishing" ]; then + GAMEDIR="/mnt/mmc/ports/netfishing" +fi + +CONFDIR="$GAMEDIR/conf" +GAME_EXECUTABLE="$GAMEDIR/NETfishing.aarch64" +WESTON_DIR="/tmp/netfishing-weston" +WESTON_RUNTIME="weston_pkg_0.2" +HARBOURMASTER="$controlfolder/harbourmaster" +if [ ! -x "$HARBOURMASTER" ] && [ -x "/mnt/mmc/MUOS/PortMaster/harbourmaster" ]; then + HARBOURMASTER="/mnt/mmc/MUOS/PortMaster/harbourmaster" +fi + +mkdir -p "$CONFDIR/data" "$CONFDIR/config" "$CONFDIR/cache" "$WESTON_DIR" +chmod +x "$GAME_EXECUTABLE" + +> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1 + +if [ ! -f "$controlfolder/libs/${WESTON_RUNTIME}.squashfs" ]; then + if [ ! -x "$HARBOURMASTER" ]; then + pm_message "NETfishing requires the latest PortMaster and WestonPack runtime." + sleep 5 + exit 1 + fi + $ESUDO "$HARBOURMASTER" --quiet --no-check runtime_check "${WESTON_RUNTIME}.squashfs" +fi + +if [ ! -f "$controlfolder/libs/${WESTON_RUNTIME}.squashfs" ]; then + pm_message "WestonPack could not be installed. Check the network connection and PortMaster runtime manager." + sleep 5 + exit 1 +fi + +if [[ "$PM_CAN_MOUNT" != "N" ]]; then + $ESUDO umount "$WESTON_DIR" 2>/dev/null +fi +$ESUDO mount "$controlfolder/libs/${WESTON_RUNTIME}.squashfs" "$WESTON_DIR" + +cd "$GAMEDIR" || exit 1 + +# Godot 4.7 identifies the muOS H700 virtual controller with a CRC-bearing +# GUID and numbers its buttons without the volume keys exposed by SDL2. The +# PortMaster SDL2 database entry therefore cannot drive Godot's gamepad layer. +GODOT_MUOS_MAPPING="19004ca6010000000100000000010000,muOS-Keys,a:b0,b:b1,x:b3,y:b2,leftshoulder:b4,rightshoulder:b5,lefttrigger:b10,righttrigger:b11,guide:b8,start:b7,back:b6,dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4,leftx:a0,lefty:a1,leftstick:b9,rightx:a2,righty:a3,rightstick:b12,platform:Linux," +MUOS_SDL2_GUID="19000000010000000100000000010000" +if [[ "$sdl_controllerconfig" == "${MUOS_SDL2_GUID},"* ]]; then + netfishing_controllerconfig="$GODOT_MUOS_MAPPING" +elif [ -n "$sdl_controllerconfig" ]; then + netfishing_controllerconfig="$sdl_controllerconfig" +else + netfishing_controllerconfig="$GODOT_MUOS_MAPPING" +fi + +NETFISHING_GODOT_OPTIONS=() +NETFISHING_GAME_ENVIRONMENT=() +DEVICE_COMPATIBILITY="" +if [[ -r /proc/device-tree/compatible ]]; then + DEVICE_COMPATIBILITY="$(tr '\0' ' ' /dev/null +fi +pm_finish diff --git a/scripts/portmaster/gameinfo.xml b/scripts/portmaster/gameinfo.xml new file mode 100755 index 0000000..ef608bd --- /dev/null +++ b/scripts/portmaster/gameinfo.xml @@ -0,0 +1,13 @@ + + + + ./NETfishing.sh + NETfishing + A relaxed multiplayer fishing game about exploring a small island, catching and collecting fish, customizing your character, and spending time by the water. + 20260804T000000 + NETfishing project + Independent + adventure, simulation + ./netfishing/screenshot.png + + diff --git a/scripts/portmaster/licenses/CREDITS.md b/scripts/portmaster/licenses/CREDITS.md new file mode 100755 index 0000000..8edbd4f --- /dev/null +++ b/scripts/portmaster/licenses/CREDITS.md @@ -0,0 +1,50 @@ +# Credits + +## Project direction + +NETfishing is designed, directed, reviewed, and released by its project +creator. A public-facing creator or studio credit has not yet been recorded in +this repository. + +## Development assistance + +Development has used automated coding assistance for implementation, +investigation, testing, documentation, and review. Design choices, supplied +artwork, acceptance decisions, repository operations, and releases remain +human-controlled. + +This disclosure is intentionally about process. Commit style, code style, or +repository metadata cannot reliably prove whether a particular change was +written with or without assistance. + +## Fonts + +- Tuffy: Thatcher Ulrich, Karoly Barta, and Michael Evans. The bundled license + dedicates the work to the public domain; see `ui/fonts/Tuffy-LICENSE.txt`. +- Seattle Avenue: bundled as `ui/fonts/seattle_avenue.otf`. Author and + redistribution terms still need to be recorded before public distribution. + +## Music + +- `audio/music/title/as_in_four_wolves.ogg`: original title/ambient music by + the project owner. + +## Sound effects + +- “Spinning reel.wav” by tosha73, used as the source for the fishing fight and + manual-reeling loops. Freesound sound 509902, Creative Commons Zero (CC0): + https://freesound.org/s/509902/ +- “Gentle Ocean Waves Loop” by kkenny101, used for the saltwater shoreline + ambience. Freesound sound 852826, Creative Commons Zero (CC0): + https://freesound.org/s/852826/ +- “Quick Water Droplet” by qubodup, used for the bobber water-impact sound. + Freesound sound 792931, Creative Commons Zero (CC0): + https://freesound.org/s/792931/ + +## Artwork + +- 2D artwork: Rheannon Eisworth, contributor. +- 3D models: original work by the project owner. + +Asset categories and release-record requirements are listed in +[`docs/ASSET-PROVENANCE.md`](docs/ASSET-PROVENANCE.md). diff --git a/scripts/portmaster/licenses/Tuffy-LICENSE.txt b/scripts/portmaster/licenses/Tuffy-LICENSE.txt new file mode 100755 index 0000000..defced0 --- /dev/null +++ b/scripts/portmaster/licenses/Tuffy-LICENSE.txt @@ -0,0 +1,11 @@ +We, the copyright holders of this work, hereby release it into the +public domain. This applies worldwide. + +In case this is not legally possible, + +We grant any entity the right to use this work for any purpose, without +any conditions, unless such conditions are required by law. + +Thatcher Ulrich http://tulrich.com +Karoly Barta bartakarcsi@gmail.com +Michael Evans http://www.evertype.com diff --git a/scripts/portmaster/port.json b/scripts/portmaster/port.json new file mode 100644 index 0000000..d14fa8f --- /dev/null +++ b/scripts/portmaster/port.json @@ -0,0 +1,39 @@ +{ + "version": 4, + "name": "netfishing.zip", + "items": [ + "NETfishing.sh", + "netfishing/" + ], + "items_opt": null, + "attr": { + "title": "NETfishing", + "porter": [ + "Voyager", + "asellite" + ], + "desc": "A relaxed multiplayer fishing game about exploring a small island, catching and collecting fish, customizing your character, and spending time by the water.", + "desc_md": null, + "inst": "Ready to run on supported ARM64 PortMaster devices. This alpha package requires two analog sticks.", + "inst_md": null, + "genres": [ + "adventure", + "simulation" + ], + "image": {}, + "rtr": false, + "exp": false, + "runtime": [ + "weston_pkg_0.2.squashfs" + ], + "store": [], + "availability": "full", + "reqs": [ + "analog_2" + ], + "arch": [ + "aarch64" + ], + "min_glibc": "2.28" + } +} diff --git a/scripts/portmaster/screenshot.png b/scripts/portmaster/screenshot.png new file mode 100755 index 0000000..4fe8f2e Binary files /dev/null and b/scripts/portmaster/screenshot.png differ diff --git a/scripts/portmaster/screenshot.png.import b/scripts/portmaster/screenshot.png.import new file mode 100644 index 0000000..3aa5f2d --- /dev/null +++ b/scripts/portmaster/screenshot.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ww0w8iubiwsf" +path="res://.godot/imported/screenshot.png-6eed5589b80ced9ab24aed11cadf34f5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://scripts/portmaster/screenshot.png" +dest_files=["res://.godot/imported/screenshot.png-6eed5589b80ced9ab24aed11cadf34f5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/settings/controller_mapping_manager.gd b/settings/controller_mapping_manager.gd index 600c9b3..d5acd21 100644 --- a/settings/controller_mapping_manager.gd +++ b/settings/controller_mapping_manager.gd @@ -13,19 +13,19 @@ const MAX_PROFILE_BYTES: int = 1024 * 1024 const CAPTURE_AXIS_THRESHOLD: float = 0.55 const CAPTURE_AXIS_RELEASE_THRESHOLD: float = 0.30 const ACTIVE_DEVICE_AXIS_THRESHOLD: float = 0.35 -const MUOS_MAPPING_REVISION: String = "muos-v3" +const MUOS_MAPPING_REVISION: String = "muos-v4" const MUOS_CONTROLLER_NAMES: Array[String] = [ "muOS-Keys", "Deeplay-keys", ] const MUOS_MAPPING_BINDINGS: String = ( - "a:b4,b:b3,x:b5,y:b6," - + "leftshoulder:b7,rightshoulder:b8," - + "lefttrigger:b13,righttrigger:b14," - + "guide:b11,start:b10,back:b9," + "a:b0,b:b1,x:b3,y:b2," + + "leftshoulder:b4,rightshoulder:b5," + + "lefttrigger:b10,righttrigger:b11," + + "guide:b8,start:b7,back:b6," + "dpup:h0.1,dpleft:h0.8,dpright:h0.2,dpdown:h0.4," - + "leftx:a0,lefty:a1,leftstick:b12," - + "rightx:a2,righty:a3,rightstick:b15,platform:Linux," + + "leftx:a0,lefty:a1,leftstick:b9," + + "rightx:a2,righty:a3,rightstick:b12,platform:Linux," ) const ROLE_A: StringName = &"a" const ROLE_B: StringName = &"b" @@ -739,8 +739,8 @@ static func should_install_muos_compatibility_mapping( controller_name: String, is_known: bool, ) -> bool: - # Current SDL databases should remain authoritative. This fallback exists - # only for legacy muOS virtual controllers that SDL cannot identify. + # Never update an already-recognized virtual controller at runtime. Godot + # can stop delivering its standardized input events after that replacement. return is_muos_controller_name(controller_name) and not is_known diff --git a/settings/player_settings.gd b/settings/player_settings.gd index 2a8697b..8979e57 100644 --- a/settings/player_settings.gd +++ b/settings/player_settings.gd @@ -27,6 +27,7 @@ const UI_COMPACT_RENDER_HEIGHTS: Array[int] = [0, 408, 336, 264, 192] @export_range(0.001, 0.012, 0.0005) var mouse_camera_sensitivity: float = 0.005 @export_range(0.5, 5.0, 0.1) var controller_camera_sensitivity: float = 2.5 @export var invert_camera_y: bool = false +@export var on_screen_keyboard_enabled: bool = false @export var chat_draft: String = "" @export var chat_collapsed: bool = false @export var chat_dock_right: bool = false @@ -63,6 +64,7 @@ func copy() -> PlayerSettings: result.mouse_camera_sensitivity = mouse_camera_sensitivity result.controller_camera_sensitivity = controller_camera_sensitivity result.invert_camera_y = invert_camera_y + result.on_screen_keyboard_enabled = on_screen_keyboard_enabled result.chat_draft = chat_draft result.chat_collapsed = chat_collapsed result.chat_dock_right = chat_dock_right diff --git a/settings/player_settings_manager.gd b/settings/player_settings_manager.gd index 7597c82..a983ca3 100644 --- a/settings/player_settings_manager.gd +++ b/settings/player_settings_manager.gd @@ -45,6 +45,10 @@ func load_settings() -> bool: if ( typeof(accessibility.get("auto_click_enabled")) != TYPE_BOOL or typeof(camera.get("invert_vertical")) != TYPE_BOOL + or ( + accessibility.has("on_screen_keyboard_enabled") + and typeof(accessibility["on_screen_keyboard_enabled"]) != TYPE_BOOL + ) or ( accessibility.has("use_readable_interface_font") and typeof(accessibility["use_readable_interface_font"]) != TYPE_BOOL @@ -88,6 +92,9 @@ func load_settings() -> bool: -1.0 ) loaded.invert_camera_y = camera["invert_vertical"] + loaded.on_screen_keyboard_enabled = bool( + accessibility.get("on_screen_keyboard_enabled", false) + ) loaded.world_pixel_size = _read_clamped_integer( presentation.get( "world_pixel_size", @@ -176,6 +183,9 @@ func save_now() -> bool: "auto_click_enabled": current_settings.auto_click_enabled, "auto_click_interval": current_settings.auto_click_interval, "use_readable_interface_font": true, + "on_screen_keyboard_enabled": ( + current_settings.on_screen_keyboard_enabled + ), }, "camera": { "mouse_sensitivity": current_settings.mouse_camera_sensitivity, diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd new file mode 100644 index 0000000..638e0cc --- /dev/null +++ b/tests/controller_focus_presentation_validation.gd @@ -0,0 +1,75 @@ +extends SceneTree + +const FocusPresentationType = preload( + "res://ui/controller_focus_presentation.gd" +) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var stage := Control.new() + stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(stage) + var presentation := FocusPresentationType.new() + stage.add_child(presentation) + var standard_button := Button.new() + standard_button.text = "standard" + standard_button.custom_minimum_size = Vector2(120.0, 60.0) + stage.add_child(standard_button) + var authored_selector := Button.new() + authored_selector.text = "authored selector" + authored_selector.position = Vector2(140.0, 0.0) + authored_selector.custom_minimum_size = Vector2(160.0, 60.0) + authored_selector.set_meta(&"controller_focus_inversion_disabled", true) + stage.add_child(authored_selector) + await process_frame + + var controller_event := InputEventJoypadButton.new() + controller_event.button_index = JOY_BUTTON_A + controller_event.pressed = true + presentation._input(controller_event) + standard_button.grab_focus() + await process_frame + _expect( + standard_button.material != null, + "ordinary controller focus receives inversion", + ) + + authored_selector.grab_focus() + await process_frame + _expect( + standard_button.material == null, + "inversion clears when focus moves", + ) + _expect( + authored_selector.material == null, + "authored selector backgrounds opt out of inversion", + ) + + standard_button.grab_focus() + await process_frame + standard_button.focus_mode = Control.FOCUS_NONE + await process_frame + _expect( + standard_button.material == null, + "inversion clears when a focused control is defocused", + ) + + stage.queue_free() + if _failures.is_empty(): + print("Controller focus presentation validation: PASS") + quit(0) + return + for failure: String in _failures: + push_error(failure) + quit(1) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/controller_focus_presentation_validation.gd.uid b/tests/controller_focus_presentation_validation.gd.uid new file mode 100644 index 0000000..0cacf4a --- /dev/null +++ b/tests/controller_focus_presentation_validation.gd.uid @@ -0,0 +1 @@ +uid://bjay8ki3albk6 diff --git a/tests/controller_focus_recovery_validation.gd b/tests/controller_focus_recovery_validation.gd new file mode 100644 index 0000000..e34afaa --- /dev/null +++ b/tests/controller_focus_recovery_validation.gd @@ -0,0 +1,88 @@ +extends SceneTree + +const ControllerFocusRecoveryType = preload( + "res://ui/controller_focus_recovery.gd" +) + +var _failures: Array[String] = [] + + +func _initialize() -> void: + _run.call_deferred() + + +func _run() -> void: + var stage := Control.new() + stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_child(stage) + var recovery := ControllerFocusRecoveryType.new() + stage.add_child(recovery) + var option_list := VBoxContainer.new() + option_list.position = Vector2(100.0, 100.0) + stage.add_child(option_list) + var original := _make_button("cute", "cute (show variants)") + option_list.add_child(original) + await process_frame + + var controller_event := InputEventJoypadButton.new() + controller_event.button_index = JOY_BUTTON_A + controller_event.pressed = true + recovery._input(controller_event) + original.grab_focus() + await process_frame + _expect(root.gui_get_focus_owner() == original, "original option receives focus") + + option_list.remove_child(original) + original.queue_free() + var replacement := _make_button("cute", "cute (hide variants)") + option_list.add_child(replacement) + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == replacement, + "focus follows a rebuilt semantic option", + ) + + var explicit_target := _make_button("explicit", "explicit") + option_list.add_child(explicit_target) + option_list.remove_child(replacement) + replacement.queue_free() + explicit_target.grab_focus() + await process_frame + _expect( + root.gui_get_focus_owner() == explicit_target, + "explicit focus changes take precedence over recovery", + ) + var leave_world_ui_event := InputEventJoypadButton.new() + leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER + leave_world_ui_event.pressed = true + recovery._input(leave_world_ui_event) + root.gui_release_focus() + await process_frame + await process_frame + _expect( + root.gui_get_focus_owner() == null, + "LB intentionally leaving world UI is never recovered", + ) + + stage.queue_free() + if _failures.is_empty(): + print("Controller focus recovery validation: PASS") + quit(0) + return + for failure: String in _failures: + push_error(failure) + quit(1) + + +func _make_button(text: String, tooltip: String) -> Button: + var button := Button.new() + button.text = text + button.tooltip_text = tooltip + button.custom_minimum_size = Vector2(120.0, 48.0) + return button + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tests/controller_focus_recovery_validation.gd.uid b/tests/controller_focus_recovery_validation.gd.uid new file mode 100644 index 0000000..7bcb1a2 --- /dev/null +++ b/tests/controller_focus_recovery_validation.gd.uid @@ -0,0 +1 @@ +uid://cqk75fxykqrhs diff --git a/tests/controller_mapping_validation.gd b/tests/controller_mapping_validation.gd index bb6ef3b..16c9ad0 100644 --- a/tests/controller_mapping_validation.gd +++ b/tests/controller_mapping_validation.gd @@ -18,6 +18,8 @@ func _run() -> void: root.add_child(manager) await process_frame var failure: String = _validate_manager(manager) + if failure.is_empty(): + failure = _validate_portmaster_launcher() if failure.is_empty(): failure = _validate_auto_map(manager) if failure.is_empty(): @@ -40,19 +42,19 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: ) ) for expected_binding: String in [ - "a:b4", - "b:b3", - "x:b5", - "y:b6", - "leftshoulder:b7", - "rightshoulder:b8", - "lefttrigger:b13", - "righttrigger:b14", - "back:b9", - "start:b10", - "guide:b11", - "leftstick:b12", - "rightstick:b15", + "a:b0", + "b:b1", + "x:b3", + "y:b2", + "leftshoulder:b4", + "rightshoulder:b5", + "lefttrigger:b10", + "righttrigger:b11", + "back:b6", + "start:b7", + "guide:b8", + "leftstick:b9", + "rightstick:b12", "leftx:a0", "righty:a3", ]: @@ -79,7 +81,7 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: "muOS-Keys", true, ): - return "recognized muOS controller would override its SDL mapping" + return "recognized muOS controller would be replaced at runtime" if ControllerMappingManagerType.should_install_muos_compatibility_mapping( "ordinary controller", false, @@ -194,6 +196,30 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String: return "" +func _validate_portmaster_launcher() -> String: + const launcher_path: String = "res://scripts/portmaster/NETfishing.sh" + if not FileAccess.file_exists(launcher_path): + return "PortMaster launcher template is missing" + var launcher: String = FileAccess.get_file_as_string(launcher_path) + for expected_fragment: String in [ + "19004ca6010000000100000000010000", + "19000000010000000100000000010000", + "a:b0,b:b1,x:b3,y:b2", + "leftshoulder:b4,rightshoulder:b5", + "lefttrigger:b10,righttrigger:b11", + "guide:b8,start:b7,back:b6", + "leftstick:b9", + "rightstick:b12", + "netfishing_controllerconfig=\"$GODOT_MUOS_MAPPING\"", + "SDL_GAMECONTROLLERCONFIG=\"$netfishing_controllerconfig\" \\", + ]: + if expected_fragment not in launcher: + return "PortMaster launcher omitted " + expected_fragment + if "$'\\n'" in launcher: + return "PortMaster launcher appends mappings across a Weston-unsafe newline" + return "" + + func _validate_auto_map(manager: ControllerMappingManagerType) -> String: var panel := ControllerMappingPanelType.new() root.add_child(panel) @@ -258,13 +284,35 @@ func _validate_auto_map(manager: ControllerMappingManagerType) -> String: return "manual remapping still dictates a specific physical input" if "any button" not in panel._progress_label.text.to_lower(): return "manual remapping does not request a generic controller input" - var cancel_button := InputEventJoypadButton.new() - cancel_button.device = manager.get_active_device_id() - cancel_button.button_index = JOY_BUTTON_B - cancel_button.pressed = true - manager.controller_input_observed.emit(cancel_button) - if panel.is_capturing(): - return "mapped controller back input did not cancel capture" + panel._cancel_capture() + panel._begin_auto_map() + panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS) + var lone_button := InputEventJoypadButton.new() + lone_button.device = manager.get_active_device_id() + lone_button.button_index = JOY_BUTTON_B + lone_button.pressed = true + manager.controller_input_observed.emit(lone_button) + if not panel._auto_map_active or panel._auto_map_index != 1: + return "a single controller button cancelled auto-map" + panel._cancel_capture() + panel._begin_auto_map() + panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS) + var left_bumper := InputEventJoypadButton.new() + left_bumper.device = manager.get_active_device_id() + left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER + left_bumper.pressed = true + manager.controller_input_observed.emit(left_bumper) + var right_bumper := InputEventJoypadButton.new() + right_bumper.device = manager.get_active_device_id() + right_bumper.button_index = JOY_BUTTON_RIGHT_SHOULDER + right_bumper.pressed = true + manager.controller_input_observed.emit(right_bumper) + panel._process(ControllerMappingPanelType.CANCEL_COMBO_HOLD_SECONDS - 0.01) + if not panel._auto_map_active: + return "bumper combo cancelled auto-map before the hold threshold" + panel._process(0.02) + if panel._auto_map_active or panel.is_capturing(): + return "sustained bumper combo did not cancel auto-map" panel._begin_auto_map() panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS) var held_left := InputEventJoypadMotion.new() diff --git a/tests/controller_ui_navigation_validation.gd b/tests/controller_ui_navigation_validation.gd new file mode 100644 index 0000000..7cecbab --- /dev/null +++ b/tests/controller_ui_navigation_validation.gd @@ -0,0 +1,82 @@ +extends SceneTree + +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) +const UIReferencePresentationType = preload( + "res://ui/ui_reference_presentation.gd" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_spatial_navigation() + _validate_controller_hierarchy_contract() + _validate_four_by_three_centering() + _validate_low_end_profile_contract() + print("Controller UI navigation validation: PASS") + quit() + + +func _validate_spatial_navigation() -> void: + var host := Control.new() + root.add_child(host) + var center := _make_button(host, "center", Vector2(200.0, 200.0)) + var left := _make_button(host, "left", Vector2(50.0, 200.0)) + var right := _make_button(host, "right", Vector2(350.0, 200.0)) + var top := _make_button(host, "top", Vector2(200.0, 50.0)) + var bottom := _make_button(host, "bottom", Vector2(200.0, 350.0)) + var controls: Array[Control] = [center, left, right, top, bottom] + ControllerFocusNavigationType.configure_spatial_neighbors(controls) + assert(center.get_node(center.focus_neighbor_left) == left) + assert(center.get_node(center.focus_neighbor_right) == right) + assert(center.get_node(center.focus_neighbor_top) == top) + assert(center.get_node(center.focus_neighbor_bottom) == bottom) + host.queue_free() + + +func _validate_controller_hierarchy_contract() -> void: + var source: String = FileAccess.get_file_as_string( + "res://ui/player_menu.gd" + ) + assert(source.contains("ROLE_POINTER_MODIFIER")) + assert(source.contains("ROLE_CAMERA_ZOOM")) + assert(source.contains("_handle_controller_secondary_switch")) + assert(source.contains("ROLE_LB")) + assert(source.contains("ROLE_RB")) + assert(source.contains("_reserve_main_navigation_for_page_switching")) + + +func _validate_four_by_three_centering() -> void: + var stage_position: Vector2 = ( + UIReferencePresentationType.get_stage_position(Vector2(640.0, 480.0)) + ) + assert(stage_position.is_equal_approx(Vector2(0.0, 120.0))) + + +func _validate_low_end_profile_contract() -> void: + var launcher: String = FileAccess.get_file_as_string( + "res://scripts/portmaster/NETfishing.sh" + ) + assert(launcher.contains("allwinner,h616")) + assert(launcher.contains("sun50iw9p1")) + assert(launcher.contains("NETFISHING_LOW_END=1")) + assert(launcher.contains("--max-fps 30")) + assert(launcher.contains("--audio-output-latency 40")) + + +func _make_button( + host: Control, + button_name: String, + button_position: Vector2, +) -> Button: + var button := Button.new() + button.name = button_name + button.position = button_position + button.size = Vector2(80.0, 80.0) + button.focus_mode = Control.FOCUS_ALL + host.add_child(button) + return button diff --git a/tests/controller_ui_navigation_validation.gd.uid b/tests/controller_ui_navigation_validation.gd.uid new file mode 100644 index 0000000..73ef752 --- /dev/null +++ b/tests/controller_ui_navigation_validation.gd.uid @@ -0,0 +1 @@ +uid://cbv0ia42rqu41 diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd new file mode 100644 index 0000000..e794cda --- /dev/null +++ b/tests/on_screen_keyboard_validation.gd @@ -0,0 +1,82 @@ +extends SceneTree + +const KeyboardType = preload("res://ui/on_screen_keyboard.gd") +const SettingsManagerType = preload( + "res://settings/player_settings_manager.gd" +) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _validate_default_and_persistence() + await _validate_keyboard_entry() + print("On-screen keyboard validation: PASS") + quit() + + +func _validate_default_and_persistence() -> void: + var defaults := PlayerSettings.new() + assert(not defaults.on_screen_keyboard_enabled) + var manager := SettingsManagerType.new() + root.add_child(manager) + assert(manager.load_settings()) + var edited: PlayerSettings = manager.current_settings.copy() + edited.on_screen_keyboard_enabled = true + assert(manager.apply_settings(edited)) + var reloaded := SettingsManagerType.new() + root.add_child(reloaded) + assert(reloaded.load_settings()) + assert(reloaded.current_settings.on_screen_keyboard_enabled) + manager.queue_free() + reloaded.queue_free() + + +func _validate_keyboard_entry() -> void: + var host := Control.new() + root.add_child(host) + var edit := LineEdit.new() + edit.focus_mode = Control.FOCUS_ALL + host.add_child(edit) + var keyboard := KeyboardType.new() + host.add_child(keyboard) + await process_frame + keyboard.set_enabled(true) + edit.grab_focus() + var activate_event := InputEventJoypadButton.new() + activate_event.button_index = JOY_BUTTON_A + activate_event.pressed = true + keyboard.call("_input", activate_event) + assert(keyboard.is_open()) + keyboard.call("_type_character", "a") + keyboard.call("_type_space") + keyboard.call("_set_page", KeyboardType.Page.UPPER) + keyboard.call("_type_character", "B") + assert(edit.text == "a B") + keyboard.call("_move_caret", -1) + keyboard.call("_type_character", "C") + assert(edit.text == "a CB") + var backspace_event := InputEventJoypadButton.new() + backspace_event.button_index = JOY_BUTTON_X + backspace_event.pressed = true + keyboard.call("_input", backspace_event) + assert(edit.text == "a B") + var defocus_event := InputEventJoypadButton.new() + defocus_event.button_index = JOY_BUTTON_LEFT_SHOULDER + defocus_event.pressed = true + keyboard.call("_input", defocus_event) + assert(not keyboard.is_open()) + assert(root.gui_get_focus_owner() == null) + edit.grab_focus() + keyboard.call("_input", activate_event) + assert(keyboard.is_open()) + var submitted: Array[String] = [] + edit.text_submitted.connect(func(value: String) -> void: + submitted.append(value) + ) + keyboard.call("_submit") + assert(not keyboard.is_open()) + assert(submitted == ["a B"]) + host.queue_free() diff --git a/tests/on_screen_keyboard_validation.gd.uid b/tests/on_screen_keyboard_validation.gd.uid new file mode 100644 index 0000000..973bdca --- /dev/null +++ b/tests/on_screen_keyboard_validation.gd.uid @@ -0,0 +1 @@ +uid://bgmt6joqdk37q diff --git a/tests/ui_scaling_runtime_validation.gd b/tests/ui_scaling_runtime_validation.gd index f3f4974..d329755 100644 --- a/tests/ui_scaling_runtime_validation.gd +++ b/tests/ui_scaling_runtime_validation.gd @@ -94,7 +94,10 @@ func _run() -> void: assert(player_menu.size.is_equal_approx( UIReferencePresentationType.REFERENCE_SIZE )) - assert(hotbar.position.is_equal_approx(Vector2.ZERO)) + assert(hotbar.position.is_equal_approx(Vector2( + 0.0, + canonical_stage.position.y, + ))) assert(hotbar.size.is_equal_approx( UIReferencePresentationType.REFERENCE_SIZE )) diff --git a/ui/chat_ui.gd b/ui/chat_ui.gd index b8690cb..2d4e5fb 100644 --- a/ui/chat_ui.gd +++ b/ui/chat_ui.gd @@ -568,6 +568,21 @@ func _refresh_input_ownership() -> void: ) _panel.mouse_filter = filter _history.mouse_filter = filter + _entry.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) + _collapse_button.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) + _height_button.focus_mode = ( + Control.FOCUS_ALL + if _opened and not _mobile_mode + else Control.FOCUS_NONE + ) + if not _opened: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner in [_entry, _collapse_button, _height_button]: + get_viewport().gui_release_focus() func _clock_panel_style() -> StyleBoxFlat: @@ -886,9 +901,14 @@ func _refresh_visibility() -> void: _collapse_button.show() var collapsed := _presentation_state == PresentationState.COLLAPSED _panel.show() + _collapse_button.focus_mode = ( + Control.FOCUS_ALL if _opened else Control.FOCUS_NONE + ) _height_button.visible = not _mobile_mode _height_button.focus_mode = ( - Control.FOCUS_NONE if _mobile_mode else Control.FOCUS_ALL + Control.FOCUS_ALL + if _opened and not _mobile_mode + else Control.FOCUS_NONE ) _unread_indicator.visible = collapsed and _collapsed_has_unread _hint.visible = not _opened diff --git a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd index 11a7db3..4bebdfe 100644 --- a/ui/components/bubble_hotbar/bubble_hotbar_slot.gd +++ b/ui/components/bubble_hotbar/bubble_hotbar_slot.gd @@ -56,6 +56,8 @@ var _base_position: Vector2 = Vector2.ZERO var _presented_size: Vector2 = Vector2.ZERO var _compact: bool = false var _presentation_initialized: bool = false +var _controller_preview_active: bool = false +var _controller_preview_texture: Texture2D func _ready() -> void: @@ -146,6 +148,15 @@ func set_drag_enabled(enabled: bool) -> void: _hovered = false +func set_controller_placement_preview( + active: bool, + texture: Texture2D, +) -> void: + _controller_preview_active = active + _controller_preview_texture = texture + refresh() + + func refresh() -> void: if _hotbar == null: return @@ -161,11 +172,16 @@ func refresh() -> void: if _fish_inventory != null and not catch_id.is_empty() else null ) - _item_icon.texture = ( + var assigned_texture: Texture2D = ( fish_catch.fish.display_texture if fish_catch != null else item.icon if item != null else null ) + _item_icon.texture = ( + _controller_preview_texture + if _controller_preview_active + else assigned_texture + ) var quantity: int = ( _bag.get_quantity(item_id) if _bag != null and not item_id.is_empty() @@ -177,7 +193,9 @@ func refresh() -> void: else "" ) _quantity_label.text = quantity_text - _quantity_label.visible = not quantity_text.is_empty() + _quantity_label.visible = ( + not _controller_preview_active and not quantity_text.is_empty() + ) tooltip_text = ( "%s · %.1f lb" % [ FishQualityType.qualified_name( @@ -192,7 +210,11 @@ func refresh() -> void: var was_selected: bool = _selected var was_empty: bool = _empty _selected = slot_index == _hotbar.get_selected_slot() - _empty = item == null and fish_catch == null + _empty = ( + not _controller_preview_active + and item == null + and fish_catch == null + ) if was_selected != _selected or was_empty != _empty: _apply_style() @@ -220,7 +242,9 @@ func _apply_style() -> void: var normal_fill: Color = profile.normal_fill normal_fill.a = 1.0 var selected_fill: Color = normal_fill - if _selected: + if _controller_preview_active: + selected_fill = normal_fill.lightened(0.22) + elif _selected: selected_fill = normal_fill.lightened(0.12) add_theme_stylebox_override( "normal", diff --git a/ui/components/bubble_menu/bubble_button.gd b/ui/components/bubble_menu/bubble_button.gd index 32cdef3..f01d50b 100644 --- a/ui/components/bubble_menu/bubble_button.gd +++ b/ui/components/bubble_menu/bubble_button.gd @@ -42,6 +42,53 @@ func _ready() -> void: _apply_icon_presentation(neutral_size) +func _gui_input(event: InputEvent) -> void: + if _adjustment_direction(self) == 0: + return + var direction: int = 0 + if event.is_action_pressed(&"ui_left"): + direction = -1 + elif event.is_action_pressed(&"ui_right"): + direction = 1 + if direction == 0: + return + var adjustment_button: BaseButton = _find_adjustment_button(direction) + if adjustment_button == null: + return + adjustment_button.pressed.emit() + accept_event() + + +func _find_adjustment_button(direction: int) -> BaseButton: + var parent_node: Node = get_parent() + if parent_node == null: + return null + for child: Node in parent_node.get_children(): + var button := child as BaseButton + if ( + button != null + and button.visible + and not button.disabled + and _adjustment_direction(button) == direction + ): + return button + return null + + +func _adjustment_direction(button: BaseButton) -> int: + var descriptions: Array[String] = [ + button.text.strip_edges().to_lower(), + button.tooltip_text.strip_edges().to_lower(), + button.accessibility_name.strip_edges().to_lower(), + ] + for description: String in descriptions: + if description in ["-", "−", "minus", "decrease"]: + return -1 + if description in ["+", "plus", "increase"]: + return 1 + return 0 + + func apply_layout( center: Vector2, bubble_size: Vector2, diff --git a/ui/components/bubble_menu/bubble_cluster.gd b/ui/components/bubble_menu/bubble_cluster.gd index 9a8d4d0..0ad2624 100644 --- a/ui/components/bubble_menu/bubble_cluster.gd +++ b/ui/components/bubble_menu/bubble_cluster.gd @@ -1,6 +1,10 @@ class_name BubbleCluster extends Control +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) + @export var profile: BubbleMenuProfile @export var desktop_reference_size: Vector2 = Vector2(396.0, 318.0) @export var compact_reference_size: Vector2 = Vector2(294.0, 200.0) @@ -18,14 +22,6 @@ func configure(bubbles: Array[BubbleButton]) -> void: if bubble.profile == null: bubble.profile = profile bubble.apply_profile() - if index > 0: - bubble.focus_neighbor_top = bubble.get_path_to( - _bubbles[index - 1] - ) - if index + 1 < _bubbles.size(): - bubble.focus_neighbor_bottom = bubble.get_path_to( - _bubbles[index + 1] - ) func apply_layout(field_size: Vector2, compact: bool) -> void: @@ -52,6 +48,7 @@ func apply_layout(field_size: Vector2, compact: bool) -> void: bubble_size, profile.font_size_ratio ) + ControllerFocusNavigationType.configure_spatial_neighbors(_bubbles) func advance_motion(delta: float) -> void: diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd index d491969..be47c17 100644 --- a/ui/components/bubble_menu/cooler_fish_sprite.gd +++ b/ui/components/bubble_menu/cooler_fish_sprite.gd @@ -19,6 +19,7 @@ var _quality_color := Color.WHITE func _ready() -> void: + set_meta(&"controller_focus_inversion_disabled", true) resized.connect(_update_visual_pivot) focus_entered.connect(_refresh_style) focus_exited.connect(_refresh_style) diff --git a/ui/controller_focus_navigation.gd b/ui/controller_focus_navigation.gd new file mode 100644 index 0000000..cd93796 --- /dev/null +++ b/ui/controller_focus_navigation.gd @@ -0,0 +1,57 @@ +class_name ControllerFocusNavigation +extends RefCounted + +const PERPENDICULAR_WEIGHT: float = 2.5 +const MINIMUM_FORWARD_DISTANCE: float = 0.5 + + +static func configure_spatial_neighbors(controls: Array) -> void: + var candidates: Array[Control] = [] + for item: Variant in controls: + var control := item as Control + if control == null or not control.is_visible_in_tree(): + continue + candidates.append(control) + for control: Control in candidates: + control.focus_neighbor_left = _neighbor_path( + control, candidates, Vector2.LEFT + ) + control.focus_neighbor_right = _neighbor_path( + control, candidates, Vector2.RIGHT + ) + control.focus_neighbor_top = _neighbor_path( + control, candidates, Vector2.UP + ) + control.focus_neighbor_bottom = _neighbor_path( + control, candidates, Vector2.DOWN + ) + + +static func _neighbor_path( + origin: Control, + candidates: Array[Control], + direction: Vector2, +) -> NodePath: + var origin_center: Vector2 = origin.get_global_rect().get_center() + var best: Control = null + var best_score: float = INF + for candidate: Control in candidates: + if candidate == origin or candidate.focus_mode == Control.FOCUS_NONE: + continue + var delta: Vector2 = ( + candidate.get_global_rect().get_center() - origin_center + ) + var forward_distance: float = delta.dot(direction) + if forward_distance <= MINIMUM_FORWARD_DISTANCE: + continue + var perpendicular_distance: float = absf(delta.cross(direction)) + var score: float = ( + forward_distance + + perpendicular_distance * PERPENDICULAR_WEIGHT + ) + if score < best_score: + best = candidate + best_score = score + if best == null: + return NodePath() + return origin.get_path_to(best) diff --git a/ui/controller_focus_navigation.gd.uid b/ui/controller_focus_navigation.gd.uid new file mode 100644 index 0000000..d13ed12 --- /dev/null +++ b/ui/controller_focus_navigation.gd.uid @@ -0,0 +1 @@ +uid://dfb6bobetmujn diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd new file mode 100644 index 0000000..88954ac --- /dev/null +++ b/ui/controller_focus_presentation.gd @@ -0,0 +1,97 @@ +class_name ControllerFocusPresentation +extends Node + +const CONTROLLER_MOTION_THRESHOLD: float = 0.35 +const INVERSION_DISABLED_META: StringName = &"controller_focus_inversion_disabled" + +var _controller_active: bool = false +var _focused_item: CanvasItem +var _original_material: Material +var _inversion_material: ShaderMaterial + + +func _ready() -> void: + var inversion_shader := Shader.new() + inversion_shader.code = """ +shader_type canvas_item; +render_mode unshaded; + +void fragment() { + vec4 source = texture(TEXTURE, UV) * COLOR; + COLOR = vec4(vec3(1.0) - source.rgb, source.a); +} +""" + _inversion_material = ShaderMaterial.new() + _inversion_material.shader = inversion_shader + get_viewport().gui_focus_changed.connect(_on_focus_changed) + set_process_input(true) + set_process(true) + + +func _exit_tree() -> void: + _restore_focused_item() + + +func _input(event: InputEvent) -> void: + if event is InputEventJoypadButton: + if (event as InputEventJoypadButton).pressed: + _set_controller_active(true) + elif event is InputEventJoypadMotion: + if absf((event as InputEventJoypadMotion).axis_value) >= ( + CONTROLLER_MOTION_THRESHOLD + ): + _set_controller_active(true) + elif event is InputEventMouseButton: + if (event as InputEventMouseButton).pressed: + _set_controller_active(false) + elif event is InputEventKey: + if (event as InputEventKey).pressed: + _set_controller_active(false) + + +func _process(_delta: float) -> void: + if not is_instance_valid(_focused_item): + _focused_item = null + _original_material = null + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if ( + focus_owner != _focused_item + or not focus_owner.is_visible_in_tree() + or focus_owner.focus_mode == Control.FOCUS_NONE + or bool(focus_owner.get_meta(INVERSION_DISABLED_META, false)) + ): + _apply_to_focus(focus_owner) + + +func _set_controller_active(active: bool) -> void: + if _controller_active == active: + return + _controller_active = active + _apply_to_focus(get_viewport().gui_get_focus_owner()) + + +func _on_focus_changed(control: Control) -> void: + _apply_to_focus(control) + + +func _apply_to_focus(control: Control) -> void: + _restore_focused_item() + if ( + not _controller_active + or control == null + or not control.is_visible_in_tree() + or control.focus_mode == Control.FOCUS_NONE + or bool(control.get_meta(INVERSION_DISABLED_META, false)) + ): + return + _focused_item = control + _original_material = _focused_item.material + _focused_item.material = _inversion_material + + +func _restore_focused_item() -> void: + if is_instance_valid(_focused_item): + _focused_item.material = _original_material + _focused_item = null + _original_material = null diff --git a/ui/controller_focus_presentation.gd.uid b/ui/controller_focus_presentation.gd.uid new file mode 100644 index 0000000..29b0a72 --- /dev/null +++ b/ui/controller_focus_presentation.gd.uid @@ -0,0 +1 @@ +uid://b2ccvcx1rfd1x diff --git a/ui/controller_focus_recovery.gd b/ui/controller_focus_recovery.gd new file mode 100644 index 0000000..55bc6ad --- /dev/null +++ b/ui/controller_focus_recovery.gd @@ -0,0 +1,142 @@ +class_name ControllerFocusRecovery +extends Node + +const CONTROLLER_AXIS_THRESHOLD: float = 0.35 +const SEMANTIC_MATCH_BONUS: float = 1000000.0 + +var _controller_active: bool = false +var _last_focus_center: Vector2 = Vector2.ZERO +var _last_focus_key: String = "" +var _scope_chain: Array[WeakRef] = [] +var _recovery_generation: int = 0 + + +func _ready() -> void: + set_process_input(true) + set_process(true) + get_viewport().gui_focus_changed.connect(_on_gui_focus_changed) + + +func _exit_tree() -> void: + var viewport := get_viewport() + if viewport.gui_focus_changed.is_connected(_on_gui_focus_changed): + viewport.gui_focus_changed.disconnect(_on_gui_focus_changed) + + +func _input(event: InputEvent) -> void: + if event is InputEventJoypadButton: + var button_event := event as InputEventJoypadButton + if button_event.pressed: + _controller_active = true + if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER: + _recovery_generation += 1 + _scope_chain.clear() + return + if event is InputEventJoypadMotion: + if absf((event as InputEventJoypadMotion).axis_value) >= ( + CONTROLLER_AXIS_THRESHOLD + ): + _controller_active = true + return + if event is InputEventMouseButton: + if (event as InputEventMouseButton).pressed: + _controller_active = false + return + if event is InputEventKey and (event as InputEventKey).pressed: + _controller_active = false + + +func _process(_delta: float) -> void: + if ( + _controller_active + and not _scope_chain.is_empty() + and get_viewport().gui_get_focus_owner() == null + ): + _recover_focus(_recovery_generation) + + +func _on_gui_focus_changed(control: Control) -> void: + _recovery_generation += 1 + if control != null: + if _controller_active and control is BaseButton: + _remember_focus(control) + return + if not _controller_active or _scope_chain.is_empty(): + return + var generation: int = _recovery_generation + _recover_focus.call_deferred(generation) + + +func _remember_focus(control: Control) -> void: + _last_focus_center = control.get_global_rect().get_center() + _last_focus_key = _semantic_key(control) + _scope_chain.clear() + var recovery_root: Node = get_parent() + var ancestor: Node = control.get_parent() + while ancestor != null: + if ancestor is Control: + _scope_chain.append(weakref(ancestor)) + if ancestor == recovery_root: + break + ancestor = ancestor.get_parent() + + +func _recover_focus(generation: int) -> void: + if generation != _recovery_generation: + return + if not _controller_active or get_viewport().gui_get_focus_owner() != null: + return + for scope_reference: WeakRef in _scope_chain: + var scope := scope_reference.get_ref() as Control + if ( + scope == null + or not is_instance_valid(scope) + or not scope.is_inside_tree() + or not scope.is_visible_in_tree() + ): + continue + var replacement := _best_replacement_in(scope) + if replacement != null: + replacement.grab_focus() + return + + +func _best_replacement_in(scope: Control) -> Control: + var best: Control = null + var best_score: float = INF + for node: Node in scope.find_children("*", "Control", true, false): + var candidate := node as Control + if not _is_focusable(candidate): + continue + var distance: float = candidate.get_global_rect().get_center().distance_squared_to( + _last_focus_center + ) + if not _last_focus_key.is_empty() and _semantic_key(candidate) == _last_focus_key: + distance -= SEMANTIC_MATCH_BONUS + if distance < best_score: + best = candidate + best_score = distance + return best + + +func _is_focusable(control: Control) -> bool: + if ( + control == null + or not control.is_inside_tree() + or not control.is_visible_in_tree() + or control.focus_mode == Control.FOCUS_NONE + ): + return false + var button := control as BaseButton + return button == null or not button.disabled + + +func _semantic_key(control: Control) -> String: + if control.has_meta(&"controller_focus_key"): + return str(control.get_meta(&"controller_focus_key")) + var button := control as Button + var label: String = button.text if button != null else "" + var tooltip: String = control.tooltip_text + tooltip = tooltip.trim_suffix(" (show variants)") + tooltip = tooltip.trim_suffix(" (hide variants)") + return "%s|%s|%s" % [control.get_class(), label, tooltip] diff --git a/ui/controller_focus_recovery.gd.uid b/ui/controller_focus_recovery.gd.uid new file mode 100644 index 0000000..b7f8ce9 --- /dev/null +++ b/ui/controller_focus_recovery.gd.uid @@ -0,0 +1 @@ +uid://c7cw61ethr3pt diff --git a/ui/controller_mapping_panel.gd b/ui/controller_mapping_panel.gd index 809f3b5..555f499 100644 --- a/ui/controller_mapping_panel.gd +++ b/ui/controller_mapping_panel.gd @@ -8,6 +8,7 @@ const ControllerMappingManagerType = preload( ) const UtilityPageStyleType = preload("res://ui/utility_page_style.gd") const CAPTURE_NEUTRAL_SECONDS: float = 0.22 +const CANCEL_COMBO_HOLD_SECONDS: float = 1.25 var _mapping_manager: ControllerMappingManagerType var _binding_buttons: Dictionary = {} @@ -24,6 +25,9 @@ var _auto_map_draft: Dictionary = {} var _capture_device_id: int = 0 var _waiting_for_neutral: bool = false var _neutral_elapsed: float = 0.0 +var _cancel_left_bumper_pressed: bool = false +var _cancel_right_bumper_pressed: bool = false +var _cancel_combo_elapsed: float = 0.0 func _ready() -> void: @@ -112,6 +116,14 @@ func _process(delta: float) -> void: or _capturing_role.is_empty() ): return + if _cancel_left_bumper_pressed and _cancel_right_bumper_pressed: + _cancel_combo_elapsed += delta + _progress_label.text = "keep holding both bumpers to cancel" + if _cancel_combo_elapsed >= CANCEL_COMBO_HOLD_SECONDS: + _cancel_capture() + _progress_label.text = "controller mapping cancelled" + return + _cancel_combo_elapsed = 0.0 if _waiting_for_neutral: if not _mapping_manager.are_capture_inputs_neutral(_capture_device_id): _neutral_elapsed = 0.0 @@ -152,18 +164,7 @@ func _on_controller_input_observed(event: InputEvent) -> void: return if event_device != _capture_device_id: return - if ( - button_event != null - and button_event.pressed - and _capturing_role != ControllerMappingManagerType.ROLE_B - and _mapping_manager.event_matches_role( - event, - ControllerMappingManagerType.ROLE_B, - ) - ): - _cancel_capture() - _progress_label.text = "controller mapping cancelled" - return + _track_cancel_combo_button(button_event) if _waiting_for_neutral: return _try_capture_event(event) @@ -332,6 +333,7 @@ func _begin_auto_map() -> void: _auto_map_active = true _auto_map_index = 0 _auto_map_draft = {} + _reset_cancel_combo() _capture_device_id = _mapping_manager.get_active_device_id() _set_capture_role(ControllerMappingManagerType.ROLE_ORDER[_auto_map_index]) @@ -340,6 +342,7 @@ func _begin_manual_capture(role: StringName) -> void: _auto_map_active = false _auto_map_index = -1 _auto_map_draft.clear() + _reset_cancel_combo() _capture_device_id = _mapping_manager.get_active_device_id() _set_capture_role(role) @@ -356,10 +359,14 @@ func _refresh_capture_prompt() -> void: if _capturing_role.is_empty(): return if _auto_map_active: - _progress_label.text = "step %d of %d: %s" % [ + _progress_label.text = ( + "step %d of %d: %s\n" + + "hold both bumpers for %.2f seconds to cancel" + ) % [ _auto_map_index + 1, ControllerMappingManagerType.ROLE_ORDER.size(), _mapping_manager.get_role_prompt(_capturing_role), + CANCEL_COMBO_HOLD_SECONDS, ] return var input_instruction: String = "press any controller button" @@ -408,11 +415,34 @@ func _cancel_capture() -> void: _auto_map_draft.clear() _waiting_for_neutral = false _neutral_elapsed = 0.0 + _reset_cancel_combo() _set_action_buttons_disabled(false) if _progress_label != null: _progress_label.text = "" +func _track_cancel_combo_button(button_event: InputEventJoypadButton) -> void: + if button_event == null: + return + match button_event.button_index: + JOY_BUTTON_LEFT_SHOULDER: + _cancel_left_bumper_pressed = button_event.pressed + JOY_BUTTON_RIGHT_SHOULDER: + _cancel_right_bumper_pressed = button_event.pressed + _: + return + if not ( + _cancel_left_bumper_pressed and _cancel_right_bumper_pressed + ): + _cancel_combo_elapsed = 0.0 + + +func _reset_cancel_combo() -> void: + _cancel_left_bumper_pressed = false + _cancel_right_bumper_pressed = false + _cancel_combo_elapsed = 0.0 + + func _set_action_buttons_disabled(disabled: bool) -> void: if _auto_map_button != null: _auto_map_button.disabled = disabled diff --git a/ui/game_ui.gd b/ui/game_ui.gd index a684e8a..a9af18e 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -133,6 +133,8 @@ const SHOP_SPEECH_CHARACTERS_PER_SECOND: float = 28.0 @onready var _shop_prompt_key: Label = %ShopPromptKey @onready var _shop_prompt_pointer: Polygon2D = %ShopPromptPointer var _shop_animalese_voice: AnimaleseVoiceType +var _shop_npc_player_in_range: bool = false +var _shop_npc_spoken_for_current_visit: bool = false @onready var _effect_status: Label = %EffectStatus @onready var _chat_ui: ChatUIType = %ChatUI @onready var _emote_radial_menu: EmoteRadialMenuType = %EmoteRadialMenu @@ -207,6 +209,18 @@ func _ready() -> void: _hotbar_ui.presentation_transition_finished.connect( _on_hotbar_presentation_transition_finished ) + _player_menu.controller_hotbar_placement_requested.connect( + _on_controller_hotbar_placement_requested + ) + _player_menu.controller_hotbar_placement_ended.connect( + _on_controller_hotbar_placement_ended + ) + _player_menu.controller_hotbar_management_requested.connect( + _on_controller_hotbar_management_requested + ) + _player_menu.controller_hotbar_management_ended.connect( + _on_controller_hotbar_management_ended + ) _title_settings_panel.panel_visibility_changed.connect( _on_settings_visibility_changed ) @@ -503,7 +517,7 @@ func _handle_controller_chat_controls(event: InputEvent) -> bool: _chat_ui.toggle_chat() return true if focus_pressed: - _chat_ui.toggle_focus() + _chat_ui.refocus_gameplay() return true if accept_pressed: return _chat_ui.request_virtual_keyboard() @@ -1302,6 +1316,8 @@ func _on_hud_bait_inventory_changed() -> void: func set_gameplay_ui_enabled(enabled: bool) -> void: _gameplay_ui_enabled = enabled + if enabled: + _try_start_shop_npc_speech() _gameplay_transient_hud.visible = enabled and not _player_menu_open _experience_presentation.visible = enabled _refresh_chat_availability() @@ -1362,7 +1378,6 @@ func set_shop_prompt_visible( world_anchor: Vector3 = Vector3(0.0, INF, 0.0), ) -> void: _apply_shop_prompt_style() - var was_visible := _shop_prompt.visible _shop_prompt.visible = ( is_visible and _gameplay_ui_enabled @@ -1371,23 +1386,42 @@ func set_shop_prompt_visible( and not _shop_open ) if _shop_prompt.visible: - if not was_visible: - _ensure_shop_animalese_voice() - TypewriterRevealType.start( - _shop_prompt_message, - SHOP_SPEECH_CHARACTERS_PER_SECOND, - ) - _shop_animalese_voice.speak_text( - _shop_prompt_message, - _shop_prompt_message.text, - "shopkeeper", - SHOP_ANIMALESE_VOICE_ID, - SHOP_SPEECH_CHARACTERS_PER_SECOND, - ) if world_anchor.is_finite(): _position_shop_prompt(world_anchor) +func set_shop_npc_player_in_range(in_range: bool) -> void: + if _shop_npc_player_in_range == in_range: + return + _shop_npc_player_in_range = in_range + if not in_range: + _shop_npc_spoken_for_current_visit = false + return + _try_start_shop_npc_speech() + + +func _try_start_shop_npc_speech() -> void: + if ( + not _shop_npc_player_in_range + or _shop_npc_spoken_for_current_visit + or not _gameplay_ui_enabled + ): + return + _shop_npc_spoken_for_current_visit = true + _ensure_shop_animalese_voice() + TypewriterRevealType.start( + _shop_prompt_message, + SHOP_SPEECH_CHARACTERS_PER_SECOND, + ) + _shop_animalese_voice.speak_text( + _shop_prompt_message, + _shop_prompt_message.text, + "shopkeeper", + SHOP_ANIMALESE_VOICE_ID, + SHOP_SPEECH_CHARACTERS_PER_SECOND, + ) + + func _ensure_shop_animalese_voice() -> void: if _shop_animalese_voice != null: return @@ -2017,6 +2051,48 @@ func _on_inventory_hotbar_context_changed(show_hotbar: bool) -> void: _refresh_hotbar_visibility() +func _on_controller_hotbar_placement_requested( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, + initial_slot: int, +) -> void: + _player_menu_hotbar_visible = true + _hotbar_ui.set_player_menu_context(true) + _hotbar_ui.set_drag_enabled(false) + _hotbar_ui.set_presentation_visible(true, false) + _hotbar_ui.begin_controller_placement( + assignment_kind, + identity, + initial_slot, + ) + + +func _on_controller_hotbar_placement_ended() -> void: + _hotbar_ui.end_controller_placement() + _hotbar_ui.set_drag_enabled( + _player_menu_open + and _player_menu_hotbar_visible + and not _system_menu_open + ) + + +func _on_controller_hotbar_management_requested(initial_slot: int) -> void: + _player_menu_hotbar_visible = true + _hotbar_ui.set_player_menu_context(true) + _hotbar_ui.set_drag_enabled(false) + _hotbar_ui.set_presentation_visible(true, false) + _hotbar_ui.begin_controller_management(initial_slot) + + +func _on_controller_hotbar_management_ended() -> void: + _hotbar_ui.end_controller_management() + _hotbar_ui.set_drag_enabled( + _player_menu_open + and _player_menu_hotbar_visible + and not _system_menu_open + ) + + func _on_hotbar_presentation_transition_finished( is_visible: bool, ) -> void: diff --git a/ui/hotbar.gd b/ui/hotbar.gd index fa10df0..e058f3f 100644 --- a/ui/hotbar.gd +++ b/ui/hotbar.gd @@ -4,6 +4,7 @@ extends Control signal presentation_transition_finished(is_visible: bool) const ItemCatalogType = preload("res://items/item_catalog.gd") +const ItemDataType = preload("res://items/item_data.gd") const PlayerBagType = preload("res://inventory/player_bag.gd") const PlayerHotbarType = preload("res://inventory/player_hotbar.gd") const FishInventoryType = preload("res://inventory/fish_inventory.gd") @@ -41,6 +42,13 @@ var _item_name_suppressed: bool = false var _motion_elapsed: float = 0.0 var _compact_layout: bool = false var _player_menu_context: bool = false +var _controller_placement_active: bool = false +var _controller_management_active: bool = false +var _controller_placement_kind: PlayerHotbarType.AssignmentKind = ( + PlayerHotbarType.AssignmentKind.EMPTY +) +var _controller_placement_identity: StringName +var _controller_placement_texture: Texture2D var _visibility_tween: Tween var _visibility_generation: int = 0 @@ -99,6 +107,109 @@ func set_drag_enabled(enabled: bool) -> void: _hide_item_name() +func begin_controller_placement( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, + initial_slot: int, +) -> void: + if _hotbar == null or identity.is_empty(): + return + _controller_placement_active = true + _controller_placement_kind = assignment_kind + _controller_placement_identity = identity + _controller_placement_texture = _resolve_controller_placement_texture() + var slot_count: int = _slots.size() + for index: int in slot_count: + var slot: BubbleHotbarSlotType = _slots[index] + slot.focus_mode = Control.FOCUS_ALL + slot.focus_neighbor_left = slot.get_path_to( + _slots[wrapi(index - 1, 0, slot_count)] + ) + slot.focus_neighbor_right = slot.get_path_to( + _slots[wrapi(index + 1, 0, slot_count)] + ) + slot.focus_neighbor_top = slot.get_path_to(slot) + slot.focus_neighbor_bottom = slot.get_path_to(slot) + var target_index: int = clampi(initial_slot, 0, slot_count - 1) + _hotbar.select_slot(target_index) + _refresh_controller_placement_preview() + _slots[target_index].call_deferred("grab_focus") + + +func end_controller_placement() -> void: + if not _controller_placement_active: + return + _controller_placement_active = false + _controller_placement_kind = PlayerHotbarType.AssignmentKind.EMPTY + _controller_placement_identity = StringName() + _controller_placement_texture = null + for slot: BubbleHotbarSlotType in _slots: + slot.focus_mode = Control.FOCUS_NONE + slot.set_controller_placement_preview(false, null) + _show_selected_item_briefly() + + +func begin_controller_management(initial_slot: int) -> void: + if _hotbar == null or _slots.is_empty(): + return + _controller_management_active = true + var slot_count: int = _slots.size() + for index: int in slot_count: + var slot: BubbleHotbarSlotType = _slots[index] + slot.focus_mode = Control.FOCUS_ALL + slot.focus_neighbor_left = slot.get_path_to( + _slots[wrapi(index - 1, 0, slot_count)] + ) + slot.focus_neighbor_right = slot.get_path_to( + _slots[wrapi(index + 1, 0, slot_count)] + ) + slot.focus_neighbor_top = slot.get_path_to(slot) + slot.focus_neighbor_bottom = slot.get_path_to(slot) + var target_index: int = clampi(initial_slot, 0, slot_count - 1) + _hotbar.select_slot(target_index) + _slots[target_index].call_deferred("grab_focus") + + +func end_controller_management() -> void: + if not _controller_management_active: + return + _controller_management_active = false + for slot: BubbleHotbarSlotType in _slots: + slot.focus_mode = Control.FOCUS_NONE + _show_selected_item_briefly() + + +func _resolve_controller_placement_texture() -> Texture2D: + if ( + _controller_placement_kind == PlayerHotbarType.AssignmentKind.FISH + and _fish_inventory != null + ): + var fish_catch: FishCatchType = _fish_inventory.get_catch_by_id( + _controller_placement_identity + ) + return fish_catch.fish.display_texture if fish_catch != null else null + if ( + _controller_placement_kind == PlayerHotbarType.AssignmentKind.ITEM + and _catalog != null + ): + var item: ItemDataType = _catalog.get_item_by_id( + _controller_placement_identity + ) + return item.icon if item != null else null + return null + + +func _refresh_controller_placement_preview() -> void: + if not _controller_placement_active or _hotbar == null: + return + var target_index: int = _hotbar.get_selected_slot() + for slot: BubbleHotbarSlotType in _slots: + slot.set_controller_placement_preview( + slot.slot_index == target_index, + _controller_placement_texture, + ) + + func set_player_menu_context(enabled: bool) -> void: if _player_menu_context == enabled: return @@ -230,6 +341,9 @@ func _collect_slots() -> void: slot.item_hover_ended.connect(_on_slot_item_hover_ended) slot.item_drag_started.connect(_on_slot_drag_started) slot.item_drag_finished.connect(_on_slot_drag_finished) + slot.focus_entered.connect( + _on_controller_slot_focused.bind(slot.slot_index) + ) _slots.append(slot) _slots.sort_custom( func( @@ -278,10 +392,24 @@ func _on_selected_slot_changed( _item_id: StringName, ) -> void: _refresh() + if _controller_placement_active: + _refresh_controller_placement_preview() + return if _hovered_slot_index < 0: _show_selected_item_briefly() +func _on_controller_slot_focused(slot_index: int) -> void: + if ( + not (_controller_placement_active or _controller_management_active) + or _hotbar == null + ): + return + _hotbar.select_slot(slot_index) + if _controller_placement_active: + _refresh_controller_placement_preview() + + func _on_slot_item_hovered( slot_index: int, item_id: StringName, diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index 504ec1c..a7d4767 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -453,6 +453,7 @@ func _refresh_catalog() -> void: func _make_entry(fish: FishDataType, discovered: bool) -> Button: var entry := Button.new() + entry.set_meta(&"controller_focus_inversion_disabled", true) entry.custom_minimum_size = CATALOG_ENTRY_SIZE entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd new file mode 100644 index 0000000..0d7315d --- /dev/null +++ b/ui/on_screen_keyboard.gd @@ -0,0 +1,456 @@ +class_name OnScreenKeyboard +extends Control + +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) +const CHECK_ICON: Texture2D = preload( + "res://ui/icons/pictograms/check_mark_dark.png" +) +const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0) +const CHARACTER_FONT_SIZE: int = 34 +const TRIGGER_PRESS_THRESHOLD: float = 0.55 +const TRIGGER_RELEASE_THRESHOLD: float = 0.25 + +signal text_submitted(value: String) + +enum Page { + LOWER, + UPPER, + SYMBOLS, +} + +var _enabled: bool = false +var _page: Page = Page.LOWER +var _target: Control +var _target_virtual_keyboard_enabled: bool = true +var _buffer: String = "" +var _preview: Label +var _page_buttons: Array[Button] = [] +var _keys_host: VBoxContainer +var _key_buttons: Array[Button] = [] +var _caret_left_button: Button +var _caret_right_button: Button +var _backspace_button: Button +var _space_button: Button +var _check_button: Button +var _left_trigger_pressed: bool = false +var _right_trigger_pressed: bool = false + + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + z_index = 1000 + mouse_filter = Control.MOUSE_FILTER_STOP + _build_interface() + hide() + set_process_input(true) + + +func set_enabled(enabled: bool) -> void: + _enabled = enabled + if not _enabled and visible: + _close_keyboard(false) + + +func is_enabled() -> bool: + return _enabled + + +func is_open() -> bool: + return visible + + +func _input(event: InputEvent) -> void: + if not _enabled: + return + if visible: + var joy_motion := event as InputEventJoypadMotion + if joy_motion != null and _handle_trigger_shortcut(joy_motion): + get_viewport().set_input_as_handled() + return + var joy_button := event as InputEventJoypadButton + if joy_button != null and joy_button.pressed: + if joy_button.button_index == JOY_BUTTON_X: + _backspace() + get_viewport().set_input_as_handled() + return + if joy_button.button_index == JOY_BUTTON_LEFT_SHOULDER: + _close_keyboard(false) + get_viewport().set_input_as_handled() + return + if joy_button.button_index == JOY_BUTTON_RIGHT_SHOULDER: + _set_page(wrapi(int(_page) + 1, 0, Page.size())) + get_viewport().set_input_as_handled() + return + if event.is_action_pressed(&"ui_cancel"): + _close_keyboard(true) + get_viewport().set_input_as_handled() + return + var joy_event := event as InputEventJoypadButton + if ( + joy_event == null + or not joy_event.pressed + or ( + joy_event.button_index != JOY_BUTTON_A + and not event.is_action_pressed(&"ui_accept") + ) + ): + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if _can_edit(focus_owner): + _open_for(focus_owner) + get_viewport().set_input_as_handled() + + +func _can_edit(control: Control) -> bool: + if control is LineEdit: + return (control as LineEdit).editable + if control is TextEdit: + return (control as TextEdit).editable + return false + + +func _open_for(control: Control) -> void: + _target = control + _target_virtual_keyboard_enabled = bool( + _target.get("virtual_keyboard_enabled") + ) + _target.set("virtual_keyboard_enabled", false) + _buffer = str(_target.get("text")) + _page = Page.LOWER + show() + _refresh_preview() + _rebuild_keys() + + +func _close_keyboard(restore_focus: bool) -> void: + var prior_target: Control = _target + if is_instance_valid(prior_target): + prior_target.set( + "virtual_keyboard_enabled", + _target_virtual_keyboard_enabled + ) + hide() + get_viewport().gui_release_focus() + _target = null + if restore_focus and is_instance_valid(prior_target): + prior_target.grab_focus() + + +func _submit() -> void: + var submitted_target: Control = _target + var submitted_text: String = _buffer + _close_keyboard(false) + if submitted_target is LineEdit: + (submitted_target as LineEdit).text_submitted.emit(submitted_text) + elif submitted_target is TextEdit: + (submitted_target as TextEdit).text_changed.emit() + text_submitted.emit(submitted_text) + + +func _type_character(character: String) -> void: + if not is_instance_valid(_target): + _close_keyboard(false) + return + var caret: int = _get_caret_column() + var next_text: String = ( + _buffer.substr(0, caret) + + character + + _buffer.substr(caret) + ) + if _target is LineEdit: + var line_edit := _target as LineEdit + if line_edit.max_length > 0 and next_text.length() > line_edit.max_length: + return + _buffer = next_text + _set_target_text(caret + character.length()) + + +func _type_space() -> void: + _type_character(" ") + + +func _backspace() -> void: + if not is_instance_valid(_target) or _buffer.is_empty(): + return + var caret: int = _get_caret_column() + if caret <= 0: + return + _buffer = _buffer.erase(caret - 1, 1) + _set_target_text(caret - 1) + + +func _move_caret(direction: int) -> void: + if not is_instance_valid(_target): + _close_keyboard(false) + return + var caret: int = clampi( + _get_caret_column() + direction, + 0, + _buffer.length(), + ) + if _target is LineEdit: + (_target as LineEdit).caret_column = caret + elif _target is TextEdit: + (_target as TextEdit).set_caret_column(caret) + _refresh_preview() + + +func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool: + if event.axis == JOY_AXIS_TRIGGER_LEFT: + if event.axis_value <= TRIGGER_RELEASE_THRESHOLD: + _left_trigger_pressed = false + elif ( + event.axis_value >= TRIGGER_PRESS_THRESHOLD + and not _left_trigger_pressed + ): + _left_trigger_pressed = true + _move_caret(-1) + return true + elif event.axis == JOY_AXIS_TRIGGER_RIGHT: + if event.axis_value <= TRIGGER_RELEASE_THRESHOLD: + _right_trigger_pressed = false + elif ( + event.axis_value >= TRIGGER_PRESS_THRESHOLD + and not _right_trigger_pressed + ): + _right_trigger_pressed = true + _move_caret(1) + return true + return false + + +func _get_caret_column() -> int: + if _target is LineEdit: + return clampi( + (_target as LineEdit).caret_column, + 0, + _buffer.length() + ) + return _buffer.length() + + +func _set_target_text(caret: int) -> void: + if _target is LineEdit: + var line_edit := _target as LineEdit + line_edit.text = _buffer + line_edit.caret_column = caret + line_edit.text_changed.emit(_buffer) + elif _target is TextEdit: + var text_edit := _target as TextEdit + text_edit.text = _buffer + text_edit.text_changed.emit() + _refresh_preview() + + +func _refresh_preview() -> void: + if _preview == null: + return + var displayed_text: String = _buffer + if _target is LineEdit and (_target as LineEdit).secret: + displayed_text = "*".repeat(_buffer.length()) + var caret: int = clampi(_get_caret_column(), 0, displayed_text.length()) + _preview.text = displayed_text.insert(caret, "|") + + +func _set_page(page_index: int) -> void: + _page = page_index as Page + _rebuild_keys() + + +func _rebuild_keys() -> void: + for child: Node in _keys_host.get_children(): + _keys_host.remove_child(child) + child.queue_free() + _key_buttons.clear() + var rows: Array = _rows_for_page() + for row_value: Variant in rows: + var row := HBoxContainer.new() + row.alignment = BoxContainer.ALIGNMENT_CENTER + row.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_theme_constant_override("separation", 10) + _keys_host.add_child(row) + for character_value: Variant in row_value as Array: + var character: String = str(character_value) + var key_button: Button = _make_button(character, CHARACTER_KEY_SIZE) + key_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + key_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER + key_button.add_theme_font_size_override( + "font_size", CHARACTER_FONT_SIZE + ) + key_button.pressed.connect(_type_character.bind(character)) + row.add_child(key_button) + _key_buttons.append(key_button) + for index: int in _page_buttons.size(): + _page_buttons[index].button_pressed = index == int(_page) + call_deferred("_configure_key_focus") + + +func _configure_key_focus() -> void: + var focus_controls: Array[Control] = [] + for button: Button in _key_buttons: + focus_controls.append(button) + for button: Button in [ + _caret_left_button, + _caret_right_button, + _backspace_button, + _space_button, + _check_button, + ]: + focus_controls.append(button) + ControllerFocusNavigationType.configure_spatial_neighbors(focus_controls) + if not _key_buttons.is_empty(): + _key_buttons[0].grab_focus() + + +func _rows_for_page() -> Array: + match _page: + Page.UPPER: + return [ + ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], + ["A", "S", "D", "F", "G", "H", "J", "K", "L"], + ["Z", "X", "C", "V", "B", "N", "M"], + ] + Page.SYMBOLS: + return [ + ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"], + ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"], + ["-", "_", "=", "+", "[", "]", "{", "}"], + [".", ",", "?", "/", ":", ";", "'", "\""] + ] + _: + return [ + ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], + ["a", "s", "d", "f", "g", "h", "j", "k", "l"], + ["z", "x", "c", "v", "b", "n", "m"], + ] + + +func _build_interface() -> void: + var backdrop := ColorRect.new() + backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + backdrop.color = Color(0.012, 0.075, 0.105, 0.82) + backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + add_child(backdrop) + var screen_margin := MarginContainer.new() + screen_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + for side: StringName in [ + &"margin_left", &"margin_top", &"margin_right", &"margin_bottom" + ]: + screen_margin.add_theme_constant_override(side, 14) + add_child(screen_margin) + var panel := PanelContainer.new() + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_theme_stylebox_override( + "panel", + _make_style(Color(0.075, 0.27, 0.34, 0.98), 18, 4) + ) + screen_margin.add_child(panel) + var margin := MarginContainer.new() + for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]: + margin.add_theme_constant_override(side, 18) + panel.add_child(margin) + var layout := VBoxContainer.new() + layout.add_theme_constant_override("separation", 10) + margin.add_child(layout) + var preview_panel := PanelContainer.new() + preview_panel.custom_minimum_size = Vector2(0.0, 86.0) + preview_panel.add_theme_stylebox_override( + "panel", + _make_style(Color(0.82, 0.94, 0.95, 1.0), 10, 2) + ) + layout.add_child(preview_panel) + _preview = Label.new() + _preview.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0)) + _preview.add_theme_font_size_override("font_size", 30) + _preview.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + _preview.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _preview.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _preview.add_theme_constant_override("outline_size", 0) + preview_panel.add_child(_preview) + var page_row := HBoxContainer.new() + page_row.alignment = BoxContainer.ALIGNMENT_CENTER + page_row.add_theme_constant_override("separation", 12) + layout.add_child(page_row) + for page_name: String in ["lower", "upper", "symbols"]: + var page_button: Button = _make_button(page_name, Vector2(150.0, 44.0)) + page_button.toggle_mode = true + page_button.focus_mode = Control.FOCUS_NONE + page_button.pressed.connect(_set_page.bind(_page_buttons.size())) + page_row.add_child(page_button) + _page_buttons.append(page_button) + _keys_host = VBoxContainer.new() + _keys_host.size_flags_vertical = Control.SIZE_EXPAND_FILL + _keys_host.alignment = BoxContainer.ALIGNMENT_CENTER + _keys_host.add_theme_constant_override("separation", 12) + layout.add_child(_keys_host) + var utility_row := HBoxContainer.new() + utility_row.alignment = BoxContainer.ALIGNMENT_CENTER + utility_row.add_theme_constant_override("separation", 12) + layout.add_child(utility_row) + _caret_left_button = _make_button("LT <", Vector2(118.0, 64.0)) + _caret_left_button.tooltip_text = "move text cursor left" + _caret_left_button.pressed.connect(_move_caret.bind(-1)) + utility_row.add_child(_caret_left_button) + _caret_right_button = _make_button("> RT", Vector2(118.0, 64.0)) + _caret_right_button.tooltip_text = "move text cursor right" + _caret_right_button.pressed.connect(_move_caret.bind(1)) + utility_row.add_child(_caret_right_button) + _backspace_button = _make_button("backspace X", Vector2(180.0, 64.0)) + _backspace_button.pressed.connect(_backspace) + utility_row.add_child(_backspace_button) + _space_button = _make_button("space", Vector2(350.0, 64.0)) + _space_button.size_flags_stretch_ratio = 4.0 + _space_button.pressed.connect(_type_space) + utility_row.add_child(_space_button) + _check_button = _make_button("", Vector2(92.0, 64.0)) + _check_button.icon = CHECK_ICON + _check_button.expand_icon = true + _check_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + _check_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + _check_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER + _check_button.tooltip_text = "submit" + _check_button.accessibility_name = "submit text" + _check_button.add_theme_constant_override("icon_max_width", 42) + _check_button.pressed.connect(_submit) + utility_row.add_child(_check_button) + _rebuild_keys() + + +func _make_button(label: String, minimum_size: Vector2) -> Button: + var button := Button.new() + button.text = label + button.custom_minimum_size = minimum_size + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.size_flags_vertical = Control.SIZE_EXPAND_FILL + button.focus_mode = Control.FOCUS_ALL + button.add_theme_font_size_override("font_size", 22) + button.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0)) + button.add_theme_color_override("font_hover_color", Color(0.025, 0.12, 0.17, 1.0)) + button.add_theme_color_override("font_focus_color", Color(0.025, 0.12, 0.17, 1.0)) + button.add_theme_stylebox_override( + "normal", + _make_style(Color(0.72, 0.88, 0.91, 1.0), 10, 2) + ) + button.add_theme_stylebox_override( + "hover", + _make_style(Color(0.87, 0.96, 0.96, 1.0), 10, 3) + ) + button.add_theme_stylebox_override("focus", button.get_theme_stylebox("hover")) + button.add_theme_stylebox_override( + "pressed", + _make_style(Color(0.44, 0.72, 0.77, 1.0), 10, 3) + ) + return button + + +func _make_style(color: Color, radius: int, border_width: int) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.border_color = Color(0.025, 0.12, 0.17, 1.0) + style.set_border_width_all(border_width) + style.set_corner_radius_all(radius) + return style diff --git a/ui/on_screen_keyboard.gd.uid b/ui/on_screen_keyboard.gd.uid new file mode 100644 index 0000000..da300a2 --- /dev/null +++ b/ui/on_screen_keyboard.gd.uid @@ -0,0 +1 @@ +uid://dcanibf8hordh diff --git a/ui/player_menu.gd b/ui/player_menu.gd index 88ead8d..3d99289 100644 --- a/ui/player_menu.gd +++ b/ui/player_menu.gd @@ -85,6 +85,14 @@ const MAIN_SHOP_BUYER_ID: StringName = &"main_fishing_shop" signal menu_visibility_changed(is_open: bool) signal inventory_hotbar_context_changed(show_hotbar: bool) +signal controller_hotbar_placement_requested( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, + initial_slot: int, +) +signal controller_hotbar_placement_ended +signal controller_hotbar_management_requested(initial_slot: int) +signal controller_hotbar_management_ended signal menu_exit_started signal shop_cooler_modal_changed(is_open: bool) @@ -124,6 +132,13 @@ enum CloseReason { TEARDOWN, } +enum ControllerOwnership { + ITEM_LIST, + NOTEPAD_ACTIONS, + HOTBAR_MANAGEMENT, + HOTBAR_PLACEMENT, +} + const INVENTORY_MAIN_POSITION := Vector2(54.0, 166.0) const INVENTORY_MAIN_SIZE := Vector2(882.0, 484.0) const INVENTORY_PANEL_GAP := 16.0 @@ -302,6 +317,15 @@ var _last_inventory_section: Section = Section.COOLER var _bag_view: BagView = BagView.EQUIPMENT var _tackle_view: TackleView = TackleView.BAIT var _selected_tackle_item_id: StringName +var _controller_ownership: ControllerOwnership = ControllerOwnership.ITEM_LIST +var _controller_source_section: Section = Section.COOLER +var _controller_source_identity: StringName +var _controller_notepad_actions: Array[BaseButton] = [] +var _controller_hotbar_assignment_kind: PlayerHotbarType.AssignmentKind = ( + PlayerHotbarType.AssignmentKind.EMPTY +) +var _controller_hotbar_identity: StringName +var _controller_previous_hotbar_slot: int = 0 var _sort_mode: SortMode = SortMode.CATCH_ORDER var _sort_descending: bool = true var _fish_selection := FishBatchSelectionType.new() @@ -589,12 +613,25 @@ func set_profile_preview_world_pixel_size(pixel_size: int) -> void: _profile_page.set_world_pixel_size(pixel_size) +var _left_page_trigger_held: bool = false +var _right_page_trigger_held: bool = false + + func _input(event: InputEvent) -> void: if event is InputEventKey and event.echo: return + if _handle_controller_ownership_input(event): + get_viewport().set_input_as_handled() + return + if visible: + _reserve_main_navigation_for_page_switching() + _reserve_visible_secondary_navigation() if _handle_controller_page_switch(event): get_viewport().set_input_as_handled() return + if _handle_controller_secondary_switch(event): + get_viewport().set_input_as_handled() + return if _handle_direct_page_shortcut(event): get_viewport().set_input_as_handled() return @@ -613,47 +650,381 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() +func _handle_controller_ownership_input(event: InputEvent) -> bool: + if not visible: + return false + var button_event := event as InputEventJoypadButton + var accept_pressed: bool = ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_A, + JOY_BUTTON_A, + ) + ) + var cancel_pressed: bool = ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ) + ) + var alternate_pressed: bool = ( + button_event != null + and button_event.pressed + and _event_matches_controller_role( + event, + ControllerMappingManagerType.ROLE_Y, + JOY_BUTTON_Y, + ) + ) + if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: + if cancel_pressed: + _release_controller_ownership(true, false) + return true + if alternate_pressed: + if _hotbar != null: + _hotbar.clear_slot(_hotbar.get_selected_slot()) + return true + if accept_pressed: + return true + return false + if _controller_ownership == ControllerOwnership.HOTBAR_PLACEMENT: + if accept_pressed: + _confirm_controller_hotbar_placement() + return true + if cancel_pressed: + _release_controller_ownership(true, true) + return true + if alternate_pressed: + return true + return false + if _controller_ownership == ControllerOwnership.NOTEPAD_ACTIONS: + if cancel_pressed: + _release_controller_ownership(true, false) + return true + if alternate_pressed: + return true + var direction: int = 0 + if event.is_action_pressed("ui_left") or event.is_action_pressed("ui_up"): + direction = -1 + elif ( + event.is_action_pressed("ui_right") + or event.is_action_pressed("ui_down") + ): + direction = 1 + if direction != 0: + _focus_next_notepad_action(direction) + return true + return false + if alternate_pressed: + return _try_begin_controller_hotbar_placement() + if accept_pressed: + return _try_enter_notepad_controller_ownership() + return false + + +func _event_matches_controller_role( + event: InputEvent, + role: StringName, + fallback_button: JoyButton, +) -> bool: + if _controller_mapping_manager != null: + return _controller_mapping_manager.event_matches_role(event, role) + var button_event := event as InputEventJoypadButton + return button_event != null and button_event.button_index == fallback_button + + +func _try_enter_notepad_controller_ownership() -> bool: + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null: + return false + var source_identity: StringName + var actions: Array[BaseButton] = [] + if _current_section == Section.COOLER: + var fish_node := focus_owner as CoolerFishSpriteType + if fish_node == null or fish_node.catch_id.is_empty(): + return false + source_identity = fish_node.catch_id + _on_catch_card_pressed(source_identity) + if not _favorite_bubble.disabled: + actions.append(_favorite_bubble) + if not _sell_bubble.disabled: + actions.append(_sell_bubble) + elif _current_section == Section.TACKLE_BOX: + if not focus_owner.has_meta(&"controller_tackle_item_id"): + return false + source_identity = StringName( + str(focus_owner.get_meta(&"controller_tackle_item_id")) + ) + _select_tackle_item(source_identity) + if _tackle_equip_button.visible and not _tackle_equip_button.disabled: + actions.append(_tackle_equip_button) + else: + return false + if actions.is_empty(): + _restore_controller_item_focus(_current_section, source_identity) + return true + _controller_source_section = _current_section + _controller_source_identity = source_identity + _controller_notepad_actions = actions + _controller_ownership = ControllerOwnership.NOTEPAD_ACTIONS + actions.front().call_deferred("grab_focus") + return true + + +func _focus_next_notepad_action(direction: int) -> void: + var available: Array[BaseButton] = [] + for action: BaseButton in _controller_notepad_actions: + if ( + is_instance_valid(action) + and action.visible + and not action.disabled + and action.focus_mode != Control.FOCUS_NONE + ): + available.append(action) + if available.is_empty(): + return + var focused: Control = get_viewport().gui_get_focus_owner() + var current_index: int = available.find(focused) + if current_index < 0: + current_index = 0 if direction > 0 else available.size() - 1 + else: + current_index = wrapi( + current_index + direction, + 0, + available.size(), + ) + available[current_index].grab_focus() + + +func _try_begin_controller_hotbar_placement() -> bool: + if _hotbar == null: + return false + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if focus_owner == null: + return false + var assignment_kind: PlayerHotbarType.AssignmentKind = ( + PlayerHotbarType.AssignmentKind.EMPTY + ) + var identity: StringName + if _current_section == Section.COOLER: + var fish_node := focus_owner as CoolerFishSpriteType + if fish_node == null or fish_node.catch_id.is_empty(): + return false + assignment_kind = PlayerHotbarType.AssignmentKind.FISH + identity = fish_node.catch_id + elif _current_section == Section.BAG: + var item_node := focus_owner as BagItemSpriteType + if item_node == null or item_node.item_id.is_empty(): + return false + var item: ItemDataType = ( + _item_catalog.get_item_by_id(item_node.item_id) + if _item_catalog != null + else null + ) + if item == null or not item.hotbar_allowed: + return true + assignment_kind = PlayerHotbarType.AssignmentKind.ITEM + identity = item_node.item_id + else: + return false + _controller_source_section = _current_section + _controller_source_identity = identity + _controller_hotbar_assignment_kind = assignment_kind + _controller_hotbar_identity = identity + _controller_previous_hotbar_slot = _hotbar.get_selected_slot() + var initial_slot: int = _find_controller_hotbar_assignment( + assignment_kind, + identity, + ) + if initial_slot < 0: + initial_slot = _controller_previous_hotbar_slot + _controller_ownership = ControllerOwnership.HOTBAR_PLACEMENT + controller_hotbar_placement_requested.emit( + assignment_kind, + identity, + initial_slot, + ) + return true + + +func _find_controller_hotbar_assignment( + assignment_kind: PlayerHotbarType.AssignmentKind, + identity: StringName, +) -> int: + for slot_index: int in range(PlayerHotbarType.SLOT_COUNT): + if ( + assignment_kind == PlayerHotbarType.AssignmentKind.FISH + and _hotbar.get_fish_catch_id(slot_index) == identity + ): + return slot_index + if ( + assignment_kind == PlayerHotbarType.AssignmentKind.ITEM + and _hotbar.get_item_id(slot_index) == identity + ): + return slot_index + return -1 + + +func _confirm_controller_hotbar_placement() -> void: + if ( + _controller_ownership != ControllerOwnership.HOTBAR_PLACEMENT + or _hotbar == null + ): + return + var slot_index: int = _hotbar.get_selected_slot() + var assigned: bool = false + if ( + _controller_hotbar_assignment_kind + == PlayerHotbarType.AssignmentKind.FISH + ): + assigned = _hotbar.assign_fish( + slot_index, + _controller_hotbar_identity, + ) + elif ( + _controller_hotbar_assignment_kind + == PlayerHotbarType.AssignmentKind.ITEM + ): + assigned = _hotbar.assign_item( + slot_index, + _controller_hotbar_identity, + ) + if assigned: + _release_controller_ownership(true, false) + + +func _release_controller_ownership( + restore_source_focus: bool, + restore_previous_hotbar_slot: bool, +) -> void: + if _controller_ownership == ControllerOwnership.ITEM_LIST: + return + var prior_ownership: ControllerOwnership = _controller_ownership + var source_section: Section = _controller_source_section + var source_identity: StringName = _controller_source_identity + _controller_ownership = ControllerOwnership.ITEM_LIST + _controller_notepad_actions.clear() + _controller_source_identity = StringName() + _controller_hotbar_assignment_kind = PlayerHotbarType.AssignmentKind.EMPTY + _controller_hotbar_identity = StringName() + if prior_ownership == ControllerOwnership.HOTBAR_PLACEMENT: + if restore_previous_hotbar_slot and _hotbar != null: + _hotbar.select_slot(_controller_previous_hotbar_slot) + controller_hotbar_placement_ended.emit() + if prior_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: + controller_hotbar_management_ended.emit() + inventory_hotbar_context_changed.emit( + _current_section in [Section.COOLER, Section.BAG] + ) + if restore_source_focus: + call_deferred( + "_restore_controller_item_focus", + source_section, + source_identity, + ) + + +func _restore_controller_item_focus( + section: Section, + identity: StringName, +) -> void: + if not visible or section != _current_section: + return + if identity.is_empty(): + _focus_current_section() + return + var target: Control + if section == Section.COOLER: + target = _fish_nodes.get(identity) as CoolerFishSpriteType + elif section == Section.BAG: + target = _bag_item_nodes.get(identity) as BagItemSpriteType + elif section == Section.TACKLE_BOX: + for child: Node in _tackle_item_list.get_children(): + var button := child as BaseButton + if ( + button != null + and button.has_meta(&"controller_tackle_item_id") + and StringName(str(button.get_meta( + &"controller_tackle_item_id" + ))) == identity + ): + target = button + break + if ( + target != null + and is_instance_valid(target) + and target.is_visible_in_tree() + and target.focus_mode != Control.FOCUS_NONE + ): + target.grab_focus() + else: + _focus_current_section() + + func _handle_controller_page_switch(event: InputEvent) -> bool: var button_event: InputEventJoypadButton = event as InputEventJoypadButton + var motion_event: InputEventJoypadMotion = event as InputEventJoypadMotion var use_mapping: bool = ( _controller_mapping_manager != null ) - var uses_left_bumper: bool = ( + var uses_left_trigger: bool = ( _controller_mapping_manager.event_uses_role( - event, ControllerMappingManagerType.ROLE_LB + event, ControllerMappingManagerType.ROLE_POINTER_MODIFIER ) if use_mapping else ( - button_event != null - and button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + motion_event != null + and motion_event.axis == JOY_AXIS_TRIGGER_LEFT ) ) - var uses_right_bumper: bool = ( + var uses_right_trigger: bool = ( _controller_mapping_manager.event_uses_role( - event, ControllerMappingManagerType.ROLE_RB + event, ControllerMappingManagerType.ROLE_CAMERA_ZOOM ) if use_mapping else ( - button_event != null - and button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + motion_event != null + and motion_event.axis == JOY_AXIS_TRIGGER_RIGHT ) ) if ( - button_event == null - or not (uses_left_bumper or uses_right_bumper) + not (uses_left_trigger or uses_right_trigger) or not visible ): return false - if not button_event.pressed: + var is_pressed: bool = ( + button_event.pressed + if button_event != null + else motion_event.axis_value > 0.5 + ) + if not is_pressed: + if uses_left_trigger: + _left_page_trigger_held = false + if uses_right_trigger: + _right_page_trigger_held = false return true - # Shoulder input belongs to the Player Menu while it is visible, even when - # a transition or modal temporarily prevents changing pages. This keeps LB - # from opening Chat and RB from leaking into gameplay behind the menu. + if ( + (uses_left_trigger and _left_page_trigger_held) + or (uses_right_trigger and _right_page_trigger_held) + ): + return true + if uses_left_trigger: + _left_page_trigger_held = true + if uses_right_trigger: + _right_page_trigger_held = true + # Trigger input belongs to the Player Menu while it is visible, even when a + # transition or modal temporarily prevents changing pages. if ( _transitioning or _page_transitioning or _sale_confirmation.visible or get_viewport().gui_is_dragging() + or _controller_ownership != ControllerOwnership.ITEM_LIST ): return true var sections: Array[Section] = [ @@ -670,13 +1041,169 @@ func _handle_controller_page_switch(event: InputEvent) -> bool: if current_index < 0: current_index = 0 var direction: int = ( - -1 if uses_left_bumper else 1 + -1 if uses_left_trigger else 1 ) var next_index: int = wrapi(current_index + direction, 0, sections.size()) _show_section(sections[next_index]) return true +func _handle_controller_secondary_switch(event: InputEvent) -> bool: + var button_event := event as InputEventJoypadButton + if button_event == null or not visible: + return false + var use_mapping: bool = _controller_mapping_manager != null + var uses_left_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, ControllerMappingManagerType.ROLE_LB + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_LEFT_SHOULDER + ) + var uses_right_bumper: bool = ( + _controller_mapping_manager.event_uses_role( + event, ControllerMappingManagerType.ROLE_RB + ) + if use_mapping + else button_event.button_index == JOY_BUTTON_RIGHT_SHOULDER + ) + if not (uses_left_bumper or uses_right_bumper): + return false + if not button_event.pressed: + return true + var direction: int = -1 if uses_left_bumper else 1 + if _controller_ownership == ControllerOwnership.HOTBAR_MANAGEMENT: + _cycle_from_controller_hotbar_management(direction) + return true + if ( + _transitioning + or _page_transitioning + or _sale_confirmation.visible + or get_viewport().gui_is_dragging() + or _controller_ownership != ControllerOwnership.ITEM_LIST + ): + return true + if _is_inventory_section(_current_section): + if ( + (direction > 0 and _current_section == Section.BAG) + or (direction < 0 and _current_section == Section.COOLER) + ): + _begin_controller_hotbar_management() + return true + var inventory_sections: Array[Section] = [ + Section.COOLER, + Section.TACKLE_BOX, + Section.BAG, + ] + var inventory_index: int = inventory_sections.find(_current_section) + _show_section(inventory_sections[wrapi( + inventory_index + direction, + 0, + inventory_sections.size(), + )]) + return true + _cycle_visible_secondary_tabs(direction) + return true + + +func _begin_controller_hotbar_management() -> void: + if _hotbar == null: + return + _controller_source_section = _current_section + _controller_source_identity = StringName() + var focus_owner: Control = get_viewport().gui_get_focus_owner() + if _current_section == Section.COOLER: + var fish_node := focus_owner as CoolerFishSpriteType + if fish_node != null: + _controller_source_identity = fish_node.catch_id + elif _current_section == Section.BAG: + var item_node := focus_owner as BagItemSpriteType + if item_node != null: + _controller_source_identity = item_node.item_id + _controller_ownership = ControllerOwnership.HOTBAR_MANAGEMENT + controller_hotbar_management_requested.emit(_hotbar.get_selected_slot()) + + +func _cycle_from_controller_hotbar_management(direction: int) -> void: + _release_controller_ownership(false, false) + var target_section: Section = ( + Section.COOLER if direction > 0 else Section.BAG + ) + if target_section == _current_section: + call_deferred("_focus_current_section") + else: + _show_section(target_section) + + +func _cycle_visible_secondary_tabs(direction: int) -> bool: + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + var grouped_buttons: Dictionary = {} + _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) + var selected_group: Variant = null + var selected_group_y: float = INF + for group_value: Variant in grouped_buttons.keys(): + var buttons := grouped_buttons[group_value] as Array + if buttons.size() < 2: + continue + var group_y: float = INF + for item: Variant in buttons: + var button := item as BaseButton + group_y = minf(group_y, button.global_position.y) + if group_y < selected_group_y: + selected_group = group_value + selected_group_y = group_y + if selected_group == null: + return false + var tabs := grouped_buttons[selected_group] as Array + tabs.sort_custom(func(first: BaseButton, second: BaseButton) -> bool: + return first.global_position.x < second.global_position.x + ) + var current_index: int = 0 + for index: int in tabs.size(): + var tab := tabs[index] as BaseButton + if tab.button_pressed: + current_index = index + break + var target := tabs[wrapi( + current_index + direction, 0, tabs.size() + )] as BaseButton + target.set_pressed_no_signal(true) + target.pressed.emit() + return true + + +func _collect_visible_toggle_buttons( + root: Node, + navigation_cluster: Control, + grouped_buttons: Dictionary, +) -> void: + for child: Node in root.get_children(): + var child_control := child as Control + if child_control != null and not child_control.is_visible_in_tree(): + continue + var button := child as BaseButton + if ( + button != null + and button.toggle_mode + and ( + navigation_cluster == null + or not navigation_cluster.is_ancestor_of(button) + ) + ): + var group_key: Variant = ( + button.button_group + if button.button_group != null + else button.get_parent() + ) + if not grouped_buttons.has(group_key): + grouped_buttons[group_key] = [] + var buttons := grouped_buttons[group_key] as Array + buttons.append(button) + _collect_visible_toggle_buttons( + child, navigation_cluster, grouped_buttons + ) + + func _handle_direct_page_shortcut(event: InputEvent) -> bool: var key_event := event as InputEventKey if ( @@ -772,6 +1299,7 @@ func open_menu() -> void: or not _fishing_spot.can_open_player_menu() ): return + _release_controller_ownership(false, true) _menu_generation += 1 _transition_generation += 1 _cancel_presentation_tween() @@ -791,6 +1319,9 @@ func open_menu() -> void: _update_shell_layout() _begin_menu_entry() menu_visibility_changed.emit(true) + inventory_hotbar_context_changed.emit( + _current_section in [Section.COOLER, Section.BAG] + ) func open_section(section: Section) -> bool: @@ -921,6 +1452,7 @@ func close_menu( if reason != CloseReason.USER: _finish_close(reason, restore_controls, _menu_generation) return + _release_controller_ownership(false, true) get_viewport().gui_cancel_drag() _close_sale_confirmation() if reason in [ @@ -949,6 +1481,7 @@ func close_for_session_end() -> void: func _exit_tree() -> void: + _release_controller_ownership(false, true) _cancel_presentation_tween() _cancel_page_tween() if visible: @@ -1007,6 +1540,7 @@ func _show_section(section: Section) -> void: and _profile_page.request_close_confirmation() ): return + _release_controller_ownership(false, true) _begin_page_transition(section) @@ -1122,22 +1656,39 @@ func _focus_current_section() -> void: if _shop_cooler_context_active: _focus_shop_cooler() return - if _current_section == Section.COOLER: - _cooler_sub_tab.grab_focus() - elif _current_section == Section.BAG: - _bag_sub_tab.grab_focus() - elif _current_section == Section.TACKLE_BOX: - _tackle_sub_tab.grab_focus() - elif _current_section == Section.LOGBOOK: - _catalog_logbook.focus_initial() - elif _current_section == Section.NET: - _the_net_page.focus_initial() - elif _current_section == Section.MAIL: - _mail_tab.grab_focus() - elif _current_section == Section.PLAYERS: - _players_tab.grab_focus() - else: - _profile_tab.grab_focus() + _reserve_main_navigation_for_page_switching() + _reserve_visible_secondary_navigation() + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + var candidates: Array[Control] = [] + _collect_focusable_content(self, navigation_cluster, candidates) + if candidates.is_empty(): + return + candidates.sort_custom(func(first: Control, second: Control) -> bool: + if not is_equal_approx(first.global_position.y, second.global_position.y): + return first.global_position.y < second.global_position.y + return first.global_position.x < second.global_position.x + ) + candidates[0].grab_focus() + + +func _collect_focusable_content( + root: Node, + navigation_cluster: Control, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + if child == navigation_cluster: + continue + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if ( + control != null + and control.focus_mode != Control.FOCUS_NONE + and not control is ScrollBar + ): + output.append(control) + _collect_focusable_content(child, navigation_cluster, output) func _process(delta: float) -> void: @@ -1161,6 +1712,7 @@ func _process(delta: float) -> void: func _configure_navigation_focus() -> void: + _reserve_main_navigation_for_page_switching() var navigation: Array[BubbleButtonType] = [ _inventory_tab, _logbook_tab, @@ -1182,6 +1734,48 @@ func _configure_navigation_focus() -> void: bubble.focus_neighbor_right = bubble.get_path_to(next) bubble.focus_neighbor_top = bubble.focus_neighbor_left bubble.focus_neighbor_bottom = bubble.focus_neighbor_right + + +func _reserve_main_navigation_for_page_switching() -> void: + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + if navigation_cluster == null: + return + _set_descendant_focus_disabled(navigation_cluster) + + +func _set_descendant_focus_disabled(root: Node) -> void: + for child: Node in root.get_children(): + var control := child as Control + if control != null: + control.focus_mode = Control.FOCUS_NONE + _set_descendant_focus_disabled(child) + + +func _reserve_visible_secondary_navigation() -> void: + var navigation_cluster := get_node_or_null("%NavigationCluster") as Control + var grouped_buttons: Dictionary = {} + _collect_visible_toggle_buttons(self, navigation_cluster, grouped_buttons) + var selected_tabs: Array = [] + var selected_group_y: float = INF + for group_value: Variant in grouped_buttons.keys(): + var buttons := grouped_buttons[group_value] as Array + if buttons.size() < 2: + continue + var group_y: float = INF + for item: Variant in buttons: + var button := item as BaseButton + group_y = minf(group_y, button.global_position.y) + if group_y < selected_group_y: + selected_tabs = buttons + selected_group_y = group_y + var focus_owner: Control = get_viewport().gui_get_focus_owner() + var displaced_focus: bool = selected_tabs.has(focus_owner) + for item: Variant in selected_tabs: + var tab := item as Control + tab.focus_mode = Control.FOCUS_NONE + if displaced_focus: + focus_owner.release_focus() + call_deferred("_focus_current_section") var inventory_tabs: Array[Button] = [ _cooler_sub_tab, _tackle_sub_tab, @@ -1359,6 +1953,7 @@ func _refresh_tackle_box() -> void: row.text = "%s ×%d" % [item.display_name, owned.quantity] row.alignment = HORIZONTAL_ALIGNMENT_LEFT row.toggle_mode = true + row.set_meta(&"controller_tackle_item_id", owned.item_id) row.button_pressed = owned.item_id == _selected_tackle_item_id if item.is_bait() and item.icon != null: _apply_tackle_bait_button_style(row) diff --git a/ui/players_page.gd b/ui/players_page.gd index b5a62ee..2f0d6a7 100644 --- a/ui/players_page.gd +++ b/ui/players_page.gd @@ -336,7 +336,32 @@ func _confirm(text: String, action: Callable) -> void: func _focus_first() -> void: - for child: Node in _tabs.get_children(): - if child is Button and child.visible and not child.disabled: - child.grab_focus() - return + var candidates: Array[Control] = [] + _collect_focusable_player_controls(self, candidates) + if candidates.is_empty(): + return + candidates.sort_custom(func(first: Control, second: Control) -> bool: + if not is_equal_approx(first.global_position.y, second.global_position.y): + return first.global_position.y < second.global_position.y + return first.global_position.x < second.global_position.x + ) + candidates[0].grab_focus() + + +func _collect_focusable_player_controls( + root: Node, + output: Array[Control], +) -> void: + for child: Node in root.get_children(): + if child == _tabs: + continue + var control := child as Control + if control != null and not control.is_visible_in_tree(): + continue + if ( + control != null + and control.focus_mode != Control.FOCUS_NONE + and not control is ScrollBar + ): + output.append(control) + _collect_focusable_player_controls(child, output) diff --git a/ui/settings/settings_bubble_page.gd b/ui/settings/settings_bubble_page.gd index 1ccff30..4710be9 100644 --- a/ui/settings/settings_bubble_page.gd +++ b/ui/settings/settings_bubble_page.gd @@ -1,6 +1,10 @@ class_name SettingsBubblePage extends Control +const ControllerFocusNavigationType = preload( + "res://ui/controller_focus_navigation.gd" +) + @export var page_id: StringName @export var cluster_path: NodePath = ^"BubbleCluster" @export var bubble_paths: Array[NodePath] = [] @@ -169,23 +173,13 @@ func _update_layout() -> void: _cluster.position = _resting_cluster_position _cluster.size = field_size _cluster.apply_layout(field_size, compact) + ControllerFocusNavigationType.configure_spatial_neighbors(_focus_bubbles) func _configure_focus_order() -> void: for bubble: BubbleButton in _bubbles: bubble.focus_mode = Control.FOCUS_NONE - for index: int in _focus_bubbles.size(): - var bubble: BubbleButton = _focus_bubbles[index] - if index > 0: - bubble.focus_neighbor_top = bubble.get_path_to( - _focus_bubbles[index - 1] - ) - bubble.focus_neighbor_left = bubble.focus_neighbor_top - if index + 1 < _focus_bubbles.size(): - bubble.focus_neighbor_bottom = bubble.get_path_to( - _focus_bubbles[index + 1] - ) - bubble.focus_neighbor_right = bubble.focus_neighbor_bottom + ControllerFocusNavigationType.configure_spatial_neighbors(_focus_bubbles) func _set_interactive(interactive: bool) -> void: diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 768980a..640cc1d 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -53,6 +53,7 @@ enum PresentationMode { @onready var _mouse_value: BubbleButton = %MouseValue @onready var _controller_value: BubbleButton = %ControllerValue @onready var _invert_y_toggle: BubbleButton = %InvertYToggle +@onready var _on_screen_keyboard_toggle: BubbleButton = %OnScreenKeyboardToggle @onready var _auto_click_toggle: BubbleButton = %AutoClickToggle @onready var _auto_click_interval: BubbleButton = %AutoClickIntervalValue @@ -84,6 +85,7 @@ var _auto_click_interval_value: float = 0.20 var _mouse_sensitivity: float = 0.005 var _controller_sensitivity: float = 2.5 var _invert_camera_y: bool = false +var _on_screen_keyboard_enabled: bool = false var _network_profile: NetworkProfilePreferences var _network_session: NetworkSession var _data_root: PlayerDataRoot @@ -161,6 +163,7 @@ func _ready() -> void: _adjust_controller_sensitivity.bind(1) ) _invert_y_toggle.pressed.connect(_toggle_invert_y) + _on_screen_keyboard_toggle.pressed.connect(_toggle_on_screen_keyboard) _auto_click_toggle.pressed.connect(_toggle_auto_click) for control: Control in [_auto_click_toggle, _auto_click_interval]: control.mouse_entered.connect( @@ -704,6 +707,7 @@ func _apply_settings() -> void: edited.mouse_camera_sensitivity = _mouse_sensitivity edited.controller_camera_sensitivity = _controller_sensitivity edited.invert_camera_y = _invert_camera_y + edited.on_screen_keyboard_enabled = _on_screen_keyboard_enabled if _settings_manager.apply_settings(edited): if ( _presentation_mode == PresentationMode.TITLE_EMBEDDED @@ -725,6 +729,7 @@ func _load_controls() -> void: _mouse_sensitivity = settings.mouse_camera_sensitivity _controller_sensitivity = settings.controller_camera_sensitivity _invert_camera_y = settings.invert_camera_y + _on_screen_keyboard_enabled = settings.on_screen_keyboard_enabled _refresh_value_labels() @@ -755,6 +760,10 @@ func _refresh_value_labels() -> void: "invert\nvertical\ncamera\n" + ("on" if _invert_camera_y else "off") ) + _on_screen_keyboard_toggle.text = ( + "on-screen\nkeyboard\n" + + ("on" if _on_screen_keyboard_enabled else "off") + ) _auto_click_toggle.text = ( "accessibility\nauto-click\n" + ("on" if _auto_click_enabled else "off") @@ -883,6 +892,11 @@ func _toggle_invert_y() -> void: _refresh_value_labels() +func _toggle_on_screen_keyboard() -> void: + _on_screen_keyboard_enabled = not _on_screen_keyboard_enabled + _refresh_value_labels() + + func _toggle_auto_click() -> void: _auto_click_enabled = not _auto_click_enabled _refresh_value_labels() diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index 6d05831..e831674 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -397,8 +397,8 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("3_page") page_id = &"controls" -bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/ControlsBackButton")]) initial_focus_path = NodePath("BubbleCluster/MouseValue") back_focus_path = NodePath("BubbleCluster/ControlsBackButton") @@ -491,17 +491,29 @@ neutral_size = Vector2(176, 170) compact_minimum_size = Vector2(170, 164) minimum_font_size = 14 maximum_font_size = 24 -desktop_anchor = Vector2(525, 145) -compact_anchor = Vector2(485, 135) +desktop_anchor = Vector2(525, 105) +compact_anchor = Vector2(485, 95) motion_phase = 4.5 +[node name="OnScreenKeyboardToggle" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "on-screen\nkeyboard\noff" +neutral_size = Vector2(170, 150) +compact_minimum_size = Vector2(154, 136) +minimum_font_size = 14 +maximum_font_size = 22 +desktop_anchor = Vector2(525, 255) +compact_anchor = Vector2(485, 235) +motion_phase = 4.7 + [node name="ControllerMapping" parent="ControlsPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "map\ncontroller" neutral_size = Vector2(160, 150) -desktop_anchor = Vector2(525, 330) -compact_anchor = Vector2(485, 300) +desktop_anchor = Vector2(525, 390) +compact_anchor = Vector2(485, 355) compact_minimum_size = Vector2(148, 138) minimum_font_size = 14 maximum_font_size = 23 @@ -512,8 +524,8 @@ unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) text = "back" neutral_size = Vector2(100, 94) -desktop_anchor = Vector2(635, 445) -compact_anchor = Vector2(555, 405) +desktop_anchor = Vector2(635, 470) +compact_anchor = Vector2(555, 420) compact_minimum_size = Vector2(84, 80) minimum_font_size = 14 maximum_font_size = 17 diff --git a/ui/title_screen.gd b/ui/title_screen.gd index 4531b3f..5031efc 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -608,6 +608,10 @@ func _prepare_awaiting_start_input() -> void: _navigation_focus_active = false _modal_restore_navigation_focus = false _button_center.show() + var bubble_field := get_node_or_null("%BubbleField") as Control + if bubble_field != null: + bubble_field.hide() + call_deferred("set_process", visible) _feedback_label.show() _feedback_label.modulate.a = 0.0 _continue_stats_hovered = false @@ -650,6 +654,10 @@ func _reveal_primary_menu() -> void: ): return _awaiting_start_input = false + _button_center.show() + var bubble_field := get_node_or_null("%BubbleField") as Control + if bubble_field != null: + bubble_field.show() _stop_entry_prompt_animation() _title_entry_generation += 1 _title_entry_transition_active = true diff --git a/ui/ui_pixelation_presenter.gd b/ui/ui_pixelation_presenter.gd index 18481c3..2cc2063 100644 --- a/ui/ui_pixelation_presenter.gd +++ b/ui/ui_pixelation_presenter.gd @@ -5,6 +5,13 @@ const MIN_UI_VIEWPORT_SIZE: Vector2i = Vector2i(256, 180) const UIReferencePresentationType = preload( "res://ui/ui_reference_presentation.gd" ) +const ControllerFocusPresentationType = preload( + "res://ui/controller_focus_presentation.gd" +) +const ControllerFocusRecoveryType = preload( + "res://ui/controller_focus_recovery.gd" +) +const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd") signal effective_pixel_size_changed( requested_pixel_size: int, @@ -16,6 +23,9 @@ signal effective_pixel_size_changed( @onready var _canonical_stage: Control = ( $UIViewport/GameUI/UIRoot/CanonicalStage ) +@onready var _hotbar: HotbarUI = ( + $UIViewport/GameUI/UIRoot/CanonicalStage/Hotbar +) @onready var _chat_ui: ChatUI = $UIViewport/GameUI/UIRoot/ChatUI @onready var _title_content_stage: Control = ( $UIViewport/GameUI/UIRoot/TitleScreen/ResponsiveTitleStage @@ -26,14 +36,25 @@ var _effective_pixel_size: int = PlayerSettings.DEFAULT_UI_PIXEL_SIZE var _gameplay_active: bool = false var _interactive_ui_open: bool = false var _passive_pointer_ui_enabled: bool = false +var _on_screen_keyboard: OnScreenKeyboardType func _ready() -> void: + _on_screen_keyboard = OnScreenKeyboardType.new() + _ui_root.add_child(_on_screen_keyboard) + var controller_focus_recovery := ControllerFocusRecoveryType.new() + _ui_root.add_child(controller_focus_recovery) + var controller_focus_presentation := ControllerFocusPresentationType.new() + _ui_root.add_child(controller_focus_presentation) var root_viewport: Viewport = get_viewport() root_viewport.size_changed.connect(_resize_presentation) _resize_presentation() +func set_on_screen_keyboard_enabled(enabled: bool) -> void: + _on_screen_keyboard.set_enabled(enabled) + + func set_pixel_size(pixel_size: int) -> void: _requested_pixel_size = clampi( pixel_size, @@ -120,6 +141,7 @@ func _resize_presentation() -> void: UIReferencePresentationType.get_stage_position(display_size) ) _canonical_stage.size = UIReferencePresentationType.REFERENCE_SIZE + _hotbar.position = Vector2(0.0, _canonical_stage.position.y) _title_content_stage.set_anchors_preset(Control.PRESET_TOP_LEFT) _title_content_stage.position = _canonical_stage.position _title_content_stage.size = UIReferencePresentationType.REFERENCE_SIZE diff --git a/world/README-WORLD-AUTHORING.md b/world/README-WORLD-AUTHORING.md index d596c05..60cfdc8 100644 --- a/world/README-WORLD-AUTHORING.md +++ b/world/README-WORLD-AUTHORING.md @@ -1,6 +1,6 @@ # World authoring -The active test world uses the human-authored starter island through +The active test world uses the canonical starter island through `world/regions/starter_island_region.tscn`. ## Active composition