Compare commits

...

4 commits

50 changed files with 2881 additions and 130 deletions

View file

@ -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

View file

@ -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.

137
docs/PORTMASTER-RELEASE.md Normal file
View file

@ -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<project-version>` 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<project-version>/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.

View file

@ -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())

155
scripts/build_portmaster.sh Executable file
View file

@ -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" <<EOF
NETfishing PortMaster ARM64 build
Project version: ${PROJECT_VERSION}
Source commit: ${TAG_COMMIT}
Engine/export template: $("${GODOT_BIN}" --version)
Architecture: AArch64
Minimum linked GLIBC symbol version: GLIBC_2.28
Rendering method: gl_compatibility
The executable and PCK were exported from the release commit listed above.
Repository working-tree changes were not included in game content.
EOF
cat >"${GAME_ROOT}/README.md" <<EOF
# NETfishing for PortMaster
This package contains NETfishing \`${RELEASE_TAG}\`, exported from Godot 4.7.1
for 64-bit ARM Linux and wrapped for PortMaster.
## Installation
Install the complete \`netfishing.zip\` archive with PortMaster or HarbourMaster.
The package requires an ARM64 device, two analog sticks, GLIBC 2.28 or newer,
and the \`weston_pkg_0.2\` runtime.
Save data and device-local configuration remain under \`netfishing/conf/\`.
See \`netfishing/licenses/\` for bundled credits and license information.
EOF
(
cd "${STAGE_ROOT}"
zip -qry "${ARCHIVE}" NETfishing.sh netfishing port.json
)
unzip -tq "${ARCHIVE}" >/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}"

120
scripts/portmaster/NETfishing.sh Executable file
View file

@ -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' ' ' </proc/device-tree/compatible)"
elif [[ -r /sys/firmware/devicetree/base/compatible ]]; then
DEVICE_COMPATIBILITY="$(
tr '\0' ' ' </sys/firmware/devicetree/base/compatible
)"
fi
case "$DEVICE_COMPATIBILITY" in
*allwinner,h616*|*sun50iw9p1*|*allwinner,sun50i-h700*)
NETFISHING_GAME_ENVIRONMENT+=("NETFISHING_LOW_END=1")
NETFISHING_GODOT_OPTIONS+=(
--single-window
--disable-vsync
--max-fps 30
--audio-output-latency 40
)
;;
esac
$ESUDO env CRUSTY_RESOLUTION="${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}" \
"$WESTON_DIR/westonwrap.sh" headless noop kiosk crusty_x11egl \
XDG_DATA_HOME="$CONFDIR/data" \
XDG_CONFIG_HOME="$CONFDIR/config" \
XDG_CACHE_HOME="$CONFDIR/cache" \
SDL_GAMECONTROLLERCONFIG="$netfishing_controllerconfig" \
GODOT_SILENCE_ROOT_WARNING=1 \
"${NETFISHING_GAME_ENVIRONMENT[@]}" \
"$GAME_EXECUTABLE" \
"${NETFISHING_GODOT_OPTIONS[@]}" \
--resolution "${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}" \
--fullscreen \
--rendering-driver opengl3_es \
--audio-driver ALSA
$ESUDO "$WESTON_DIR/westonwrap.sh" cleanup
if [[ "$PM_CAN_MOUNT" != "N" ]]; then
$ESUDO umount "$WESTON_DIR" 2>/dev/null
fi
pm_finish

13
scripts/portmaster/gameinfo.xml Executable file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<gameList>
<game>
<path>./NETfishing.sh</path>
<name>NETfishing</name>
<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>
<releasedate>20260804T000000</releasedate>
<developer>NETfishing project</developer>
<publisher>Independent</publisher>
<genre>adventure, simulation</genre>
<image>./netfishing/screenshot.png</image>
</game>
</gameList>

View file

@ -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).

View file

@ -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 <tu@tulrich.com> http://tulrich.com
Karoly Barta bartakarcsi@gmail.com
Michael Evans http://www.evertype.com

View file

@ -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"
}
}

BIN
scripts/portmaster/screenshot.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -0,0 +1 @@
uid://bjay8ki3albk6

View file

@ -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)

View file

@ -0,0 +1 @@
uid://cqk75fxykqrhs

View file

@ -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()

View file

@ -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

View file

@ -0,0 +1 @@
uid://cbv0ia42rqu41

View file

@ -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()

View file

@ -0,0 +1 @@
uid://bgmt6joqdk37q

View file

@ -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
))

View file

@ -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

View file

@ -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",

View file

@ -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,

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1 @@
uid://dfb6bobetmujn

View file

@ -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

View file

@ -0,0 +1 @@
uid://b2ccvcx1rfd1x

View file

@ -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]

View file

@ -0,0 +1 @@
uid://c7cw61ethr3pt

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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

456
ui/on_screen_keyboard.gd Normal file
View file

@ -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

View file

@ -0,0 +1 @@
uid://dcanibf8hordh

View file

@ -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)

View file

@ -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)

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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