diff --git a/.github/workflows/check-doc-snippets.yml b/.github/workflows/check-doc-snippets.yml index 6db644de5cc..b34a40d8224 100644 --- a/.github/workflows/check-doc-snippets.yml +++ b/.github/workflows/check-doc-snippets.yml @@ -18,6 +18,7 @@ on: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/slate/**' + - 'scripts/splat/**' - 'scripts/skirmish/**' - 'scripts/dash/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index bcd25331985..eb0d9f32928 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -21,6 +21,7 @@ on: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/slate/**' + - 'scripts/splat/**' - 'scripts/skirmish/**' - 'scripts/dash/**' - 'scripts/parley/**' @@ -150,6 +151,7 @@ jobs: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/slate/**' + - 'scripts/splat/**' - 'scripts/hellocodenameone/common/src/main/kmp/**' - 'maven/cn1-app-kit/**' workflow: diff --git a/.github/workflows/initializr-e2e.yml b/.github/workflows/initializr-e2e.yml index c8af172d687..88a7ec54796 100644 --- a/.github/workflows/initializr-e2e.yml +++ b/.github/workflows/initializr-e2e.yml @@ -28,6 +28,7 @@ on: - 'scripts/bastion/common/src/test/**' - 'scripts/pulse/common/src/test/**' - 'scripts/slate/common/src/test/**' + - 'scripts/splat/common/src/test/**' schedule: - cron: '17 3 * * *' workflow_dispatch: @@ -47,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, DASH, PARLEY, BASTION, REEL, PULSE, SLATE] + template: [BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, DASH, SPLAT, PARLEY, BASTION, REEL, PULSE, SLATE] java: ['17'] # Developers download on current JDKs too: Kotlin 1.6.0 crashed on 25 # ("IllegalArgumentException: 25") while every 17 leg was green. diff --git a/.github/workflows/splat.yml b/.github/workflows/splat.yml new file mode 100644 index 00000000000..ef33fef481c --- /dev/null +++ b/.github/workflows/splat.yml @@ -0,0 +1,505 @@ +name: Splat (paintball sample) + +# scripts/splat is one Maven project holding a game and its server: a +# `rules` module (the pure, deterministic simulation both sides run), a `shared` +# module of transfer objects and @RestClient contracts, the Codename One client +# (`common`), a Codename One Backend that implements the contracts, and an `ios` +# module for the device build. It is modelled on savor.yml. +# +# Five things are checked here, because each fails without the others noticing: +# +# rules the simulation's own tests, on the JVM, with no server +# backend the server's own tests, on the JVM and compiled by ParparVM, +# then the same tests on PostgreSQL and MySQL +# client-e2e the application in the simulator, driven through its real +# screens against a running server (run-e2e.sh builds and starts +# the server itself) +# android the Android Gradle project builds +# ios the Xcode project generates and builds for the simulator +# +# Savor's `web` job (the browser build hosted by the server) is dropped: Splat +# has no run-web-e2e.sh, so there is nothing to run. The two device builds stop +# at a build: the screens are covered by the simulator run, and neither port +# holds screenshot baselines for this sample. + +on: + # Nightly at 00:11 UTC on master (nightly-watchdog.yml opens an issue when it + # fails), and on a pull request only when this workflow's own tree changes. + # Shared code (core, ports, builders, backend) reaches it through the nightly. + pull_request: + paths: + - 'scripts/splat/**' + - '.github/workflows/splat.yml' + workflow_dispatch: + schedule: + - cron: '11 0 * * *' + +concurrency: + # Only the latest revision needs validation. PR numbers keep forks isolated; + # event names keep a manual run from cancelling a push or PR run. + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # Required to pull the pr-ci-container image from ghcr.io. + packages: read + +jobs: + template-sync: + name: Initializr template matches the sample + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + # The template's resources are derived from scripts/splat and committed. + # A change to one without the other is a template that is not the project + # this workflow tests. + - name: Compare the template with scripts/splat + run: | + python3 scripts/sync-initializr-template.py splat + + rules: + name: Rules tests (deterministic simulation) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,codenameone-maven-plugin,cn1-app-kit,cn1-app-kit/common,cn1-app-kit/backend,cn1-app-kit/lib \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the rules tests + working-directory: scripts/splat + run: mvn -B -pl rules test -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: splat-rules-tests + path: scripts/splat/rules/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + backend: + name: Server tests (JVM and compiled) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Install native build tools + run: | + bash scripts/ci/apt-get-update.sh + # A server binary links libcurl, OpenSSL and nghttp2; clang alone + # compiles the tests and then fails at the link. + sudo apt-get install -y clang zlib1g-dev libcurl4-openssl-dev libssl-dev libnghttp2-dev + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + # The quality gates run in pr.yml; here they would only slow an install + # whose purpose is to put this checkout's artifacts in the repository. + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin,cn1-app-kit,cn1-app-kit/common,cn1-app-kit/backend,cn1-app-kit/lib \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on the JVM and compiled + working-directory: scripts/splat + run: | + mvn -B -Dcodename1.platform=backend -pl rules,shared,backend test \ + -Dcn1.backend.compiledTests=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + JDK_8_HOME: ${{ env.JDK_8_HOME }} + + # A goal that skipped leaves the job green, so the reports are what prove + # that both runners ran. + - name: Check that both runners reported + working-directory: scripts/splat/backend/target/surefire-reports + run: | + jvm=$(find . -maxdepth 1 -name 'TEST-*.xml' ! -name '*-compiled.xml' | wc -l) + compiled=$(find . -maxdepth 1 -name 'TEST-*-compiled.xml' | wc -l) + echo "JVM reports: $jvm, compiled reports: $compiled" + if [ "$jvm" -eq 0 ] || [ "$compiled" -eq 0 ]; then + echo "::error::a runner produced no test reports" + exit 1 + fi + + - name: Upload the test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: splat-backend-tests + path: scripts/splat/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + backend-database: + name: Server tests on ${{ matrix.database }} + runs-on: ubuntu-24.04 + timeout-minutes: 60 + # The backend job runs on in-memory SQLite, which accepts any declared + # column type for any value and has no collations to get wrong. PostgreSQL + # and MySQL are what a deployment runs on, and where the ORM's schema + # validation refuses a mapping that disagrees with its migration. + strategy: + fail-fast: false + matrix: + database: [postgresql, mysql] + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: cn1 + POSTGRES_PASSWORD: secret + POSTGRES_DB: cn1 + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cn1" --health-interval 5s + --health-timeout 5s --health-retries 20 + steps: + - uses: actions/checkout@v6 + + # The runner's own MySQL 8.0 rather than a service container: a service + # cannot be given server options, and MySQL 8.4 (mysql:8) turns + # mysql_native_password off. The backend does caching_sha2_password's + # full exchange only over verified TLS, which a throwaway server has no + # certificate for. + - name: Start MySQL + if: matrix.database == 'mysql' + run: | + sudo systemctl start mysql.service + sudo mysql -uroot -proot -e "CREATE USER 'cn1'@'%' IDENTIFIED WITH mysql_native_password BY 'secret'; GRANT ALL ON *.* TO 'cn1'@'%'; CREATE DATABASE cn1;" + + - name: Set up JDK 8 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '8' + cache: 'maven' + - name: Save JDK 8 Path + run: echo "JDK_8_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + - name: Save JDK 17 Path + run: echo "JDK_17_HOME=$JAVA_HOME" >> "$GITHUB_ENV" + + - name: Install the framework, the backend runtime and the plugin + working-directory: maven + run: | + bash "$GITHUB_WORKSPACE/scripts/ci/retry.sh" mvn -B -pl core,backend,backend-test,codenameone-maven-plugin,cn1-app-kit,cn1-app-kit/common,cn1-app-kit/backend,cn1-app-kit/lib \ + -am install -DskipTests -Plocal-dev-javase \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true \ + -Dmaven.javadoc.skip=true \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_8_HOME }} + RETRY_ONLY_MATCHING: transient + + - name: Run the server tests on ${{ matrix.database }} + run: | + if [ "${{ matrix.database }}" = mysql ]; then + export CN1_TEST_DATABASE_URL="mysql://cn1:secret@127.0.0.1:3306/cn1?sslmode=disable" + else + export CN1_TEST_DATABASE_URL="postgres://cn1:secret@localhost:5432/cn1?sslmode=disable" + fi + scripts/ci/sample-server-tests-on-database.sh scripts/splat \ + -Dmaven.repo.local="$GITHUB_WORKSPACE/.m2-repo" + env: + JAVA_HOME: ${{ env.JDK_17_HOME }} + + - name: Upload the test reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: splat-backend-${{ matrix.database }}-tests + path: scripts/splat/backend/target/surefire-reports/** + if-no-files-found: warn + retention-days: 14 + + client-e2e: + name: Application against a running server (simulator) + runs-on: ubuntu-24.04 + container: ghcr.io/codenameone/codenameone/pr-ci-container:latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Install Codename One artifacts (core / javase / css-compiler / plugin / backend) + # Retried only for a failure that is the network's: a compilation + # error has to fail on the first attempt. + env: + RETRY_ONLY_MATCHING: transient + RETRY_ATTEMPTS: '5' + RETRY_DELAY_SECONDS: '60' + run: | + export JAVA_HOME="${JAVA_HOME_8}" + export PATH="${JAVA_HOME_8}/bin:$PATH" + cd maven + bash ../scripts/ci/retry.sh mvn -B \ + -pl core,javase,css-compiler,codenameone-maven-plugin,backend,backend-test,cn1-app-kit/backend,cn1-app-kit/common,cn1-app-kit/lib -am install \ + -Plocal-dev-javase -DskipTests -Dmaven.javadoc.skip=true \ + -Dspotbugs.skip=true -Dpmd.skip=true -Dcheckstyle.skip=true + + - name: Ensure a virtual display (xvfb) for the simulator + run: | + if ! command -v xvfb-run >/dev/null 2>&1; then + (timeout 300 apt-get update && timeout 300 apt-get install -y xvfb) || true + fi + + # Ahead of the simulator run: it is quick, and a template that does not + # build should not wait behind the screens to say so. + - name: Build a project generated from the Initializr template + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/splat/check-template.sh "$RUNNER_TEMP/splat-template" + + # The Initializr's own tests, which cover this template too, run in + # wayline.yml: a change here reaches the generator's resources, which + # trigger it. + + - name: Run the application against the server + run: | + export JDK_8_HOME="${JAVA_HOME_8}" + export JAVA_HOME="${JAVA_HOME_17}" + export PATH="${JAVA_HOME_17}/bin:$PATH" + scripts/splat/run-e2e.sh + + - name: Upload the captures and the server log + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: splat-e2e + path: | + scripts/splat/target/e2e-shots/** + scripts/splat/target/e2e-server.log + if-no-files-found: warn + retention-days: 14 + + android: + name: Android build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CN1_APP_DIR: scripts/splat + steps: + - uses: actions/checkout@v6 + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-splat-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2-splat- + ${{ runner.os }}-m2- + - name: Cache Gradle + uses: actions/cache@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-splat-${{ hashFiles('Ports/Android/build.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle-splat- + ${{ runner.os }}-gradle- + - name: Setup workspace + run: ./scripts/setup-workspace.sh -q -DskipTests + - name: Build Android port + run: ./scripts/build-android-port.sh -q -DskipTests + - name: Build the Android application + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + ./scripts/build-android-app.sh -q -DskipTests + + build-ios-port: + # The reusable workflow scripts-ios.yml depends on; sharing it shares its cache. + uses: ./.github/workflows/_build-ios-port.yml + + ios: + name: iOS build (simulator) + needs: build-ios-port + runs-on: macos-15 + timeout-minutes: 50 + env: + CN1_APP_DIR: scripts/splat + steps: + - uses: actions/checkout@v6 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v5 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" + + - name: Cache codenameone-tools + uses: actions/cache@v5 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v5 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v5 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Download built CN1 + iOS port bundle + uses: actions/download-artifact@v4 + with: + name: cn1-built-bundle + path: ${{ runner.temp }} + - name: Restore built CN1 + iOS port tree + run: | + set -euo pipefail + tar -C / -xf "$RUNNER_TEMP/cn1-built-bundle.tar" + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate the Xcode project + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 25 + + - name: Build the application for the simulator + run: | + set -euo pipefail + WORKSPACE='${{ steps.build-ios-app.outputs.workspace }}' + SCHEME='${{ steps.build-ios-app.outputs.scheme }}' + DERIVED_DATA="${{ runner.temp }}/splat-dd" + rm -rf "$DERIVED_DATA" + # One architecture, the host's: the port's SIMD natives are NEON + # under __aarch64__, and a universal simulator build compiles an + # x86_64 slice that has no use here. + HOST_ARCH="$(uname -m)" + case "$HOST_ARCH" in + arm64|x86_64) BUILD_ARCH="$HOST_ARCH" ;; + *) BUILD_ARCH=arm64 ;; + esac + XCB_CONTAINER_FLAG="-workspace" + if [[ "$WORKSPACE" != *.xcworkspace ]]; then + XCB_CONTAINER_FLAG="-project" + fi + # No -sdk: it forces every target of the scheme onto the iPhone SDK, the + # embedded watch app included, whose watch-only icon then has nothing to + # compile for. The destination picks each target's own SDK. + xcodebuild "$XCB_CONTAINER_FLAG" "$WORKSPACE" -scheme "$SCHEME" \ + -configuration Debug \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$DERIVED_DATA" \ + ARCHS="$BUILD_ARCH" ONLY_ACTIVE_ARCH=YES \ + EXCLUDED_ARCHS="armv7 armv7s" \ + CODE_SIGNING_ALLOWED=NO build + APP_BUNDLE="$(find "$DERIVED_DATA/Build/Products" -maxdepth 3 -type d -name "${SCHEME}.app" | head -n 1)" + if [ -z "$APP_BUNDLE" ]; then + echo "::error::no .app bundle under $DERIVED_DATA" + exit 1 + fi + timeout-minutes: 20 diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 51808950c05..87a132e35d1 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -67,6 +67,7 @@ on: - 'scripts/bastion/**' - 'scripts/pulse/**' - 'scripts/slate/**' + - 'scripts/splat/**' - 'scripts/skirmish/**' - 'scripts/dash/**' - 'scripts/parley/**' diff --git a/CodenameOne/src/com/codename1/gaming/AnimatedModel.java b/CodenameOne/src/com/codename1/gaming/AnimatedModel.java index 857292c14ca..34f00817608 100644 --- a/CodenameOne/src/com/codename1/gaming/AnimatedModel.java +++ b/CodenameOne/src/com/codename1/gaming/AnimatedModel.java @@ -117,6 +117,12 @@ public class AnimatedModel { private float fadeLength; private float fadeElapsed; private boolean posed; + private GltfScene.Animation layer; + private float layerTime; + private float layerWeight; + private boolean[] layerMask; + private String layerRoot; + private float[] layerPose; private float x; private float y; @@ -169,6 +175,8 @@ public AnimatedModel(GraphicsDevice device, GltfScene scene) { if (p.getMaterial() >= 0 && p.getMaterial() < scene.getMaterialCount()) { GltfScene.MaterialInfo info = scene.getMaterial(p.getMaterial()); material.setColor(info.getBaseColor()); + material.setMetallic(info.getMetallic()).setRoughness(info.getRoughness()) + .setEmissive(info.getEmissive()); Texture t = scene.getTexture(device, info.getImage()); if (t != null) { material.setTexture(t); @@ -186,6 +194,20 @@ public AnimatedModel(GraphicsDevice device, GltfScene scene) { pose(); } + /// Switches every part of the model to a lighting model, keeping its + /// colour, texture and the metallic, roughness and emissive factors the + /// glTF file gave it. Parts start as `Material.Type.LAMBERT`; + /// `Material.Type.PBR` shades them the way the asset was authored. + /// + /// #### Parameters + /// + /// - `type`: the lighting model + public void setMaterialType(Material.Type type) { + for (Model part : parts) { + part.getMaterial().setType(type); + } + } + /// The scene this instance poses. public GltfScene getScene() { return scene; @@ -324,6 +346,74 @@ public boolean crossFade(String name, float seconds, boolean loop) { return true; } + /// Blends clip `name` over whatever plays, at `weight` (0 to 1), on the + /// node `root` and everything under it. The layer keeps its own clock + /// and loops. It is how a character aims where it looks: a run plays + /// below, and an aim-up or aim-down pose is mixed into the spine by how + /// far the camera is pitched, without dragging the legs along. + /// + /// Calling it every frame with a new weight is cheap; the same clip keeps + /// its time. A weight of 0, a null name or a clip the scene lacks turns + /// the layer off. + /// + /// #### Parameters + /// + /// - `name`: the clip to mix in, or null for none + /// + /// - `weight`: how much of it, 0 to 1 + /// + /// - `root`: the node whose subtree it moves, or null for the whole model + /// + /// #### Returns + /// + /// false when the layer is off afterwards + public boolean setLayer(String name, float weight, String root) { + GltfScene.Animation a = name == null ? null : scene.findAnimation(name); + if (a == null || weight <= 0f) { + if (layer != null) { + posed = false; + } + layer = null; + return false; + } + if (a != layer) { //NOPMD CompareObjectsWithEquals - the same clip instance + layer = a; + layerTime = 0f; + posed = false; + } + boolean sameRoot = root == null ? layerRoot == null : root.equals(layerRoot); + if (layerMask == null || !sameRoot) { + layerMask = mask(root); + layerRoot = root; + posed = false; + if (layerPose == null) { + layerPose = new float[rest.length]; + } + } + layerWeight = weight > 1f ? 1f : weight; + posed = false; + return true; + } + + private boolean[] mask(String root) { + int count = scene.getNodeCount(); + boolean[] m = new boolean[count]; + int top = -1; + if (root != null) { + for (int i = 0; i < count; i++) { + if (root.equals(scene.getNode(i).getName())) { + top = i; + break; + } + } + } + for (int n : order) { + int parent = scene.getNode(n).getParent(); + m[n] = top < 0 || n == top || parent >= 0 && m[parent]; + } + return m; + } + /// The name of the clip playing, or null. public String getClipName() { return clip == null ? null : clip.getName(); @@ -371,6 +461,10 @@ public void update(float deltaSeconds) { } posed = false; } + if (layer != null) { + layerTime = advance(layer, layerTime + dt, true); + posed = false; + } if (!posed && visible) { pose(); } @@ -398,6 +492,10 @@ public void pose() { float w = fadeLength <= 0f ? 1f : Math.min(1f, fadeElapsed / fadeLength); blend(fadePose, pose, w, pose); } + if (layer != null) { + sample(layer, layerTime, layerPose); + blendMasked(pose, layerPose, layerWeight, layerMask); + } computeWorld(); int skinned = -1; for (int i = 0; i < parts.length; i++) { @@ -469,6 +567,35 @@ private static void blend(float[] a, float[] b, float w, float[] dst) { } } + /// `pose = lerp(pose, over, w)` on the nodes `mask` selects. + private static void blendMasked(float[] pose, float[] over, float w, boolean[] mask) { + for (int node = 0; node < mask.length; node++) { + if (!mask[node]) { + continue; + } + int n = node * POSE; + for (int i = 0; i < 3; i++) { + pose[n + i] += (over[n + i] - pose[n + i]) * w; + pose[n + 7 + i] += (over[n + 7 + i] - pose[n + 7 + i]) * w; + } + float dot = pose[n + 3] * over[n + 3] + pose[n + 4] * over[n + 4] + + pose[n + 5] * over[n + 5] + pose[n + 6] * over[n + 6]; + float sign = dot < 0f ? -1f : 1f; + float len = 0f; + for (int i = 3; i < 7; i++) { + float v = pose[n + i] + (sign * over[n + i] - pose[n + i]) * w; + pose[n + i] = v; + len += v * v; + } + len = (float) Math.sqrt(len); + if (len > 0f) { + for (int i = 3; i < 7; i++) { + pose[n + i] /= len; + } + } + } + } + private void computeWorld() { for (int n : order) { int b = n * POSE; diff --git a/CodenameOne/src/com/codename1/gaming/GameView.java b/CodenameOne/src/com/codename1/gaming/GameView.java index 4c90050b448..608458f7798 100644 --- a/CodenameOne/src/com/codename1/gaming/GameView.java +++ b/CodenameOne/src/com/codename1/gaming/GameView.java @@ -22,8 +22,10 @@ */ package com.codename1.gaming; +import com.codename1.gpu.Environment; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.Light; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.RenderView; import com.codename1.ui.Component; import com.codename1.ui.Form; @@ -139,6 +141,19 @@ public Light getLight() { return ((SpriteRenderer) getRenderer()).getLight(); } + /// The fog, hemisphere ambient and tone mapping the 3D scene is shaded in. + /// The default changes nothing. + public Environment getEnvironment() { + return ((SpriteRenderer) getRenderer()).getEnvironment(); + } + + /// Sets the full screen effects (bloom, colour grade, vignette, grain) + /// applied to a perspective scene, or null for none. The touch controls + /// are drawn after them and are never affected. + public void setPostProcess(PostProcess postProcess) { + ((SpriteRenderer) getRenderer()).setPostProcess(postProcess); + } + /// Adds a 3D `Model` to be drawn in the perspective camera alongside the sprites. /// Build models from `#onSetup(com.codename1.gpu.GraphicsDevice)`, where the GPU /// device is available. diff --git a/CodenameOne/src/com/codename1/gaming/SpriteRenderer.java b/CodenameOne/src/com/codename1/gaming/SpriteRenderer.java index d5e7f7c4be3..936136420f3 100644 --- a/CodenameOne/src/com/codename1/gaming/SpriteRenderer.java +++ b/CodenameOne/src/com/codename1/gaming/SpriteRenderer.java @@ -23,11 +23,13 @@ package com.codename1.gaming; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.Light; import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.Primitives; import com.codename1.gpu.RenderState; import com.codename1.gpu.Renderer; @@ -77,6 +79,11 @@ interface Updatable { private final GameCamera gameCamera = new GameCamera(); private final Camera camera = new Camera(); private final Light light = new Light(); + private final Environment environment = new Environment(); + // what the touch controls are drawn in: no fog, no tone mapping + private final Environment plain = new Environment(); + private PostProcess postProcess; + private long startTime; private final List models = new ArrayList(); private TouchControls controls; private Mesh quad; @@ -127,6 +134,23 @@ public Light getLight() { return light; } + /// The fog, hemisphere ambient and tone mapping the 3D scene is shaded in. + /// The default changes nothing. + public Environment getEnvironment() { + return environment; + } + + /// Sets the full screen effects applied to a perspective scene, or null for + /// none. The touch controls are drawn after them and are never affected. + public void setPostProcess(PostProcess postProcess) { + this.postProcess = postProcess; + } + + /// Returns the full screen effects, or null if there are none. + public PostProcess getPostProcess() { + return postProcess; + } + /// Adds a 3D model to be drawn (in perspective mode) alongside the sprites. public void addModel(Model model) { models.add(model); @@ -287,16 +311,41 @@ public void onFrame(GraphicsDevice device) { disposeReleased(device); } - device.clear(clearColor, true, true); // configure the GPU camera from the GameCamera each frame so a moving 3D // camera (or a switch between 2D and perspective) takes effect immediately. gameCamera.apply(camera, viewWidth, viewHeight); device.setCamera(camera); device.setLight(light); + device.setEnvironment(environment); boolean perspective = gameCamera.getMode() == GameCamera.MODE_PERSPECTIVE; + int modelCount = models.size(); + + // the shadow map is rendered from the light before the frame itself + if (perspective && light.isShadows() && device.beginShadowPass(light)) { + // A caster well outside the map cannot shade anything in it. The + // margin allows for the model's own size and its shadow's length. + float reach = light.getShadowExtent() * 1.5f + 12f; + float cx = light.getShadowCenterX(); + float cz = light.getShadowCenterZ(); + for (int i = 0; i < modelCount; i++) { + Model m = (Model) models.get(i); + if (m.isVisible() && m.getMesh() != null && m.getMaterial().isCastShadows()) { + float[] mm = m.modelMatrix(); + float dx = mm[12] - cx; + float dz = mm[14] - cz; + if (dx * dx + dz * dz > reach * reach) { + continue; + } + device.draw(m.getMesh(), m.getMaterial(), mm); + } + } + device.endShadowPass(); + } + boolean post = perspective && postProcess != null + && device.beginPostProcess(postProcess, viewWidth, viewHeight); + device.clear(clearColor, true, true); // opaque 3D models first (they write depth), then alpha-blended sprites. - int modelCount = models.size(); for (int i = 0; i < modelCount; i++) { Model m = (Model) models.get(i); if (m.isVisible() && m.getMesh() != null) { @@ -317,6 +366,13 @@ public void onFrame(GraphicsDevice device) { device.draw(quad, material, modelMatrix(s)); } + if (post) { + if (startTime == 0) { + startTime = now; + } + device.endPostProcess((now - startTime) / 1000f); + } + device.setEnvironment(plain); drawControls(device); } @@ -346,10 +402,14 @@ private void drawControls(GraphicsDevice device) { int bn = controls.getButtonCount(); for (int i = 0; i < bn; i++) { VirtualButton b = controls.getButton(i); - Image face = b.getLabel() == null ? discImage : labelDisc(b.getLabel()); int col = b.isPressed() ? brighten(b.getColor()) : b.getColor(); - screenQuad(device, face, b.getCenterX(), b.getCenterY(), + screenQuad(device, discImage, b.getCenterX(), b.getCenterY(), b.getRadius() * 2, b.getRadius() * 2, col); + if (b.getLabel() != null) { + // the label is a texture of its own, tinted by its own colour + screenQuad(device, labelImage(b.getLabel()), b.getCenterX(), b.getCenterY(), + b.getRadius() * 2, b.getRadius() * 2, b.getLabelColor()); + } } } @@ -365,38 +425,34 @@ private void screenQuad(GraphicsDevice device, Image image, float cx, float cy, device.draw(quad, material, model); } - /// A white disc with a centered label, cached per label string. The font is - /// scaled down until the label fits inside the disc, so multi-letter labels - /// (e.g. "Jump") are not clipped. - private Image labelDisc(String label) { + /// A button label in white on a transparent square, cached per label + /// string. The text is drawn at its natural size and then scaled to fit + /// the disc, so a label of any length is shown whole: stepping down + /// through the system sizes alone still clipped a word like "Jump" on a + /// dense screen, where even the small font is wider than the texture. + private Image labelImage(String label) { Image img = (Image) labelImages.get(label); if (img == null) { int size = 128; - float maxTextWidth = size * 0.74f; - img = circleTexture(size, 0f, 1f); + com.codename1.ui.Font f = com.codename1.ui.Font.createSystemFont( + com.codename1.ui.Font.FACE_SYSTEM, com.codename1.ui.Font.STYLE_BOLD, + com.codename1.ui.Font.SIZE_LARGE); + int tw = Math.max(1, f.stringWidth(label)); + int th = Math.max(1, f.getHeight()); + Image text = Image.createImage(tw + 4, th, 0); + com.codename1.ui.Graphics tg = text.getGraphics(); + tg.setAntiAliased(true); + tg.setAntiAliasedText(true); + tg.setFont(f); + tg.setColor(0xffffff); + tg.drawString(label, 2, 0); + float fit = Math.min(size * 0.74f / (tw + 4), size * 0.5f / th); + int dw = Math.max(1, (int) ((tw + 4) * fit)); + int dh = Math.max(1, (int) (th * fit)); + img = Image.createImage(size, size, 0); com.codename1.ui.Graphics g = img.getGraphics(); g.setAntiAliased(true); - g.setAntiAliasedText(true); - int[] fontSizes = { - com.codename1.ui.Font.SIZE_LARGE, - com.codename1.ui.Font.SIZE_MEDIUM, - com.codename1.ui.Font.SIZE_SMALL - }; - com.codename1.ui.Font f = null; - int tw = 0; - for (int fontSize : fontSizes) { - f = com.codename1.ui.Font.createSystemFont( - com.codename1.ui.Font.FACE_SYSTEM, com.codename1.ui.Font.STYLE_BOLD, - fontSize); - tw = f.stringWidth(label); - if (tw <= maxTextWidth) { - break; - } - } - g.setFont(f); - g.setColor(0x303030); - int th = f.getHeight(); - g.drawString(label, (size - tw) / 2, (size - th) / 2); + g.drawImage(text, (size - dw) / 2, (size - dh) / 2, dw, dh); labelImages.put(label, img); } return img; diff --git a/CodenameOne/src/com/codename1/gaming/TouchControls.java b/CodenameOne/src/com/codename1/gaming/TouchControls.java index db61a2ad970..92788190a7f 100644 --- a/CodenameOne/src/com/codename1/gaming/TouchControls.java +++ b/CodenameOne/src/com/codename1/gaming/TouchControls.java @@ -107,6 +107,17 @@ public VirtualButton addButton(int keyCode, float radius, int hAlign, int vAlign return b; } + /// Adds a button anchored to a corner of the view's safe area with its own + /// horizontal and vertical gaps, so a cluster of buttons can sit in an arc + /// around a bigger one rather than on the corner's diagonal. + public VirtualButton addButton(int keyCode, float radius, int hAlign, int vAlign, float marginX, + float marginY) { + VirtualButton b = new VirtualButton(keyCode, 0, 0, radius); + b.setAnchor(hAlign, vAlign, marginX, marginY); + buttons.add(b); + return b; + } + /// Repositions every anchored control within the given safe-area rectangle (in /// view-local pixels). Called by `GameView` each frame; absolutely positioned /// controls are left untouched. diff --git a/CodenameOne/src/com/codename1/gaming/VirtualButton.java b/CodenameOne/src/com/codename1/gaming/VirtualButton.java index 9a65595c948..b20aeb4dbfc 100644 --- a/CodenameOne/src/com/codename1/gaming/VirtualButton.java +++ b/CodenameOne/src/com/codename1/gaming/VirtualButton.java @@ -37,6 +37,7 @@ public class VirtualButton { private float centerY; private final float radius; private String label; + private int labelColor = 0xff303030; private int color = 0xc0ffffff; private boolean pressed; @@ -88,6 +89,18 @@ public VirtualButton setColor(int argb) { return this; } + /// The ARGB colour of the label (default a dark grey). The label is drawn + /// over the button in this colour and is not tinted by `setColor`, so a + /// light label stays readable on a dark, translucent button. + public VirtualButton setLabelColor(int argb) { + this.labelColor = argb; + return this; + } + + public int getLabelColor() { + return labelColor; + } + boolean contains(float px, float py) { float dx = px - centerX; float dy = py - centerY; diff --git a/CodenameOne/src/com/codename1/gpu/Environment.java b/CodenameOne/src/com/codename1/gpu/Environment.java new file mode 100644 index 00000000000..d0dd4c4d483 --- /dev/null +++ b/CodenameOne/src/com/codename1/gpu/Environment.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gpu; + +/// The atmosphere every draw is shaded in: fog, the sky and ground colours that +/// light a surface from above and below, and how the result is exposed and +/// tone mapped to the display. +/// +/// A default environment changes nothing: no fog, the light's own ambient +/// colour and no tone mapping, so a scene that never sets one renders exactly +/// as before. Set it with `GraphicsDevice.setEnvironment`, or +/// `GameView.getEnvironment()` in a game. +/// +/// ```java +/// GameView view = new GameView() { +/// protected void update(double deltaSeconds) { +/// } +/// }; +/// view.getEnvironment() +/// .setFog(0xffa9b3ba, 0.035f, 0.08f) +/// .setHemisphere(0xff9fb4c8, 0xff4a4036) +/// .setToneMapping(true) +/// .setExposure(1.1f); +/// ``` +/// +/// Fog is exponential squared in distance from the eye, thinned with height by +/// `heightFalloff`, so a valley fills with haze while a rooftop stays clear. It +/// applies to every material type except `Material.Type.SKYBOX`. +public final class Environment { + private int fogColor = 0xff808080; + private float fogDensity; + private float fogHeightFalloff; + private float fogBaseHeight; + private int skyColor; + private int groundColor; + private boolean hemisphere; + private boolean toneMapping; + private float exposure = 1f; + private boolean sky; + private int zenithColor; + private int horizonColor; + private int sunColor; + private float cloudCover; + private int version; + + /// Turns fog on. A density of 0 turns it off. + /// + /// #### Parameters + /// + /// - `color`: the colour of the fog, as ARGB + /// + /// - `density`: how thick it is, per world unit; a surface `1 / density` + /// units away is about 63% fog + /// + /// - `heightFalloff`: how quickly it thins with height above + /// `setFogBaseHeight`, per world unit; 0 is the same thickness everywhere + /// + /// #### Returns + /// + /// this environment for chaining + public Environment setFog(int color, float density, float heightFalloff) { + this.fogColor = color; + this.fogDensity = Math.max(0f, density); + this.fogHeightFalloff = Math.max(0f, heightFalloff); + version++; + return this; + } + + /// Sets the height at which fog is at its full density; it thins above it. + /// + /// #### Parameters + /// + /// - `y`: the world height + /// + /// #### Returns + /// + /// this environment for chaining + public Environment setFogBaseHeight(float y) { + this.fogBaseHeight = y; + version++; + return this; + } + + /// Lights surfaces with a sky colour from above and a ground colour from + /// below, blended by the surface normal, in place of the light's flat + /// ambient colour. It is what gives an outdoor scene its shape in shadow. + /// + /// #### Parameters + /// + /// - `sky`: the colour that lights upward facing surfaces, as ARGB + /// + /// - `ground`: the colour that lights downward facing surfaces, as ARGB + /// + /// #### Returns + /// + /// this environment for chaining + public Environment setHemisphere(int sky, int ground) { + this.skyColor = sky; + this.groundColor = ground; + this.hemisphere = true; + version++; + return this; + } + + /// Goes back to the light's own flat ambient colour. + /// + /// #### Returns + /// + /// this environment for chaining + public Environment clearHemisphere() { + this.hemisphere = false; + version++; + return this; + } + + /// Maps shaded colours through a filmic (ACES) curve, so bright light rolls + /// off instead of clipping to white. Lit and physically based materials + /// are affected; unlit ones, sprites and the sky are not. + /// + /// #### Parameters + /// + /// - `toneMapping`: true to tone map + /// + /// #### Returns + /// + /// this environment for chaining + public Environment setToneMapping(boolean toneMapping) { + this.toneMapping = toneMapping; + version++; + return this; + } + + /// Scales the light reaching the camera before it is tone mapped. + /// + /// #### Parameters + /// + /// - `exposure`: the multiplier, 1 for none + /// + /// #### Returns + /// + /// this environment for chaining + public Environment setExposure(float exposure) { + this.exposure = Math.max(0f, exposure); + version++; + return this; + } + + /// Paints `Material.Type.SKYBOX` surfaces as an open sky: a gradient from + /// the horizon to the zenith, the sun where the light comes from with a + /// glow around it, and soft clouds. Draw it on a large sphere around the + /// camera, seen from inside; it is computed per pixel from the view + /// direction, so it needs no texture and has no seams. + /// + /// #### Parameters + /// + /// - `zenith`: the colour straight up, as ARGB + /// + /// - `horizon`: the colour at the horizon, as ARGB; match it to the fog + /// colour so distant geometry melts into the sky + /// + /// - `sun`: the colour of the sun disc and its glow, as ARGB; black for none + /// + /// - `clouds`: how much of the sky is cloud, from 0 to 1 + /// + /// #### Returns + /// + /// this environment for chaining + public Environment setSky(int zenith, int horizon, int sun, float clouds) { + this.zenithColor = zenith; + this.horizonColor = horizon; + this.sunColor = sun; + this.cloudCover = Math.max(0f, Math.min(1f, clouds)); + this.sky = true; + version++; + return this; + } + + /// Goes back to drawing `Material.Type.SKYBOX` surfaces in their own colour + /// and texture. + /// + /// #### Returns + /// + /// this environment for chaining + public Environment clearSky() { + this.sky = false; + version++; + return this; + } + + /// Returns true if skybox surfaces are painted as an open sky. + public boolean isSky() { + return sky; + } + + /// Returns the sky colour straight up, as ARGB. + public int getZenithColor() { + return zenithColor; + } + + /// Returns the sky colour at the horizon, as ARGB. + public int getHorizonColor() { + return horizonColor; + } + + /// Returns the colour of the sun in the sky, as ARGB. + public int getSunColor() { + return sunColor; + } + + /// Returns how much of the sky is cloud, from 0 to 1. + public float getCloudCover() { + return cloudCover; + } + + /// Returns the fog colour as ARGB. + public int getFogColor() { + return fogColor; + } + + /// Returns the fog density per world unit, 0 when there is no fog. + public float getFogDensity() { + return fogDensity; + } + + /// Returns how quickly fog thins with height, per world unit. + public float getFogHeightFalloff() { + return fogHeightFalloff; + } + + /// Returns the height at which fog is at its full density. + public float getFogBaseHeight() { + return fogBaseHeight; + } + + /// Returns true if fog is on. + public boolean isFog() { + return fogDensity > 0f; + } + + /// Returns true if surfaces are lit by the sky and ground colours. + public boolean isHemisphere() { + return hemisphere; + } + + /// Returns the sky colour as ARGB. + public int getSkyColor() { + return skyColor; + } + + /// Returns the ground colour as ARGB. + public int getGroundColor() { + return groundColor; + } + + /// Returns true if shaded colours are tone mapped. + public boolean isToneMapping() { + return toneMapping; + } + + /// Returns the exposure multiplier. + public float getExposure() { + return exposure; + } + + /// Returns a number that changes whenever a setting does, so a backend can + /// tell that a shader variant or a uniform it cached is stale. + public int getVersion() { + return version; + } + + /// Returns the part of the shader variant key that depends on this + /// environment: whether fog, hemisphere lighting, tone mapping and the + /// open sky are on. + /// Values such as the fog colour are uniforms and are not part of it. + public String getShaderKey() { + return (isFog() ? "F" : "f") + (hemisphere ? "H" : "h") + (toneMapping ? "T" : "t") + + (sky ? "K" : "k"); + } +} diff --git a/CodenameOne/src/com/codename1/gpu/GltfLoader.java b/CodenameOne/src/com/codename1/gpu/GltfLoader.java index b04238631f5..dd7a749fc5d 100644 --- a/CodenameOne/src/com/codename1/gpu/GltfLoader.java +++ b/CodenameOne/src/com/codename1/gpu/GltfLoader.java @@ -362,6 +362,16 @@ private static GltfScene buildScene(Map root, byte[] bin) { Object name = m.get("name"); materials[i] = new GltfScene.MaterialInfo(name == null ? "" : name.toString(), argb, image, isTrue(m.get("doubleSided")), "BLEND".equals(m.get("alphaMode"))); + if (pbr != null && pbr.get("metallicFactor") instanceof Number) { + materials[i].metallic = clamp01(((Number) pbr.get("metallicFactor")).floatValue()); + } + if (pbr != null && pbr.get("roughnessFactor") instanceof Number) { + materials[i].roughness = clamp01(((Number) pbr.get("roughnessFactor")).floatValue()); + } + float[] em = floats(m.get("emissiveFactor"), new float[]{0f, 0f, 0f}); + if (em.length >= 3) { + materials[i].emissive = 0xff000000 | (channel(em[0]) << 16) | (channel(em[1]) << 8) | channel(em[2]); + } } List skinList = list(root, "skins"); @@ -504,6 +514,10 @@ private static boolean isTrue(Object o) { return Boolean.TRUE.equals(o) || "true".equals(o); } + private static float clamp01(float f) { + return f < 0f ? 0f : f > 1f ? 1f : f; + } + private static int channel(float f) { int v = (int) (f * 255f + 0.5f); return v < 0 ? 0 : v > 255 ? 255 : v; diff --git a/CodenameOne/src/com/codename1/gpu/GltfScene.java b/CodenameOne/src/com/codename1/gpu/GltfScene.java index 3ad0d6693db..8e5599235c3 100644 --- a/CodenameOne/src/com/codename1/gpu/GltfScene.java +++ b/CodenameOne/src/com/codename1/gpu/GltfScene.java @@ -295,6 +295,9 @@ public static final class MaterialInfo { final int image; final boolean doubleSided; final boolean blend; + float metallic = 1f; + float roughness = 1f; + int emissive = 0xff000000; MaterialInfo(String name, int baseColor, int image, boolean doubleSided, boolean blend) { this.name = name; @@ -304,6 +307,21 @@ public static final class MaterialInfo { this.blend = blend; } + /// The metallic factor, from 0 to 1; glTF's default is 1. + public float getMetallic() { + return metallic; + } + + /// The roughness factor, from 0 to 1; glTF's default is 1. + public float getRoughness() { + return roughness; + } + + /// The emissive factor as an opaque ARGB colour, black for none. + public int getEmissive() { + return emissive; + } + /// The material's name, or an empty string. public String getName() { return name; diff --git a/CodenameOne/src/com/codename1/gpu/GpuCapabilities.java b/CodenameOne/src/com/codename1/gpu/GpuCapabilities.java index b477b986efd..f43e6b61fa2 100644 --- a/CodenameOne/src/com/codename1/gpu/GpuCapabilities.java +++ b/CodenameOne/src/com/codename1/gpu/GpuCapabilities.java @@ -6,6 +6,19 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.gpu; @@ -20,6 +33,8 @@ public final class GpuCapabilities { private final boolean depthTextureSupported; private final boolean intIndicesSupported; private final String rendererName; + private boolean shadowsSupported; + private boolean postProcessSupported; /// Constructs a capabilities descriptor. Intended to be called by platform /// backends only. @@ -48,6 +63,47 @@ public GpuCapabilities(int maxTextureSize, int maxVertexAttributes, this.rendererName = rendererName; } + /// Creates a capability descriptor that also says whether the device + /// renders shadow maps and full screen post processing. + /// + /// #### Parameters + /// + /// - `maxTextureSize`: the maximum texture dimension in pixels + /// + /// - `maxVertexAttributes`: the maximum number of vertex attributes + /// + /// - `shaderLevel3`: true if GLSL ES 3 / Metal 2 class features exist + /// + /// - `depthTextureSupported`: true if depth textures may be sampled + /// + /// - `intIndicesSupported`: true if 32 bit element indices are supported + /// + /// - `rendererName`: a human readable backend/renderer description + /// + /// - `shadowsSupported`: true if `GraphicsDevice.beginShadowPass` works + /// + /// - `postProcessSupported`: true if `GraphicsDevice.beginPostProcess` works + public GpuCapabilities(int maxTextureSize, int maxVertexAttributes, + boolean shaderLevel3, boolean depthTextureSupported, + boolean intIndicesSupported, String rendererName, + boolean shadowsSupported, boolean postProcessSupported) { + this(maxTextureSize, maxVertexAttributes, shaderLevel3, depthTextureSupported, + intIndicesSupported, rendererName); + this.shadowsSupported = shadowsSupported; + this.postProcessSupported = postProcessSupported; + } + + /// Returns true if the device renders shadow maps for a light with + /// shadows on. + public boolean isShadowsSupported() { + return shadowsSupported; + } + + /// Returns true if the device applies a `PostProcess` to the frame. + public boolean isPostProcessSupported() { + return postProcessSupported; + } + /// Returns the maximum supported texture edge length in pixels. public int getMaxTextureSize() { return maxTextureSize; diff --git a/CodenameOne/src/com/codename1/gpu/GraphicsDevice.java b/CodenameOne/src/com/codename1/gpu/GraphicsDevice.java index 628a274b9a9..8944b3fe544 100644 --- a/CodenameOne/src/com/codename1/gpu/GraphicsDevice.java +++ b/CodenameOne/src/com/codename1/gpu/GraphicsDevice.java @@ -6,6 +6,19 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.gpu; @@ -22,7 +35,9 @@ /// platform shader, uploads any dirty buffers and issues the draw call. public abstract class GraphicsDevice { private Camera camera; + private boolean shadowMapCurrent; private Light light = new Light(); + private Environment environment = new Environment(); /// Returns the capabilities and limits of the underlying GPU. public abstract GpuCapabilities getCapabilities(); @@ -99,11 +114,28 @@ public IndexBuffer createIndexBuffer(int indexCount) { /// Sets the active camera supplying the view and projection matrices used by /// subsequent draws. /// + /// Setting the camera starts a new frame as far as shadows go: a shadow map + /// rendered before it is not used again, so set the camera first and then + /// run `beginShadowPass`. + /// /// #### Parameters /// /// - `camera`: the camera public void setCamera(Camera camera) { this.camera = camera; + shadowMapCurrent = false; + } + + /// For a backend: true once a shadow pass has ended since the camera was + /// last set, so the map belongs to this frame. A frame that skipped the pass + /// must not sample the one before it. + protected final boolean isShadowMapCurrent() { + return shadowMapCurrent; + } + + /// For a backend: records that a shadow pass of this frame has ended. + protected final void shadowMapRendered() { + shadowMapCurrent = true; } /// Returns the active camera, or null if none was set. @@ -125,6 +157,81 @@ public Light getLight() { return light; } + /// Sets the environment (fog, hemisphere ambient, tone mapping) used by + /// subsequent draws. + /// + /// #### Parameters + /// + /// - `environment`: the environment; null restores the default, which + /// changes nothing + public void setEnvironment(Environment environment) { + this.environment = environment == null ? new Environment() : environment; + } + + /// Returns the active environment. + public Environment getEnvironment() { + return environment; + } + + /// Starts rendering the light's shadow map. Until `endShadowPass`, `draw` + /// writes only depth as seen from the light, and skips materials that do + /// not cast shadows. Draws after `endShadowPass` that receive shadows + /// sample the map. + /// + /// The default implementation supports no shadows and returns false; the + /// caller then skips the pass and draws as usual. + /// + /// Render the shadow pass before `beginPostProcess`, never inside it: both + /// passes return to the target that was active when they began, so a + /// shadow pass started offscreen would hand the post process its own + /// image to draw into. + /// + /// #### Parameters + /// + /// - `light`: the light whose shadows to render; it must have shadows on + /// + /// #### Returns + /// + /// true if the pass started and the shadow casters should be drawn now + public boolean beginShadowPass(Light light) { + return false; + } + + /// Ends the shadow pass started by `beginShadowPass`, and restores the + /// viewport and target that were active before it. + public void endShadowPass() { + } + + /// Redirects drawing into an offscreen image so `endPostProcess` can apply + /// `effects` to it on its way to the screen. + /// + /// The default implementation supports no post processing and returns + /// false; the caller then draws straight to the screen. + /// + /// #### Parameters + /// + /// - `effects`: the effects to apply + /// + /// - `width`: the width of the frame in pixels + /// + /// - `height`: the height of the frame in pixels + /// + /// #### Returns + /// + /// true if drawing now goes offscreen + public boolean beginPostProcess(PostProcess effects, int width, int height) { + return false; + } + + /// Applies the effects passed to `beginPostProcess` and draws the result + /// to the screen. Drawing goes to the screen again afterwards. + /// + /// #### Parameters + /// + /// - `timeSeconds`: a running clock, which animates the film grain + public void endPostProcess(float timeSeconds) { + } + /// Draws a mesh with the supplied material and model matrix. The device /// composes `camera.getViewProjection() * modelMatrix`, binds the generated /// shader for the material, applies the material render state and issues the diff --git a/CodenameOne/src/com/codename1/gpu/Light.java b/CodenameOne/src/com/codename1/gpu/Light.java index 5b826f3d4f0..ccd49caad77 100644 --- a/CodenameOne/src/com/codename1/gpu/Light.java +++ b/CodenameOne/src/com/codename1/gpu/Light.java @@ -6,6 +6,19 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.gpu; @@ -19,6 +32,14 @@ public final class Light { private float dirZ = -0.5f; private int color = 0xffffffff; private int ambientColor = 0xff404040; + private boolean shadows; + private int shadowMapSize = 1024; + private float shadowExtent = 20f; + private float shadowCenterX; + private float shadowCenterY; + private float shadowCenterZ; + private float shadowBias = 0.0015f; + private float shadowStrength = 0.8f; /// Sets the world space direction the light travels. /// @@ -92,4 +113,151 @@ public Light setAmbientColor(int argb) { this.ambientColor = argb; return this; } + + /// Turns shadow casting on or off. With shadows on, a `GraphicsDevice` + /// that supports them (`GpuCapabilities.isShadowsSupported`) renders the + /// scene from the light into a shadow map before each frame, and lit + /// materials that receive shadows are darkened where it is blocked. + /// + /// The map covers a square `extent` units either side of the shadow centre, + /// seen along the light's direction; move the centre with the camera + /// (`setShadowCenter`) and keep the extent as small as the view allows, + /// since the map's resolution is spread over it. + /// + /// #### Parameters + /// + /// - `shadows`: true to cast shadows + /// + /// #### Returns + /// + /// this light for chaining + public Light setShadows(boolean shadows) { + this.shadows = shadows; + return this; + } + + /// Returns true if the light casts shadows. + public boolean isShadows() { + return shadows; + } + + /// Sets the area and resolution of the shadow map. + /// + /// #### Parameters + /// + /// - `size`: the width and height of the map in texels; clamped to 256 to 4096 + /// + /// - `extent`: the half width of the square it covers, in world units + /// + /// #### Returns + /// + /// this light for chaining + public Light setShadowMap(int size, float extent) { + this.shadowMapSize = Math.max(256, Math.min(4096, size)); + this.shadowExtent = Math.max(0.01f, extent); + return this; + } + + /// Returns the shadow map's width and height in texels. + public int getShadowMapSize() { + return shadowMapSize; + } + + /// Returns the x of the point the shadow map is centred on. + public float getShadowCenterX() { + return shadowCenterX; + } + + /// Returns the z of the point the shadow map is centred on. + public float getShadowCenterZ() { + return shadowCenterZ; + } + + /// Returns the half width of the area the shadow map covers. + public float getShadowExtent() { + return shadowExtent; + } + + /// Centres the shadow map on a world point, normally the one the camera + /// looks at. + /// + /// #### Parameters + /// + /// - `x`: the x coordinate + /// + /// - `y`: the y coordinate + /// + /// - `z`: the z coordinate + /// + /// #### Returns + /// + /// this light for chaining + public Light setShadowCenter(float x, float y, float z) { + this.shadowCenterX = x; + this.shadowCenterY = y; + this.shadowCenterZ = z; + return this; + } + + /// Sets how dark a shadow is and how far a surface must be behind the + /// shadow map before it counts as shadowed. + /// + /// #### Parameters + /// + /// - `strength`: 0 for no darkening, 1 for no direct light at all + /// + /// - `bias`: the depth offset in the map's 0..1 range; raise it if + /// surfaces shadow themselves in stripes ("acne"), lower it if shadows + /// detach from the objects that cast them + /// + /// #### Returns + /// + /// this light for chaining + public Light setShadowStyle(float strength, float bias) { + this.shadowStrength = Math.max(0f, Math.min(1f, strength)); + this.shadowBias = Math.max(0f, bias); + return this; + } + + /// Returns how dark a shadow is, from 0 to 1. + public float getShadowStrength() { + return shadowStrength; + } + + /// Returns the shadow depth bias. + public float getShadowBias() { + return shadowBias; + } + + /// Computes the matrix that takes a world position to the shadow map's + /// clip space: an orthographic view along the light's direction, centred + /// on the shadow centre. Every backend uses this one, so shadows land in + /// the same place on each. + /// + /// #### Parameters + /// + /// - `out`: a 16 element array that receives the column-major matrix + public void computeShadowMatrix(float[] out) { + float len = (float) Math.sqrt(dirX * dirX + dirY * dirY + dirZ * dirZ); + float dx = len > 0 ? dirX / len : 0; + float dy = len > 0 ? dirY / len : -1; + float dz = len > 0 ? dirZ / len : 0; + float back = shadowExtent * 2f; + float upX = 0; + float upY = 1; + float upZ = 0; + if (Math.abs(dy) > 0.99f) { + upY = 0; + upZ = 1; + } + float[] view = Matrix4.lookAt(shadowCenterX - dx * back, shadowCenterY - dy * back, + shadowCenterZ - dz * back, shadowCenterX, shadowCenterY, shadowCenterZ, upX, upY, upZ); + // Snap the centre to whole texels in light space, so the map does not + // shimmer as the camera moves. + float texel = shadowExtent * 2f / shadowMapSize; + view[12] = (float) Math.floor(view[12] / texel) * texel; + view[13] = (float) Math.floor(view[13] / texel) * texel; + float[] proj = Matrix4.ortho(-shadowExtent, shadowExtent, -shadowExtent, shadowExtent, 0.05f, back * 2f); + Matrix4.multiply(proj, view, out); + } } diff --git a/CodenameOne/src/com/codename1/gpu/Material.java b/CodenameOne/src/com/codename1/gpu/Material.java index 4e8553c2bc0..8100ebd01be 100644 --- a/CodenameOne/src/com/codename1/gpu/Material.java +++ b/CodenameOne/src/com/codename1/gpu/Material.java @@ -6,6 +6,19 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.gpu; @@ -26,6 +39,11 @@ public enum Type { /// Diffuse and specular (Blinn-Phong) lighting using a single /// directional light and the `shininess` property. PHONG, + /// Physically based shading (metallic/roughness with a GGX specular + /// lobe, the model glTF describes) using the directional light, the + /// environment's hemisphere ambient and, when the light casts them, + /// shadows. See `setMetallic`, `setRoughness` and `setEmissive`. + PBR, /// Unlit shading intended for screen aligned sprites and billboards. SPRITE, /// Unlit shading sampling a background cube/sky texture, rendered behind @@ -38,6 +56,11 @@ public enum Type { private Texture texture; private float shininess = 32.0f; private RenderState renderState = RenderState.opaque(); + private float metallic; + private float roughness = 0.8f; + private int emissive = 0xff000000; + private boolean receiveShadows = true; + private boolean castShadows = true; /// Creates an unlit white material. public Material() { @@ -147,6 +170,114 @@ public Material setRenderState(RenderState renderState) { return this; } + /// Returns how metallic a `Type.PBR` surface is, from 0 (a dielectric such + /// as paint, plastic or wood) to 1 (bare metal). + public float getMetallic() { + return metallic; + } + + /// Sets how metallic a `Type.PBR` surface is. + /// + /// #### Parameters + /// + /// - `metallic`: 0 for a dielectric, 1 for metal + /// + /// #### Returns + /// + /// this material for chaining + public Material setMetallic(float metallic) { + this.metallic = Math.max(0f, Math.min(1f, metallic)); + return this; + } + + /// Returns how rough a `Type.PBR` surface is, from 0 (mirror) to 1 (chalk). + public float getRoughness() { + return roughness; + } + + /// Sets how rough a `Type.PBR` surface is. Rough surfaces spread their + /// highlight wide and dim; smooth ones make it small and bright. + /// + /// #### Parameters + /// + /// - `roughness`: 0 for a mirror, 1 for fully diffuse + /// + /// #### Returns + /// + /// this material for chaining + public Material setRoughness(float roughness) { + this.roughness = Math.max(0.04f, Math.min(1f, roughness)); + return this; + } + + /// Returns the light the surface gives off by itself, as RGB in an ARGB + /// int; black for none. + public int getEmissive() { + return emissive; + } + + /// Sets the light the surface gives off by itself: a lamp, a screen, wet + /// paint catching the sun. Emissive colour is added after lighting and is + /// what a bloom post effect picks up. Used by `Type.PBR`, `Type.LAMBERT` + /// and `Type.PHONG`. + /// + /// #### Parameters + /// + /// - `argb`: the emitted colour; black for none + /// + /// #### Returns + /// + /// this material for chaining + public Material setEmissive(int argb) { + this.emissive = argb | 0xff000000; + return this; + } + + /// Returns true if the surface is darkened by shadows the light casts. + public boolean isReceiveShadows() { + return receiveShadows; + } + + /// Sets whether the surface is darkened by the light's shadows. A ground + /// plane receives them; a particle or a muzzle flash should not. + /// + /// #### Parameters + /// + /// - `receiveShadows`: true to receive shadows + /// + /// #### Returns + /// + /// this material for chaining + public Material setReceiveShadows(boolean receiveShadows) { + this.receiveShadows = receiveShadows; + return this; + } + + /// Returns true if geometry drawn with this material casts a shadow. + public boolean isCastShadows() { + return castShadows; + } + + /// Sets whether geometry drawn with this material casts a shadow. + /// Transparent and unlit effects normally should not. + /// + /// #### Parameters + /// + /// - `castShadows`: true to cast shadows + /// + /// #### Returns + /// + /// this material for chaining + public Material setCastShadows(boolean castShadows) { + this.castShadows = castShadows; + return this; + } + + /// Returns true if this material is lit by the directional light. + public boolean isLit() { + return type == Type.LAMBERT || type == Type.PHONG || type == Type.PBR; + } + /// Returns a stable string identifying the shader variant required by this /// material. Backends use it together with the mesh `VertexFormat` to cache /// generated and compiled shader programs. The key intentionally depends @@ -157,6 +288,7 @@ public Material setRenderState(RenderState renderState) { /// /// a shader variant cache key public String getShaderKey() { - return type.name() + (texture != null ? "|tex" : "|notex"); + return type.name() + (texture != null ? "|tex" : "|notex") + + (receiveShadows ? "|rs" : "|nrs") + ((emissive & 0xffffff) != 0 ? "|em" : ""); } } diff --git a/CodenameOne/src/com/codename1/gpu/PostProcess.java b/CodenameOne/src/com/codename1/gpu/PostProcess.java new file mode 100644 index 00000000000..5a5a1a2e81a --- /dev/null +++ b/CodenameOne/src/com/codename1/gpu/PostProcess.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gpu; + +/// Full screen effects applied to a rendered frame: bloom, a colour grade, +/// vignette and film grain. It is what turns a correctly lit scene into one +/// that looks shot through a lens. +/// +/// The scene is drawn into an offscreen image, the effects are applied, and +/// the result is drawn to the screen. Touch controls are drawn afterwards and +/// are never graded. A device that cannot render offscreen +/// (`GpuCapabilities.isPostProcessSupported` is false) draws the scene +/// directly and ignores this. +/// +/// ```java +/// GameView view = new GameView() { +/// protected void update(double deltaSeconds) { +/// } +/// }; +/// view.setPostProcess(new PostProcess() +/// .setBloom(0.75f, 0.6f) +/// .setGrade(1.08f, 0.9f, 0xff0e1a24, 0xfffff1dc) +/// .setVignette(0.35f) +/// .setGrain(0.04f)); +/// ``` +/// +/// All effects start off, so `new PostProcess()` changes nothing but the cost +/// of the extra pass. +public final class PostProcess { + private float bloomThreshold = 1f; + private float bloomIntensity; + private float contrast = 1f; + private float saturation = 1f; + private int shadowTint = 0xff000000; + private int highlightTint = 0xffffffff; + private float vignette; + private float grain; + + /// Makes bright parts of the frame glow into their surroundings. + /// + /// #### Parameters + /// + /// - `threshold`: how bright a pixel must be to glow, from 0 to 1 of its + /// brightest channel; lower glows more of the frame + /// + /// - `intensity`: how strong the glow is; 0 turns bloom off + /// + /// #### Returns + /// + /// this post process for chaining + public PostProcess setBloom(float threshold, float intensity) { + this.bloomThreshold = Math.max(0f, Math.min(1f, threshold)); + this.bloomIntensity = Math.max(0f, intensity); + return this; + } + + /// Grades the colour of the frame. + /// + /// #### Parameters + /// + /// - `contrast`: 1 leaves it, above 1 deepens shadows and brightens highlights + /// + /// - `saturation`: 1 leaves it, 0 is greyscale + /// + /// - `shadowTint`: the colour the darkest tones are lifted toward, as ARGB; + /// black leaves them + /// + /// - `highlightTint`: the colour the brightest tones are multiplied by, as + /// ARGB; white leaves them + /// + /// #### Returns + /// + /// this post process for chaining + public PostProcess setGrade(float contrast, float saturation, int shadowTint, int highlightTint) { + this.contrast = Math.max(0f, contrast); + this.saturation = Math.max(0f, saturation); + this.shadowTint = shadowTint; + this.highlightTint = highlightTint; + return this; + } + + /// Darkens the corners of the frame. + /// + /// #### Parameters + /// + /// - `amount`: 0 for none, 1 for black corners + /// + /// #### Returns + /// + /// this post process for chaining + public PostProcess setVignette(float amount) { + this.vignette = Math.max(0f, Math.min(1f, amount)); + return this; + } + + /// Adds moving film grain. + /// + /// #### Parameters + /// + /// - `amount`: 0 for none; 0.03 to 0.06 reads as film + /// + /// #### Returns + /// + /// this post process for chaining + public PostProcess setGrain(float amount) { + this.grain = Math.max(0f, Math.min(1f, amount)); + return this; + } + + /// Returns the bloom brightness threshold. + public float getBloomThreshold() { + return bloomThreshold; + } + + /// Returns the bloom intensity, 0 when bloom is off. + public float getBloomIntensity() { + return bloomIntensity; + } + + /// Returns true if bloom is on. + public boolean isBloom() { + return bloomIntensity > 0f; + } + + /// Returns the contrast multiplier. + public float getContrast() { + return contrast; + } + + /// Returns the saturation multiplier. + public float getSaturation() { + return saturation; + } + + /// Returns the shadow tint as ARGB. + public int getShadowTint() { + return shadowTint; + } + + /// Returns the highlight tint as ARGB. + public int getHighlightTint() { + return highlightTint; + } + + /// Returns the vignette amount. + public float getVignette() { + return vignette; + } + + /// Returns the grain amount. + public float getGrain() { + return grain; + } +} diff --git a/CodenameOne/src/com/codename1/impl/gpu/GlslShaderGenerator.java b/CodenameOne/src/com/codename1/impl/gpu/GlslShaderGenerator.java index 16f285d9e03..9186a1f9bac 100644 --- a/CodenameOne/src/com/codename1/impl/gpu/GlslShaderGenerator.java +++ b/CodenameOne/src/com/codename1/impl/gpu/GlslShaderGenerator.java @@ -6,9 +6,23 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.gpu; +import com.codename1.gpu.Environment; import com.codename1.gpu.Material; import com.codename1.gpu.VertexAttribute; import com.codename1.gpu.VertexFormat; @@ -27,6 +41,20 @@ /// - uniforms: `u_mvp`, `u_model`, `u_normalMatrix` (mat4); `u_color` (vec4); /// `u_texture` (sampler2D); `u_lightDir`, `u_lightColor`, `u_ambient`, /// `u_eye` (vec3); `u_shininess` (float) +/// - environment: `u_fogColor` (vec3), `u_fog` (vec3: density, height falloff, +/// base height), `u_sky`, `u_ground` (vec3), `u_exposure` (float) +/// - physically based: `u_pbr` (vec2: metallic, roughness), `u_emissive` (vec3) +/// - shadows: `u_shadowMatrix` (mat4), `u_shadowMap` (sampler2D, texture unit 1), +/// `u_shadow` (vec3: bias, strength, texel size) +/// +/// Shadow maps store depth packed into RGBA8 (`packDepth`), so they need no +/// depth texture or float render target and work on every GLES2 and WebGL1 +/// device. +/// +/// The post processing programs (`postVertex` with `brightFragment`, +/// `blurFragment` and `compositeFragment`) draw a full screen quad whose +/// `a_position` is in clip space and read `u_scene` (unit 0) and `u_bloom` +/// (unit 1). public final class GlslShaderGenerator { /// The attribute name bound to vertex positions. public static final String A_POSITION = "a_position"; @@ -35,10 +63,22 @@ public final class GlslShaderGenerator { /// The attribute name bound to vertex texture coordinates. public static final String A_TEXCOORD = "a_texcoord"; + private static final String HIGHP = "#ifdef GL_FRAGMENT_PRECISION_HIGH\n" + + "precision highp float;\n#else\nprecision mediump float;\n#endif\n"; + + private static final String PACK = "vec4 packDepth(float d) {\n" + + " vec4 e = fract(d * vec4(1.0, 255.0, 65025.0, 16581375.0));\n" + + " e -= e.yzww * vec4(1.0 / 255.0, 1.0 / 255.0, 1.0 / 255.0, 0.0);\n" + + " return e;\n}\n"; + + private static final String UNPACK = "float unpackDepth(vec4 c) {\n" + + " return dot(c, vec4(1.0, 1.0 / 255.0, 1.0 / 65025.0, 1.0 / 16581375.0));\n}\n"; + private final String vertexSource; private final String fragmentSource; - /// Generates the shader pair for a material and vertex layout. + /// Generates the shader pair for a material and vertex layout in the + /// default environment, without shadows. /// /// #### Parameters /// @@ -46,42 +86,109 @@ public final class GlslShaderGenerator { /// /// - `format`: the mesh vertex layout public GlslShaderGenerator(Material material, VertexFormat format) { + this(material, format, null, false); + } + + /// Generates the shader pair for a material and vertex layout. + /// + /// #### Parameters + /// + /// - `material`: the material describing the lighting model and inputs + /// + /// - `format`: the mesh vertex layout + /// + /// - `environment`: the fog, ambient and tone mapping in effect, or null + /// for the default + /// + /// - `shadowed`: true if a shadow map is bound and the material receives it + public GlslShaderGenerator(Material material, VertexFormat format, Environment environment, + boolean shadowed) { boolean hasNormal = format.findByUsage(VertexAttribute.Usage.NORMAL) != null; boolean hasTexcoord = format.findByUsage(VertexAttribute.Usage.TEXCOORD) != null; - boolean textured = material.getTexture() != null && hasTexcoord; + Features f = new Features(); + f.textured = material.getTexture() != null && hasTexcoord; Material.Type type = material.getType(); - boolean lit = (type == Material.Type.LAMBERT || type == Material.Type.PHONG) && hasNormal; - boolean phong = type == Material.Type.PHONG && hasNormal; + f.lit = material.isLit() && hasNormal; + f.phong = type == Material.Type.PHONG && hasNormal; + f.pbr = type == Material.Type.PBR && hasNormal; + boolean sky = type == Material.Type.SKYBOX; + f.fog = environment != null && environment.isFog() && !sky; + f.sky = sky && environment != null && environment.isSky(); + f.hemisphere = f.lit && environment != null && environment.isHemisphere(); + f.toneMap = f.lit && environment != null && environment.isToneMapping(); + f.shadow = f.lit && shadowed && material.isReceiveShadows(); + f.emissive = f.lit && (material.getEmissive() & 0xffffff) != 0; + this.vertexSource = buildVertex(f); + this.fragmentSource = buildFragment(f); + } - this.vertexSource = buildVertex(lit, textured); - this.fragmentSource = buildFragment(lit, phong, textured); + /// Returns the part of a backend's program cache key that this generator + /// adds beyond `Material.getShaderKey`. + /// + /// #### Parameters + /// + /// - `environment`: the environment in effect, or null + /// + /// - `shadowed`: true if a shadow map is bound + /// + /// #### Returns + /// + /// the key suffix + public static String variantKey(Environment environment, boolean shadowed) { + return (environment == null ? "fht" : environment.getShaderKey()) + (shadowed ? "S" : "s"); + } + + private static final class Features { + boolean textured; + boolean lit; + boolean phong; + boolean pbr; + boolean fog; + boolean hemisphere; + boolean toneMap; + boolean shadow; + boolean emissive; + boolean sky; } - private static String buildVertex(boolean lit, boolean textured) { + private static String buildVertex(Features f) { + boolean world = f.lit || f.fog || f.sky; StringBuilder sb = new StringBuilder(); sb.append("attribute vec3 ").append(A_POSITION).append(";\n"); - if (lit) { + if (f.lit) { sb.append("attribute vec3 ").append(A_NORMAL).append(";\n"); } - if (textured) { + if (f.textured) { sb.append("attribute vec2 ").append(A_TEXCOORD).append(";\n"); } sb.append("uniform mat4 u_mvp;\n"); - if (lit) { + if (world) { sb.append("uniform mat4 u_model;\n"); + sb.append("varying vec3 v_worldPos;\n"); + } + if (f.lit) { sb.append("uniform mat4 u_normalMatrix;\n"); sb.append("varying vec3 v_normal;\n"); - sb.append("varying vec3 v_worldPos;\n"); } - if (textured) { + if (f.shadow) { + sb.append("uniform mat4 u_shadowMatrix;\n"); + sb.append("varying vec4 v_shadowPos;\n"); + } + if (f.textured) { sb.append("varying vec2 v_texcoord;\n"); } sb.append("void main() {\n"); - if (lit) { + if (world) { + sb.append(" vec4 world = u_model * vec4(").append(A_POSITION).append(", 1.0);\n"); + sb.append(" v_worldPos = world.xyz;\n"); + } + if (f.lit) { sb.append(" v_normal = (u_normalMatrix * vec4(").append(A_NORMAL).append(", 0.0)).xyz;\n"); - sb.append(" v_worldPos = (u_model * vec4(").append(A_POSITION).append(", 1.0)).xyz;\n"); } - if (textured) { + if (f.shadow) { + sb.append(" v_shadowPos = u_shadowMatrix * world;\n"); + } + if (f.textured) { sb.append(" v_texcoord = ").append(A_TEXCOORD).append(";\n"); } sb.append(" gl_Position = u_mvp * vec4(").append(A_POSITION).append(", 1.0);\n"); @@ -89,52 +196,311 @@ private static String buildVertex(boolean lit, boolean textured) { return sb.toString(); } - private static String buildFragment(boolean lit, boolean phong, boolean textured) { + private static String buildFragment(Features f) { + boolean world = f.lit || f.fog || f.sky; StringBuilder sb = new StringBuilder(); - sb.append("precision mediump float;\n"); + sb.append(f.shadow ? HIGHP : "precision mediump float;\n"); sb.append("uniform vec4 u_color;\n"); - if (textured) { + if (f.textured) { sb.append("uniform sampler2D u_texture;\n"); sb.append("varying vec2 v_texcoord;\n"); } - if (lit) { + if (world) { + sb.append("varying vec3 v_worldPos;\n"); + sb.append("uniform vec3 u_eye;\n"); + } + if (f.lit) { sb.append("uniform vec3 u_lightDir;\n"); sb.append("uniform vec3 u_lightColor;\n"); sb.append("uniform vec3 u_ambient;\n"); sb.append("varying vec3 v_normal;\n"); - sb.append("varying vec3 v_worldPos;\n"); } - if (phong) { - sb.append("uniform vec3 u_eye;\n"); + if (f.phong) { sb.append("uniform float u_shininess;\n"); } + if (f.pbr) { + sb.append("uniform vec2 u_pbr;\n"); + } + if (f.emissive) { + sb.append("uniform vec3 u_emissive;\n"); + } + if (f.hemisphere) { + sb.append("uniform vec3 u_sky;\n"); + sb.append("uniform vec3 u_ground;\n"); + } + if (f.toneMap) { + sb.append("uniform float u_exposure;\n"); + } + if (f.fog) { + sb.append("uniform vec3 u_fogColor;\n"); + sb.append("uniform vec3 u_fog;\n"); + } + if (f.shadow) { + sb.append("uniform sampler2D u_shadowMap;\n"); + sb.append("uniform vec3 u_shadow;\n"); + sb.append("varying vec4 v_shadowPos;\n"); + sb.append(UNPACK); + sb.append("float shadowAt(vec3 n, vec3 l) {\n"); + sb.append(" vec3 p = v_shadowPos.xyz / v_shadowPos.w * 0.5 + 0.5;\n"); + sb.append(" if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) {\n"); + sb.append(" return 1.0;\n }\n"); + // a steeper surface needs a larger offset to stay off its own map + sb.append(" float bias = u_shadow.x * (1.0 + 2.0 * (1.0 - max(dot(n, l), 0.0)));\n"); + sb.append(" float lit = 0.0;\n"); + sb.append(" for (int y = -1; y <= 1; y++) {\n"); + sb.append(" for (int x = -1; x <= 1; x++) {\n"); + sb.append(" float d = unpackDepth(texture2D(u_shadowMap, p.xy + vec2(float(x), float(y)) * u_shadow.z));\n"); + sb.append(" lit += p.z - bias > d ? 0.0 : 1.0;\n"); + sb.append(" }\n }\n"); + sb.append(" return 1.0 - u_shadow.y * (1.0 - lit / 9.0);\n"); + sb.append("}\n"); + } + if (f.sky) { + sb.append("uniform vec3 u_lightDir;\n"); + sb.append("uniform vec3 u_zenith;\n"); + sb.append("uniform vec3 u_horizon;\n"); + sb.append("uniform vec4 u_sun;\n"); + appendSkyNoise(sb); + } + if (f.toneMap) { + // Narkowicz's fit of the ACES filmic curve + sb.append("vec3 aces(vec3 x) {\n"); + sb.append(" return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);\n"); + sb.append("}\n"); + } sb.append("void main() {\n"); sb.append(" vec4 base = u_color;\n"); - if (textured) { + if (f.textured) { sb.append(" base = base * texture2D(u_texture, v_texcoord);\n"); } - if (lit) { + sb.append(" vec3 rgb = base.rgb;\n"); + if (f.sky) { + appendSky(sb); + } + if (f.lit) { sb.append(" vec3 n = normalize(v_normal);\n"); sb.append(" vec3 l = normalize(-u_lightDir);\n"); sb.append(" float ndotl = max(dot(n, l), 0.0);\n"); - sb.append(" vec3 lighting = u_ambient + u_lightColor * ndotl;\n"); - sb.append(" vec3 rgb = base.rgb * lighting;\n"); - if (phong) { - sb.append(" if (ndotl > 0.0) {\n"); - sb.append(" vec3 v = normalize(u_eye - v_worldPos);\n"); - sb.append(" vec3 h = normalize(l + v);\n"); - sb.append(" float spec = pow(max(dot(n, h), 0.0), u_shininess);\n"); - sb.append(" rgb += u_lightColor * spec;\n"); - sb.append(" }\n"); + if (f.hemisphere) { + sb.append(" vec3 ambient = mix(u_ground, u_sky, n.y * 0.5 + 0.5);\n"); + } else { + sb.append(" vec3 ambient = u_ambient;\n"); + } + sb.append(f.shadow ? " float shade = shadowAt(n, l);\n" : " float shade = 1.0;\n"); + if (f.pbr) { + appendPbr(sb); + } else { + sb.append(" rgb = base.rgb * (ambient + u_lightColor * ndotl * shade);\n"); + if (f.phong) { + sb.append(" if (ndotl > 0.0) {\n"); + sb.append(" vec3 v = normalize(u_eye - v_worldPos);\n"); + sb.append(" vec3 h = normalize(l + v);\n"); + sb.append(" float spec = pow(max(dot(n, h), 0.0), u_shininess);\n"); + sb.append(" rgb += u_lightColor * spec * shade;\n"); + sb.append(" }\n"); + } + } + if (f.emissive) { + sb.append(" rgb += u_emissive;\n"); + } + if (f.toneMap) { + sb.append(" rgb = aces(rgb * u_exposure);\n"); } - sb.append(" gl_FragColor = vec4(rgb, base.a);\n"); - } else { - sb.append(" gl_FragColor = base;\n"); } + if (f.fog) { + sb.append(" float dist = length(v_worldPos - u_eye);\n"); + sb.append(" float fd = u_fog.x * dist;\n"); + sb.append(" float height = exp(-u_fog.y * max(v_worldPos.y - u_fog.z, 0.0));\n"); + sb.append(" float fogAmount = clamp((1.0 - exp(-fd * fd)) * height, 0.0, 1.0);\n"); + sb.append(" rgb = mix(rgb, u_fogColor, fogAmount);\n"); + } + sb.append(" gl_FragColor = vec4(rgb, base.a);\n"); sb.append("}\n"); return sb.toString(); } + // Value noise and four octaves of it, for the clouds. + private static void appendSkyNoise(StringBuilder sb) { + sb.append("float hash(vec2 p) {\n"); + sb.append(" return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);\n"); + sb.append("}\n"); + sb.append("float noise(vec2 p) {\n"); + sb.append(" vec2 i = floor(p);\n"); + sb.append(" vec2 u = fract(p);\n"); + sb.append(" u = u * u * (3.0 - 2.0 * u);\n"); + sb.append(" return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),\n"); + sb.append(" mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n"); + sb.append("}\n"); + sb.append("float fbm(vec2 p) {\n"); + sb.append(" float v = 0.0;\n"); + sb.append(" float a = 0.5;\n"); + sb.append(" for (int i = 0; i < 4; i++) {\n"); + sb.append(" v += a * noise(p);\n"); + sb.append(" p = p * 2.03 + vec2(1.7, 9.2);\n"); + sb.append(" a *= 0.5;\n"); + sb.append(" }\n"); + sb.append(" return v;\n"); + sb.append("}\n"); + } + + // The open sky, from the direction the pixel is seen in: a horizon to + // zenith gradient, clouds projected onto a plane overhead and lit from + // the sun's side, and the sun's disc with a wide glow around it. + // u_sun is the sun colour and the cloud cover. + private static void appendSky(StringBuilder sb) { + sb.append(" vec3 dir = normalize(v_worldPos - u_eye);\n"); + sb.append(" float up = clamp(dir.y, 0.0, 1.0);\n"); + sb.append(" rgb = mix(u_horizon, u_zenith, pow(up, 0.55));\n"); + sb.append(" vec3 sunDir = normalize(-u_lightDir);\n"); + sb.append(" float sd = max(dot(dir, sunDir), 0.0);\n"); + sb.append(" if (dir.y > 0.0 && u_sun.w > 0.0) {\n"); + sb.append(" vec2 cp = dir.xz / (dir.y + 0.12) * 1.6;\n"); + sb.append(" float c = fbm(cp);\n"); + sb.append(" float cover = smoothstep(1.0 - u_sun.w, 1.0 - u_sun.w + 0.35, c);\n"); + sb.append(" float lit = 0.75 + 0.25 * fbm(cp + sunDir.xz * 0.3);\n"); + sb.append(" vec3 cloud = mix(u_horizon * 0.82, vec3(1.0), lit) + u_sun.rgb * pow(sd, 6.0) * 0.35;\n"); + // clouds thin toward the horizon, where they would bunch into stripes + sb.append(" rgb = mix(rgb, cloud, cover * smoothstep(0.0, 0.25, dir.y));\n"); + sb.append(" }\n"); + sb.append(" rgb += u_sun.rgb * (pow(sd, 8.0) * 0.25 + pow(sd, 64.0) * 0.5 + smoothstep(0.9993, 0.9997, sd) * 2.0);\n"); + } + + // Cook-Torrance with a GGX distribution, Smith-Schlick geometry and + // Schlick's Fresnel: the metallic/roughness model glTF assets are authored + // for. The light colour is scaled by pi so a white light on a white + // dielectric gives the same brightness as LAMBERT does. + private static void appendPbr(StringBuilder sb) { + sb.append(" float metallic = u_pbr.x;\n"); + sb.append(" float rough = u_pbr.y;\n"); + sb.append(" vec3 v = normalize(u_eye - v_worldPos);\n"); + sb.append(" vec3 h = normalize(l + v);\n"); + sb.append(" float ndotv = max(dot(n, v), 0.001);\n"); + sb.append(" float ndoth = max(dot(n, h), 0.0);\n"); + sb.append(" float vdoth = max(dot(v, h), 0.0);\n"); + sb.append(" vec3 f0 = mix(vec3(0.04), base.rgb, metallic);\n"); + sb.append(" float a = rough * rough;\n"); + sb.append(" float a2 = a * a;\n"); + sb.append(" float dd = ndoth * ndoth * (a2 - 1.0) + 1.0;\n"); + sb.append(" float ggx = a2 / (3.14159265 * dd * dd);\n"); + sb.append(" float k = (rough + 1.0) * (rough + 1.0) / 8.0;\n"); + sb.append(" float geo = (ndotl / (ndotl * (1.0 - k) + k)) * (ndotv / (ndotv * (1.0 - k) + k));\n"); + sb.append(" vec3 fres = f0 + (1.0 - f0) * pow(1.0 - vdoth, 5.0);\n"); + sb.append(" vec3 spec = ggx * geo * fres / (4.0 * max(ndotl, 0.001) * ndotv);\n"); + sb.append(" vec3 kd = (vec3(1.0) - fres) * (1.0 - metallic);\n"); + sb.append(" vec3 direct = (kd * base.rgb + spec * 3.14159265) * u_lightColor * ndotl * shade;\n"); + // ambient: diffuse from the hemisphere plus a rough stand-in for the + // reflection of it, so metal is not black in shadow + sb.append(" vec3 fa = f0 + (max(vec3(1.0 - rough), f0) - f0) * pow(1.0 - ndotv, 5.0);\n"); + sb.append(" vec3 amb = ambient * (base.rgb * (1.0 - metallic) * (vec3(1.0) - fa) + fa * (1.0 - rough * 0.7));\n"); + sb.append(" rgb = direct + amb;\n"); + } + + /// Returns the vertex shader that renders shadow casters into the shadow + /// map. The backend sets `u_mvp` to the shadow matrix times the model + /// matrix. + public static String shadowVertex() { + return "attribute vec3 " + A_POSITION + ";\n" + + "uniform mat4 u_mvp;\n" + + "varying float v_depth;\n" + + "void main() {\n" + + " gl_Position = u_mvp * vec4(" + A_POSITION + ", 1.0);\n" + + " v_depth = gl_Position.z / gl_Position.w * 0.5 + 0.5;\n" + + "}\n"; + } + + /// Returns the fragment shader that writes packed depth into the shadow map. + public static String shadowFragment() { + return HIGHP + PACK + + "varying float v_depth;\n" + + "void main() {\n" + + " gl_FragColor = packDepth(clamp(v_depth, 0.0, 1.0));\n" + + "}\n"; + } + + /// Returns the vertex shader of every post processing pass: a full screen + /// quad with `a_position` in clip space. + public static String postVertex() { + return "attribute vec3 " + A_POSITION + ";\n" + + "varying vec2 v_uv;\n" + + "void main() {\n" + + " v_uv = " + A_POSITION + ".xy * 0.5 + 0.5;\n" + + " gl_Position = vec4(" + A_POSITION + ".xy, 0.0, 1.0);\n" + + "}\n"; + } + + /// Returns the bloom bright pass: what is brighter than `u_threshold`, + /// rescaled to start from black. Uniforms: `u_scene`, `u_threshold` and + /// `u_texel` (vec2, one source texel in uv), which it uses to average a + /// 2x2 block while downsampling. + public static String brightFragment() { + return "precision mediump float;\n" + + "uniform sampler2D u_scene;\n" + + "uniform float u_threshold;\n" + + "uniform vec2 u_texel;\n" + + "varying vec2 v_uv;\n" + + "void main() {\n" + + " vec3 c = (texture2D(u_scene, v_uv + u_texel * vec2(-0.5, -0.5)).rgb\n" + + " + texture2D(u_scene, v_uv + u_texel * vec2(0.5, -0.5)).rgb\n" + + " + texture2D(u_scene, v_uv + u_texel * vec2(-0.5, 0.5)).rgb\n" + + " + texture2D(u_scene, v_uv + u_texel * vec2(0.5, 0.5)).rgb) * 0.25;\n" + + " float peak = max(c.r, max(c.g, c.b));\n" + + " float over = max(peak - u_threshold, 0.0) / max(1.0 - u_threshold, 0.0001);\n" + + " gl_FragColor = vec4(c * (over / max(peak, 0.0001)), 1.0);\n" + + "}\n"; + } + + /// Returns one direction of a separable 9 tap Gaussian blur. Uniforms: + /// `u_scene` and `u_dir` (vec2, one texel along the blur direction in uv). + public static String blurFragment() { + return "precision mediump float;\n" + + "uniform sampler2D u_scene;\n" + + "uniform vec2 u_dir;\n" + + "varying vec2 v_uv;\n" + + "void main() {\n" + + " vec3 c = texture2D(u_scene, v_uv).rgb * 0.2270270;\n" + + " c += texture2D(u_scene, v_uv + u_dir * 1.3846154).rgb * 0.3162162;\n" + + " c += texture2D(u_scene, v_uv - u_dir * 1.3846154).rgb * 0.3162162;\n" + + " c += texture2D(u_scene, v_uv + u_dir * 3.2307692).rgb * 0.0702703;\n" + + " c += texture2D(u_scene, v_uv - u_dir * 3.2307692).rgb * 0.0702703;\n" + + " gl_FragColor = vec4(c, 1.0);\n" + + "}\n"; + } + + /// Returns the final pass: the scene plus bloom, colour graded, with + /// vignette and grain. Uniforms: `u_scene` (unit 0), `u_bloom` (unit 1), + /// `u_bloomIntensity`, `u_grade` (vec2: contrast, saturation), + /// `u_lift` and `u_gain` (vec3), `u_vignette`, `u_grain`, `u_time` (float) + /// and `u_resolution` (vec2, pixels). + public static String compositeFragment() { + return "precision mediump float;\n" + + "uniform sampler2D u_scene;\n" + + "uniform sampler2D u_bloom;\n" + + "uniform float u_bloomIntensity;\n" + + "uniform vec2 u_grade;\n" + + "uniform vec3 u_lift;\n" + + "uniform vec3 u_gain;\n" + + "uniform float u_vignette;\n" + + "uniform float u_grain;\n" + + "uniform float u_time;\n" + + "uniform vec2 u_resolution;\n" + + "varying vec2 v_uv;\n" + + "void main() {\n" + + " vec3 c = texture2D(u_scene, v_uv).rgb;\n" + + " c += texture2D(u_bloom, v_uv).rgb * u_bloomIntensity;\n" + + " c = (c - 0.5) * u_grade.x + 0.5;\n" + + " float luma = dot(c, vec3(0.2126, 0.7152, 0.0722));\n" + + " c = mix(vec3(luma), c, u_grade.y);\n" + + " c = clamp(c, 0.0, 1.0);\n" + + " c = (u_lift + c * (1.0 - u_lift)) * u_gain;\n" + + " vec2 d = v_uv - 0.5;\n" + + " c *= 1.0 - u_vignette * smoothstep(0.25, 0.75, dot(d, d) * 2.0);\n" + + " vec2 px = floor(v_uv * u_resolution);\n" + + " float noise = fract(sin(dot(px + fract(u_time * 7.0) * 61.0, vec2(12.9898, 78.233))) * 43758.5453);\n" + + " c += (noise - 0.5) * u_grain;\n" + + " gl_FragColor = vec4(c, 1.0);\n" + + "}\n"; + } + /// Returns the generated vertex shader source. public String getVertexSource() { return vertexSource; diff --git a/NOTICE b/NOTICE index 05536e583d4..38e8d426aed 100644 --- a/NOTICE +++ b/NOTICE @@ -135,6 +135,21 @@ Skirmish sample 3D models (scripts/skirmish/common/src/main/resources/model_*.gl Hexagon Pack and Halloween Bits by Kay Lousberg (www.kaylousberg.com), released under CC0 1.0. Repacked by scripts/skirmish/tools/pack-models.py. +Splat sample 3D models (scripts/splat/common/src/main/resources/model_*.glb) + Toon Shooter Game Kit, Ultimate Nature and Universal Animation Library 1 + and 2 by Quaternius (quaternius.com), Blaster Kit and Survival Kit by + Kenney (kenney.nl), and MakeHuman system and community assets (bodies, + skins, hair and clothes marked CC0, makehumancommunity.org), all released + under CC0 1.0. Built by scripts/splat/tools/make-operators.py and + scripts/splat/tools/pack-art.py. + Photo-scanned props and ground textures (model_car, model_barrier*, + model_sofa, model_barrel, model_streetlight, model_boxes, tex_*.jpg) by + Poly Haven (polyhaven.com), released under CC0 1.0. + +Splat sample fonts (scripts/splat/common/src/main/css/fonts/Rajdhani-*.ttf) + Rajdhani, Copyright (c) 2014, Indian Type Foundry, licensed under the SIL + Open Font License 1.1; the licence text is in the same directory (OFL.txt). + ================================================================================ .NET Standard reference assembly ================================================================================ diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidGraphicsDevice.java b/Ports/Android/src/com/codename1/impl/android/AndroidGraphicsDevice.java index 4d350d32aa1..0ed4aac5100 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidGraphicsDevice.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidGraphicsDevice.java @@ -25,6 +25,7 @@ import android.opengl.GLES20; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -32,6 +33,7 @@ import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.PrimitiveType; import com.codename1.gpu.RenderState; import com.codename1.gpu.Texture; @@ -80,6 +82,7 @@ private static final class Program { int uAmbient; int uEye; int uShininess; + final Map uniforms = new HashMap(); } /// GPU handle for an uploaded texture. @@ -103,6 +106,32 @@ private static final class TexHandle { private final float[] model = new float[16]; private final float[] normalMatrix = new float[16]; + // an offscreen colour target (and optional depth) for the shadow map and + // the post processing chain + private static final class Target { + int fbo; + int tex; + int depth; + int width; + int height; + } + + private Program shadowProgram; + private Program brightProgram; + private Program blurProgram; + private Program compositeProgram; + private Target shadowTarget; + private Target sceneTarget; + private Target bloomA; + private Target bloomB; + private int quadVbo; + private boolean inShadowPass; + private boolean shadowReady; + private final float[] shadowMatrix = new float[16]; + private PostProcess activePost; + private final int[] savedFbo = new int[1]; + private final int[] savedViewport = new int[4]; + /// Lazily builds and caches the device capabilities by querying GL limits. public GpuCapabilities getCapabilities() { if (caps == null) { @@ -120,7 +149,7 @@ public GpuCapabilities getCapabilities() { if (version != null) { name = name + " / " + version; } - caps = new GpuCapabilities(maxTex, maxAttribs, false, false, false, name); + caps = new GpuCapabilities(maxTex, maxAttribs, false, false, false, name, true, true); } return caps; } @@ -190,11 +219,19 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { VertexBuffer vb = mesh.getVertices(); VertexFormat fmt = vb.getFormat(); - Program program = getProgram(material, fmt); + if (inShadowPass) { + drawShadowCaster(mesh, material, modelMatrix); + return; + } + Environment env = getEnvironment(); + boolean shadowed = shadowReady && isShadowMapCurrent() && getLight().isShadows() && material.isLit() + && material.isReceiveShadows(); + Program program = getProgram(material, fmt, env, shadowed); if (program.handle == 0) { return; } GLES20.glUseProgram(program.handle); + applyEnvironment(program, material, env, shadowed); // Compose matrices: mvp = viewProjection * model. if (modelMatrix != null) { @@ -457,15 +494,22 @@ private int uploadIndexBuffer(IndexBuffer ib) { return h.id; } - private Program getProgram(Material material, VertexFormat fmt) { - String key = material.getShaderKey() + "|" + System.identityHashCode(fmt); + private Program getProgram(Material material, VertexFormat fmt, Environment env, boolean shadowed) { + String key = material.getShaderKey() + "|" + GlslShaderGenerator.variantKey(env, shadowed) + + "|" + System.identityHashCode(fmt); Program p = programs.get(key); if (p != null) { return p; } - GlslShaderGenerator gen = new GlslShaderGenerator(material, fmt); - int handle = linkProgram(gen.getVertexSource(), gen.getFragmentSource()); - p = new Program(); + GlslShaderGenerator gen = new GlslShaderGenerator(material, fmt, env, shadowed); + p = buildProgram(gen.getVertexSource(), gen.getFragmentSource()); + programs.put(key, p); + return p; + } + + private Program buildProgram(String vertexSrc, String fragmentSrc) { + int handle = linkProgram(vertexSrc, fragmentSrc); + Program p = new Program(); p.handle = handle; if (handle != 0) { p.aPosition = GLES20.glGetAttribLocation(handle, GlslShaderGenerator.A_POSITION); @@ -482,10 +526,324 @@ private Program getProgram(Material material, VertexFormat fmt) { p.uEye = GLES20.glGetUniformLocation(handle, "u_eye"); p.uShininess = GLES20.glGetUniformLocation(handle, "u_shininess"); } - programs.put(key, p); return p; } + private int loc(Program p, String name) { + Integer cached = p.uniforms.get(name); + if (cached == null) { + cached = Integer.valueOf(p.handle == 0 ? -1 : GLES20.glGetUniformLocation(p.handle, name)); + p.uniforms.put(name, cached); + } + return cached.intValue(); + } + + private void uniformColor(Program p, String name, int argb) { + int l = loc(p, name); + if (l >= 0) { + GLES20.glUniform3f(l, ((argb >> 16) & 0xff) / 255.0f, ((argb >> 8) & 0xff) / 255.0f, + (argb & 0xff) / 255.0f); + } + } + + private void uniform1(Program p, String name, float v) { + int l = loc(p, name); + if (l >= 0) { + GLES20.glUniform1f(l, v); + } + } + + private void uniform2(Program p, String name, float a, float b) { + int l = loc(p, name); + if (l >= 0) { + GLES20.glUniform2f(l, a, b); + } + } + + private void uniform3(Program p, String name, float a, float b, float c) { + int l = loc(p, name); + if (l >= 0) { + GLES20.glUniform3f(l, a, b, c); + } + } + + private void sampler(Program p, String name, int unit, int tex) { + int l = loc(p, name); + if (l >= 0) { + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + unit); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, tex); + GLES20.glUniform1i(l, unit); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + } + } + + private void applyEnvironment(Program p, Material material, Environment env, boolean shadowed) { + uniformColor(p, "u_fogColor", env.getFogColor()); + uniform3(p, "u_fog", env.getFogDensity(), env.getFogHeightFalloff(), env.getFogBaseHeight()); + uniformColor(p, "u_sky", env.getSkyColor()); + uniformColor(p, "u_ground", env.getGroundColor()); + uniform1(p, "u_exposure", env.getExposure()); + uniform2(p, "u_pbr", material.getMetallic(), material.getRoughness()); + if (env.isSky()) { + uniformColor(p, "u_zenith", env.getZenithColor()); + uniformColor(p, "u_horizon", env.getHorizonColor()); + int sun = env.getSunColor(); + int l = loc(p, "u_sun"); + if (l >= 0) { + GLES20.glUniform4f(l, ((sun >> 16) & 0xff) / 255f, ((sun >> 8) & 0xff) / 255f, (sun & 0xff) / 255f, + env.getCloudCover()); + } + } + uniformColor(p, "u_emissive", material.getEmissive()); + if (shadowed) { + int l = loc(p, "u_shadowMatrix"); + if (l >= 0) { + GLES20.glUniformMatrix4fv(l, 1, false, shadowMatrix, 0); + } + Light light = getLight(); + uniform3(p, "u_shadow", light.getShadowBias(), light.getShadowStrength(), + 1f / shadowTarget.width); + sampler(p, "u_shadowMap", 1, shadowTarget.tex); + } + } + + private Target target(Target t, int w, int h, boolean depth) { + return target(t, w, h, depth, GLES20.GL_LINEAR); + } + + private Target target(Target t, int w, int h, boolean depth, int filter) { + if (t != null && t.width == w && t.height == h) { + return t; + } + if (t != null) { + releaseTarget(t); + } + Target n = new Target(); + n.width = w; + n.height = h; + int[] ids = new int[1]; + GLES20.glGenTextures(1, ids, 0); + n.tex = ids[0]; + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, n.tex); + GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, w, h, 0, GLES20.GL_RGBA, + GLES20.GL_UNSIGNED_BYTE, null); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, filter); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, filter); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); + GLES20.glGenFramebuffers(1, ids, 0); + n.fbo = ids[0]; + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, n.fbo); + GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, + GLES20.GL_TEXTURE_2D, n.tex, 0); + if (depth) { + GLES20.glGenRenderbuffers(1, ids, 0); + n.depth = ids[0]; + GLES20.glBindRenderbuffer(GLES20.GL_RENDERBUFFER, n.depth); + GLES20.glRenderbufferStorage(GLES20.GL_RENDERBUFFER, GLES20.GL_DEPTH_COMPONENT16, w, h); + GLES20.glFramebufferRenderbuffer(GLES20.GL_FRAMEBUFFER, GLES20.GL_DEPTH_ATTACHMENT, + GLES20.GL_RENDERBUFFER, n.depth); + GLES20.glBindRenderbuffer(GLES20.GL_RENDERBUFFER, 0); + } + return n; + } + + private void releaseTarget(Target t) { + if (t == null) { + return; + } + GLES20.glDeleteFramebuffers(1, new int[]{t.fbo}, 0); + GLES20.glDeleteTextures(1, new int[]{t.tex}, 0); + if (t.depth != 0) { + GLES20.glDeleteRenderbuffers(1, new int[]{t.depth}, 0); + } + } + + // The panel renders into its own framebuffer, so "the screen" is whatever + // was bound when a pass began, never framebuffer 0. + private void saveTarget() { + GLES20.glGetIntegerv(GLES20.GL_FRAMEBUFFER_BINDING, savedFbo, 0); + GLES20.glGetIntegerv(GLES20.GL_VIEWPORT, savedViewport, 0); + } + + private void restoreTarget() { + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, savedFbo[0]); + GLES20.glViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3]); + } + + public boolean beginShadowPass(Light light) { + if (shadowProgram == null) { + shadowProgram = buildProgram(GlslShaderGenerator.shadowVertex(), + GlslShaderGenerator.shadowFragment()); + } + if (shadowProgram.handle == 0) { + return false; + } + saveTarget(); + int size = light.getShadowMapSize(); + // packed depth must not be interpolated between texels + shadowTarget = target(shadowTarget, size, size, true, GLES20.GL_NEAREST); + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, shadowTarget.fbo); + GLES20.glViewport(0, 0, size, size); + GLES20.glClearColor(1f, 1f, 1f, 1f); + GLES20.glClearDepthf(1f); + GLES20.glDepthMask(true); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); + light.computeShadowMatrix(shadowMatrix); + inShadowPass = true; + return true; + } + + public void endShadowPass() { + if (!inShadowPass) { + return; + } + inShadowPass = false; + shadowReady = true; + shadowMapRendered(); + restoreTarget(); + } + + private void drawShadowCaster(Mesh mesh, Material material, float[] modelMatrix) { + if (!material.isCastShadows() || material.getRenderState().getBlendMode() != RenderState.BlendMode.NONE + || material.getType() == Material.Type.SKYBOX) { + return; + } + VertexBuffer vb = mesh.getVertices(); + VertexFormat fmt = vb.getFormat(); + GLES20.glUseProgram(shadowProgram.handle); + if (modelMatrix != null) { + Matrix4.copy(modelMatrix, model); + } else { + Matrix4.setIdentity(model); + } + Matrix4.multiply(shadowMatrix, model, mvp); + GLES20.glUniformMatrix4fv(shadowProgram.uMvp, 1, false, mvp, 0); + GLES20.glEnable(GLES20.GL_DEPTH_TEST); + GLES20.glDepthMask(true); + GLES20.glDisable(GLES20.GL_BLEND); + GLES20.glDisable(GLES20.GL_CULL_FACE); + int vbo = uploadVertexBuffer(vb); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbo); + bindAttribute(shadowProgram.aPosition, fmt, VertexAttribute.Usage.POSITION, fmt.getStrideBytes()); + int glMode = toGlPrimitive(mesh.getPrimitiveType()); + if (mesh.isIndexed()) { + IndexBuffer ib = mesh.getIndices(); + int ibo = uploadIndexBuffer(ib); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, ibo); + GLES20.glDrawElements(glMode, ib.getIndexCount(), GLES20.GL_UNSIGNED_SHORT, 0); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); + } else { + GLES20.glDrawArrays(glMode, 0, vb.getVertexCount()); + } + disableAttribute(shadowProgram.aPosition); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + } + + public boolean beginPostProcess(PostProcess effects, int width, int height) { + if (effects == null) { + return false; + } + if (compositeProgram == null) { + String v = GlslShaderGenerator.postVertex(); + brightProgram = buildProgram(v, GlslShaderGenerator.brightFragment()); + blurProgram = buildProgram(v, GlslShaderGenerator.blurFragment()); + compositeProgram = buildProgram(v, GlslShaderGenerator.compositeFragment()); + } + if (compositeProgram.handle == 0 || brightProgram.handle == 0 || blurProgram.handle == 0) { + return false; + } + saveTarget(); + int w = savedViewport[2]; + int h = savedViewport[3]; + if (w <= 0 || h <= 0) { + return false; + } + sceneTarget = target(sceneTarget, w, h, true); + if (effects.isBloom()) { + bloomA = target(bloomA, Math.max(1, w / 4), Math.max(1, h / 4), false); + bloomB = target(bloomB, bloomA.width, bloomA.height, false); + } + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, sceneTarget.fbo); + GLES20.glViewport(0, 0, w, h); + activePost = effects; + return true; + } + + public void endPostProcess(float timeSeconds) { + PostProcess pp = activePost; + if (pp == null) { + return; + } + activePost = null; + GLES20.glDisable(GLES20.GL_DEPTH_TEST); + GLES20.glDepthMask(false); + GLES20.glDisable(GLES20.GL_BLEND); + GLES20.glDisable(GLES20.GL_CULL_FACE); + int bloomTex = sceneTarget.tex; + if (pp.isBloom()) { + passInto(bloomA); + GLES20.glUseProgram(brightProgram.handle); + sampler(brightProgram, "u_scene", 0, sceneTarget.tex); + uniform1(brightProgram, "u_threshold", pp.getBloomThreshold()); + uniform2(brightProgram, "u_texel", 1f / sceneTarget.width, 1f / sceneTarget.height); + drawQuad(brightProgram); + for (int i = 0; i < 2; i++) { + float spread = 1f + i; + passInto(bloomB); + GLES20.glUseProgram(blurProgram.handle); + sampler(blurProgram, "u_scene", 0, bloomA.tex); + uniform2(blurProgram, "u_dir", spread / bloomA.width, 0f); + drawQuad(blurProgram); + passInto(bloomA); + sampler(blurProgram, "u_scene", 0, bloomB.tex); + uniform2(blurProgram, "u_dir", 0f, spread / bloomA.height); + drawQuad(blurProgram); + } + bloomTex = bloomA.tex; + } + restoreTarget(); + Program c = compositeProgram; + GLES20.glUseProgram(c.handle); + sampler(c, "u_scene", 0, sceneTarget.tex); + sampler(c, "u_bloom", 1, bloomTex); + uniform1(c, "u_bloomIntensity", pp.isBloom() ? pp.getBloomIntensity() : 0f); + uniform2(c, "u_grade", pp.getContrast(), pp.getSaturation()); + uniformColor(c, "u_lift", pp.getShadowTint()); + uniformColor(c, "u_gain", pp.getHighlightTint()); + uniform1(c, "u_vignette", pp.getVignette()); + uniform1(c, "u_grain", pp.getGrain()); + uniform1(c, "u_time", timeSeconds); + uniform2(c, "u_resolution", sceneTarget.width, sceneTarget.height); + drawQuad(c); + GLES20.glDepthMask(true); + } + + private void passInto(Target t) { + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, t.fbo); + GLES20.glViewport(0, 0, t.width, t.height); + } + + private void drawQuad(Program p) { + if (quadVbo == 0) { + int[] ids = new int[1]; + GLES20.glGenBuffers(1, ids, 0); + quadVbo = ids[0]; + FloatBuffer fb = ByteBuffer.allocateDirect(12 * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); + fb.put(new float[]{-1f, -1f, 0f, 1f, -1f, 0f, -1f, 1f, 0f, 1f, 1f, 0f}); + fb.position(0); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, quadVbo); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, 12 * 4, fb, GLES20.GL_STATIC_DRAW); + } + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, quadVbo); + GLES20.glEnableVertexAttribArray(p.aPosition); + GLES20.glVertexAttribPointer(p.aPosition, 3, GLES20.GL_FLOAT, false, 12, 0); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLES20.glDisableVertexAttribArray(p.aPosition); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + } + private int linkProgram(String vertexSrc, String fragmentSrc) { int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSrc); int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSrc); @@ -558,5 +916,16 @@ void disposePrograms() { } programs.clear(); caps = null; + shadowProgram = null; + brightProgram = null; + blurProgram = null; + compositeProgram = null; + // the context that owned these is gone with them + shadowTarget = null; + sceneTarget = null; + bloomA = null; + bloomB = null; + quadVbo = 0; + shadowReady = false; } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ClassPathLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/ClassPathLoader.java index 5b4c752c6fc..033fce693c1 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ClassPathLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ClassPathLoader.java @@ -35,6 +35,13 @@ * Class loader for the given classpath */ class ClassPathLoader extends ClassLoader { + // One domain for every loader, naming none. With null, each loader made + // its own default domain that references the loader, and every thread a + // test started (a JDK timer, the process reaper) captured it in its + // inherited access control context: each finished test's loader and + // application stayed in memory for as long as such a thread lived. + private static final java.security.ProtectionDomain DOMAIN = new java.security.ProtectionDomain( + new java.security.CodeSource(null, (java.security.cert.Certificate[]) null), null); private File[] classpath; private Map classes = new HashMap(); @@ -53,6 +60,14 @@ class ClassPathLoader extends ClassLoader { excludes.add("org.cef."); excludes.add("me.friwi.jcefmaven."); excludes.add("org.w3c"); + // Libraries that start threads which never end (JOGL's shared + // resource runner, JavaCPP's deallocator) are loaded once, by the + // parent. Loaded per test, each of those threads kept its test's + // loader and everything the application had loaded with it, and a + // suite of 3D tests ran out of heap by the last test. + excludes.add("com.jogamp."); + excludes.add("jogamp."); + excludes.add("org.bytedeco."); } private List includes = new ArrayList(); { @@ -257,7 +272,7 @@ public Class findClass(String className) throws ClassNotFoundException { } is.close(); classByte = byteStream.toByteArray(); - result = defineClass(className, classByte, 0, classByte.length, null); + result = defineClass(className, classByte, 0, classByte.length, DOMAIN); classes.put(className, result); return result; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEGLDevice.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEGLDevice.java index 66e5715bc62..6c420f90a48 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEGLDevice.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEGLDevice.java @@ -23,6 +23,7 @@ package com.codename1.impl.javase; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -30,6 +31,7 @@ import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.PrimitiveType; import com.codename1.gpu.RenderState; import com.codename1.gpu.Texture; @@ -89,8 +91,35 @@ private static final class Program { int uAmbient; int uEye; int uShininess; + final Map uniforms = new HashMap(); } + // an offscreen colour target (and optional depth) for the shadow map and + // the post processing chain + private static final class Target { + int fbo; + int tex; + int depth; + int width; + int height; + } + + private Program shadowProgram; + private Program brightProgram; + private Program blurProgram; + private Program compositeProgram; + private Target shadowTarget; + private Target sceneTarget; + private Target bloomA; + private Target bloomB; + private int quadVbo; + private boolean inShadowPass; + private boolean shadowReady; + private final float[] shadowMatrix = new float[16]; + private PostProcess activePost; + private final int[] savedFbo = new int[1]; + private final int[] savedViewport = new int[4]; + private static final class TexHandle { final int id; @@ -115,7 +144,7 @@ public GpuCapabilities getCapabilities() { if (version != null) { name = name + " / " + version; } - caps = new GpuCapabilities(maxTex, maxAttribs, false, false, false, name); + caps = new GpuCapabilities(maxTex, maxAttribs, false, false, false, name, true, true); } return caps; } @@ -177,7 +206,14 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { VertexBuffer vb = mesh.getVertices(); VertexFormat fmt = vb.getFormat(); - Program program = getProgram(material, fmt); + if (inShadowPass) { + drawShadowCaster(mesh, material, modelMatrix); + return; + } + Environment env = getEnvironment(); + boolean shadowed = shadowReady && isShadowMapCurrent() && getLight().isShadows() && material.isLit() + && material.isReceiveShadows(); + Program program = getProgram(material, fmt, env, shadowed); if (program.handle == 0) { return; } @@ -236,6 +272,7 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { if (program.uShininess >= 0) { gl.glUniform1f(program.uShininess, material.getShininess()); } + applyEnvironment(program, material, env, shadowed); Texture tex = material.getTexture(); if (tex != null && program.uTexture >= 0) { @@ -435,15 +472,22 @@ private int uploadIndexBuffer(IndexBuffer ib) { return h.id; } - private Program getProgram(Material material, VertexFormat fmt) { - String key = material.getShaderKey() + "|" + System.identityHashCode(fmt); + private Program getProgram(Material material, VertexFormat fmt, Environment env, boolean shadowed) { + String key = material.getShaderKey() + "|" + GlslShaderGenerator.variantKey(env, shadowed) + + "|" + System.identityHashCode(fmt); Program p = programs.get(key); if (p != null) { return p; } - GlslShaderGenerator gen = new GlslShaderGenerator(material, fmt); - int handle = linkProgram(gen.getVertexSource(), gen.getFragmentSource()); - p = new Program(); + GlslShaderGenerator gen = new GlslShaderGenerator(material, fmt, env, shadowed); + p = buildProgram(gen.getVertexSource(), gen.getFragmentSource()); + programs.put(key, p); + return p; + } + + private Program buildProgram(String vertexSrc, String fragmentSrc) { + int handle = linkProgram(vertexSrc, fragmentSrc); + Program p = new Program(); p.handle = handle; if (handle != 0) { p.aPosition = gl.glGetAttribLocation(handle, GlslShaderGenerator.A_POSITION); @@ -460,10 +504,327 @@ private Program getProgram(Material material, VertexFormat fmt) { p.uEye = gl.glGetUniformLocation(handle, "u_eye"); p.uShininess = gl.glGetUniformLocation(handle, "u_shininess"); } - programs.put(key, p); return p; } + private int loc(Program p, String name) { + Integer cached = p.uniforms.get(name); + if (cached == null) { + cached = Integer.valueOf(p.handle == 0 ? -1 : gl.glGetUniformLocation(p.handle, name)); + p.uniforms.put(name, cached); + } + return cached.intValue(); + } + + private void uniformColor(Program p, String name, int argb) { + int l = loc(p, name); + if (l >= 0) { + gl.glUniform3f(l, ((argb >> 16) & 0xff) / 255.0f, ((argb >> 8) & 0xff) / 255.0f, + (argb & 0xff) / 255.0f); + } + } + + private void uniform1(Program p, String name, float v) { + int l = loc(p, name); + if (l >= 0) { + gl.glUniform1f(l, v); + } + } + + private void uniform2(Program p, String name, float a, float b) { + int l = loc(p, name); + if (l >= 0) { + gl.glUniform2f(l, a, b); + } + } + + private void uniform3(Program p, String name, float a, float b, float c) { + int l = loc(p, name); + if (l >= 0) { + gl.glUniform3f(l, a, b, c); + } + } + + private void sampler(Program p, String name, int unit, int tex) { + int l = loc(p, name); + if (l >= 0) { + gl.glActiveTexture(GL2ES2.GL_TEXTURE0 + unit); + gl.glBindTexture(GL2ES2.GL_TEXTURE_2D, tex); + gl.glUniform1i(l, unit); + gl.glActiveTexture(GL2ES2.GL_TEXTURE0); + } + } + + private void applyEnvironment(Program p, Material material, Environment env, boolean shadowed) { + uniformColor(p, "u_fogColor", env.getFogColor()); + uniform3(p, "u_fog", env.getFogDensity(), env.getFogHeightFalloff(), env.getFogBaseHeight()); + uniformColor(p, "u_sky", env.getSkyColor()); + uniformColor(p, "u_ground", env.getGroundColor()); + uniform1(p, "u_exposure", env.getExposure()); + uniform2(p, "u_pbr", material.getMetallic(), material.getRoughness()); + if (env.isSky()) { + uniformColor(p, "u_zenith", env.getZenithColor()); + uniformColor(p, "u_horizon", env.getHorizonColor()); + int sun = env.getSunColor(); + int l = loc(p, "u_sun"); + if (l >= 0) { + gl.glUniform4f(l, ((sun >> 16) & 0xff) / 255f, ((sun >> 8) & 0xff) / 255f, (sun & 0xff) / 255f, + env.getCloudCover()); + } + } + uniformColor(p, "u_emissive", material.getEmissive()); + if (shadowed) { + int l = loc(p, "u_shadowMatrix"); + if (l >= 0) { + gl.glUniformMatrix4fv(l, 1, false, shadowMatrix, 0); + } + Light light = getLight(); + uniform3(p, "u_shadow", light.getShadowBias(), light.getShadowStrength(), + 1f / shadowTarget.width); + sampler(p, "u_shadowMap", 1, shadowTarget.tex); + } + } + + private Target target(Target t, int w, int h, boolean depth) { + return target(t, w, h, depth, GL2ES2.GL_LINEAR); + } + + private Target target(Target t, int w, int h, boolean depth, int filter) { + if (t != null && t.width == w && t.height == h) { + return t; + } + if (t != null) { + releaseTarget(t); + } + Target n = new Target(); + n.width = w; + n.height = h; + int[] ids = new int[1]; + gl.glGenTextures(1, ids, 0); + n.tex = ids[0]; + gl.glBindTexture(GL2ES2.GL_TEXTURE_2D, n.tex); + gl.glTexImage2D(GL2ES2.GL_TEXTURE_2D, 0, GL2ES2.GL_RGBA, w, h, 0, GL2ES2.GL_RGBA, + GL2ES2.GL_UNSIGNED_BYTE, null); + gl.glTexParameteri(GL2ES2.GL_TEXTURE_2D, GL2ES2.GL_TEXTURE_MIN_FILTER, filter); + gl.glTexParameteri(GL2ES2.GL_TEXTURE_2D, GL2ES2.GL_TEXTURE_MAG_FILTER, filter); + gl.glTexParameteri(GL2ES2.GL_TEXTURE_2D, GL2ES2.GL_TEXTURE_WRAP_S, GL2ES2.GL_CLAMP_TO_EDGE); + gl.glTexParameteri(GL2ES2.GL_TEXTURE_2D, GL2ES2.GL_TEXTURE_WRAP_T, GL2ES2.GL_CLAMP_TO_EDGE); + gl.glBindTexture(GL2ES2.GL_TEXTURE_2D, 0); + gl.glGenFramebuffers(1, ids, 0); + n.fbo = ids[0]; + gl.glBindFramebuffer(GL2ES2.GL_FRAMEBUFFER, n.fbo); + gl.glFramebufferTexture2D(GL2ES2.GL_FRAMEBUFFER, GL2ES2.GL_COLOR_ATTACHMENT0, + GL2ES2.GL_TEXTURE_2D, n.tex, 0); + if (depth) { + gl.glGenRenderbuffers(1, ids, 0); + n.depth = ids[0]; + gl.glBindRenderbuffer(GL2ES2.GL_RENDERBUFFER, n.depth); + gl.glRenderbufferStorage(GL2ES2.GL_RENDERBUFFER, GL2ES2.GL_DEPTH_COMPONENT16, w, h); + gl.glFramebufferRenderbuffer(GL2ES2.GL_FRAMEBUFFER, GL2ES2.GL_DEPTH_ATTACHMENT, + GL2ES2.GL_RENDERBUFFER, n.depth); + gl.glBindRenderbuffer(GL2ES2.GL_RENDERBUFFER, 0); + } + return n; + } + + private void releaseTarget(Target t) { + if (t == null || gl == null) { + return; + } + gl.glDeleteFramebuffers(1, new int[]{t.fbo}, 0); + gl.glDeleteTextures(1, new int[]{t.tex}, 0); + if (t.depth != 0) { + gl.glDeleteRenderbuffers(1, new int[]{t.depth}, 0); + } + } + + // The panel renders into its own framebuffer, so "the screen" is whatever + // was bound when a pass began, never framebuffer 0. + private void saveTarget() { + gl.glGetIntegerv(GL2ES2.GL_FRAMEBUFFER_BINDING, savedFbo, 0); + gl.glGetIntegerv(GL2ES2.GL_VIEWPORT, savedViewport, 0); + } + + private void restoreTarget() { + gl.glBindFramebuffer(GL2ES2.GL_FRAMEBUFFER, savedFbo[0]); + gl.glViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3]); + } + + public boolean beginShadowPass(Light light) { + if (gl == null) { + return false; + } + if (shadowProgram == null) { + shadowProgram = buildProgram(GlslShaderGenerator.shadowVertex(), + GlslShaderGenerator.shadowFragment()); + } + if (shadowProgram.handle == 0) { + return false; + } + saveTarget(); + int size = light.getShadowMapSize(); + // packed depth must not be interpolated between texels + shadowTarget = target(shadowTarget, size, size, true, GL2ES2.GL_NEAREST); + gl.glBindFramebuffer(GL2ES2.GL_FRAMEBUFFER, shadowTarget.fbo); + gl.glViewport(0, 0, size, size); + gl.glClearColor(1f, 1f, 1f, 1f); + gl.glClearDepthf(1f); + gl.glDepthMask(true); + gl.glClear(GL2ES2.GL_COLOR_BUFFER_BIT | GL2ES2.GL_DEPTH_BUFFER_BIT); + light.computeShadowMatrix(shadowMatrix); + inShadowPass = true; + return true; + } + + public void endShadowPass() { + if (!inShadowPass) { + return; + } + inShadowPass = false; + shadowReady = true; + shadowMapRendered(); + restoreTarget(); + } + + private void drawShadowCaster(Mesh mesh, Material material, float[] modelMatrix) { + if (!material.isCastShadows() || material.getRenderState().getBlendMode() != RenderState.BlendMode.NONE + || material.getType() == Material.Type.SKYBOX) { + return; + } + VertexBuffer vb = mesh.getVertices(); + VertexFormat fmt = vb.getFormat(); + gl.glUseProgram(shadowProgram.handle); + if (modelMatrix != null) { + Matrix4.copy(modelMatrix, model); + } else { + Matrix4.setIdentity(model); + } + Matrix4.multiply(shadowMatrix, model, mvp); + gl.glUniformMatrix4fv(shadowProgram.uMvp, 1, false, mvp, 0); + gl.glEnable(GL2ES2.GL_DEPTH_TEST); + gl.glDepthMask(true); + gl.glDisable(GL2ES2.GL_BLEND); + gl.glDisable(GL2ES2.GL_CULL_FACE); + int vbo = uploadVertexBuffer(vb); + gl.glBindBuffer(GL2ES2.GL_ARRAY_BUFFER, vbo); + bindAttribute(shadowProgram.aPosition, fmt, VertexAttribute.Usage.POSITION, fmt.getStrideBytes()); + int glMode = toGlPrimitive(mesh.getPrimitiveType()); + if (mesh.isIndexed()) { + IndexBuffer ib = mesh.getIndices(); + int ibo = uploadIndexBuffer(ib); + gl.glBindBuffer(GL2ES2.GL_ELEMENT_ARRAY_BUFFER, ibo); + gl.glDrawElements(glMode, ib.getIndexCount(), GL2ES2.GL_UNSIGNED_SHORT, 0L); + gl.glBindBuffer(GL2ES2.GL_ELEMENT_ARRAY_BUFFER, 0); + } else { + gl.glDrawArrays(glMode, 0, vb.getVertexCount()); + } + disableAttribute(shadowProgram.aPosition); + gl.glBindBuffer(GL2ES2.GL_ARRAY_BUFFER, 0); + } + + public boolean beginPostProcess(PostProcess effects, int width, int height) { + if (gl == null || effects == null) { + return false; + } + if (compositeProgram == null) { + String v = GlslShaderGenerator.postVertex(); + brightProgram = buildProgram(v, GlslShaderGenerator.brightFragment()); + blurProgram = buildProgram(v, GlslShaderGenerator.blurFragment()); + compositeProgram = buildProgram(v, GlslShaderGenerator.compositeFragment()); + } + if (compositeProgram.handle == 0 || brightProgram.handle == 0 || blurProgram.handle == 0) { + return false; + } + saveTarget(); + int w = savedViewport[2]; + int h = savedViewport[3]; + if (w <= 0 || h <= 0) { + return false; + } + sceneTarget = target(sceneTarget, w, h, true); + if (effects.isBloom()) { + bloomA = target(bloomA, Math.max(1, w / 4), Math.max(1, h / 4), false); + bloomB = target(bloomB, bloomA.width, bloomA.height, false); + } + gl.glBindFramebuffer(GL2ES2.GL_FRAMEBUFFER, sceneTarget.fbo); + gl.glViewport(0, 0, w, h); + activePost = effects; + return true; + } + + public void endPostProcess(float timeSeconds) { + PostProcess pp = activePost; + if (pp == null) { + return; + } + activePost = null; + gl.glDisable(GL2ES2.GL_DEPTH_TEST); + gl.glDepthMask(false); + gl.glDisable(GL2ES2.GL_BLEND); + gl.glDisable(GL2ES2.GL_CULL_FACE); + int bloomTex = sceneTarget.tex; + if (pp.isBloom()) { + passInto(bloomA); + gl.glUseProgram(brightProgram.handle); + sampler(brightProgram, "u_scene", 0, sceneTarget.tex); + uniform1(brightProgram, "u_threshold", pp.getBloomThreshold()); + uniform2(brightProgram, "u_texel", 1f / sceneTarget.width, 1f / sceneTarget.height); + drawQuad(brightProgram); + for (int i = 0; i < 2; i++) { + float spread = 1f + i; + passInto(bloomB); + gl.glUseProgram(blurProgram.handle); + sampler(blurProgram, "u_scene", 0, bloomA.tex); + uniform2(blurProgram, "u_dir", spread / bloomA.width, 0f); + drawQuad(blurProgram); + passInto(bloomA); + sampler(blurProgram, "u_scene", 0, bloomB.tex); + uniform2(blurProgram, "u_dir", 0f, spread / bloomA.height); + drawQuad(blurProgram); + } + bloomTex = bloomA.tex; + } + restoreTarget(); + Program c = compositeProgram; + gl.glUseProgram(c.handle); + sampler(c, "u_scene", 0, sceneTarget.tex); + sampler(c, "u_bloom", 1, bloomTex); + uniform1(c, "u_bloomIntensity", pp.isBloom() ? pp.getBloomIntensity() : 0f); + uniform2(c, "u_grade", pp.getContrast(), pp.getSaturation()); + uniformColor(c, "u_lift", pp.getShadowTint()); + uniformColor(c, "u_gain", pp.getHighlightTint()); + uniform1(c, "u_vignette", pp.getVignette()); + uniform1(c, "u_grain", pp.getGrain()); + uniform1(c, "u_time", timeSeconds); + uniform2(c, "u_resolution", sceneTarget.width, sceneTarget.height); + drawQuad(c); + gl.glDepthMask(true); + } + + private void passInto(Target t) { + gl.glBindFramebuffer(GL2ES2.GL_FRAMEBUFFER, t.fbo); + gl.glViewport(0, 0, t.width, t.height); + } + + private void drawQuad(Program p) { + if (quadVbo == 0) { + int[] ids = new int[1]; + gl.glGenBuffers(1, ids, 0); + quadVbo = ids[0]; + FloatBuffer fb = ByteBuffer.allocateDirect(12 * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); + fb.put(new float[]{-1f, -1f, 0f, 1f, -1f, 0f, -1f, 1f, 0f, 1f, 1f, 0f}); + fb.position(0); + gl.glBindBuffer(GL2ES2.GL_ARRAY_BUFFER, quadVbo); + gl.glBufferData(GL2ES2.GL_ARRAY_BUFFER, 12L * 4, fb, GL2ES2.GL_STATIC_DRAW); + } + gl.glBindBuffer(GL2ES2.GL_ARRAY_BUFFER, quadVbo); + gl.glEnableVertexAttribArray(p.aPosition); + gl.glVertexAttribPointer(p.aPosition, 3, GL2ES2.GL_FLOAT, false, 12, 0L); + gl.glDrawArrays(GL2ES2.GL_TRIANGLE_STRIP, 0, 4); + gl.glDisableVertexAttribArray(p.aPosition); + gl.glBindBuffer(GL2ES2.GL_ARRAY_BUFFER, 0); + } + private int linkProgram(String vertexSrc, String fragmentSrc) { int vs = compileShader(GL2ES2.GL_VERTEX_SHADER, vertexSrc); int fs = compileShader(GL2ES2.GL_FRAGMENT_SHADER, fragmentSrc); @@ -576,5 +937,16 @@ void disposePrograms() { } programs.clear(); caps = null; + shadowProgram = null; + brightProgram = null; + blurProgram = null; + compositeProgram = null; + // the context that owned these is gone with them + shadowTarget = null; + sceneTarget = null; + bloomA = null; + bloomB = null; + quadVbo = 0; + shadowReady = false; } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEJoglSurface.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEJoglSurface.java index fbaff9443fb..3f02b4c5afc 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEJoglSurface.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEJoglSurface.java @@ -6,6 +6,19 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.javase; @@ -58,6 +71,7 @@ class JavaSEJoglSurface extends JComponent implements JavaSEGpuSurface { private int lastW = -1; private int lastH = -1; private Timer timer; + private boolean continuous; JavaSEJoglSurface(RenderView view) { this.view = view; @@ -182,7 +196,8 @@ public JComponent getComponent() { } public void setContinuous(boolean continuous) { - if (continuous) { + this.continuous = continuous; + if (continuous && isDisplayable()) { if (timer == null) { timer = new Timer(16, new ActionListener() { public void actionPerformed(ActionEvent e) { @@ -196,6 +211,28 @@ public void actionPerformed(ActionEvent e) { } } + // A view taken off the screen kept repainting every 16ms, and Swing's + // timer queue held it, its renderer and the whole scene for good: every + // 3D view the simulator had shown stayed alive. The timer runs only while + // the surface is in a window; a view that comes back (a dialog over it + // removes it for a moment) picks up where it was. + @Override + public void addNotify() { + super.addNotify(); + if (continuous) { + setContinuous(true); + } + } + + @Override + public void removeNotify() { + if (timer != null) { + timer.stop(); + timer = null; + } + super.removeNotify(); + } + public void requestRender() { view.repaint(); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 5666b47be2e..7de582ec16d 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -5827,6 +5827,28 @@ public void paintDirty() { * @inheritDoc */ public void deinitialize() { + // What was registered to be torn down with the port and not already run + // by deinitializeSync(): sound pools and media players own threads that + // never end by themselves, and the test runner retires each test's port + // this way. Left running they kept that test's whole application in + // memory, and a suite ran out of heap after a dozen tests. + for (JavaSEGpuSurface surface : new ArrayList(glSurfaces.keySet())) { + try { + surface.disposeSurface(); + } catch (Throwable t) { + Log.e(t); + } + } + glSurfaces.clear(); + ArrayList pending = new ArrayList(deinitializeHooks); + deinitializeHooks.clear(); + for (Runnable r : pending) { + try { + r.run(); + } catch (Throwable t) { + Log.e(t); + } + } // The window manager's monitor poller is a daemon timer that outlives the port // otherwise. A simulator session that restarts through deinitialize()/init() // builds a new manager each time, and every previous poller kept waking every @@ -17618,7 +17640,14 @@ public boolean isSoundPoolSupported() { @Override public com.codename1.media.SoundPoolPeer createSoundPool(int maxStreams) { try { - return new JavaSESoundPool(maxStreams); + final JavaSESoundPool pool = new JavaSESoundPool(maxStreams); + // Its mixer thread runs until the pool is released. + addDeinitializeHook(new Runnable() { + public void run() { + pool.release(); + } + }); + return pool; } catch (Throwable t) { // audio line unavailable (e.g. headless CI) -- fall back to the // MediaManager based pool by reporting no native backend @@ -21982,8 +22011,14 @@ public PeerComponent createNativePeer(Object nativeComponent) { return new JavaSEPort.Peer((JFrame)cnt, (java.awt.Component) nativeComponent); } - private final java.util.Map glSurfaces = - new java.util.IdentityHashMap(); + // Every live surface, for deinitialize(). Weak: a surface is found from its + // peer (a client property, so it lives exactly as long as the peer) and this + // set must not keep it. A map from peer to surface never let go, because the + // surface's view holds the peer: every 3D view ever shown stayed in memory + // with its frame buffers. + private final java.util.Map glSurfaces = + new java.util.WeakHashMap(); + private static final String GPU_SURFACE = "cn1$gpuSurface"; private final com.codename1.impl.gpu.GpuImplementation gpuImpl = new com.codename1.impl.gpu.GpuImplementation() { @@ -22016,14 +22051,15 @@ public com.codename1.ui.PeerComponent createPeer(com.codename1.gpu.RenderView vi } com.codename1.ui.PeerComponent peer = createNativePeer(surface.getComponent()); if (peer != null) { - glSurfaces.put(peer, surface); + peer.putClientProperty(GPU_SURFACE, surface); + glSurfaces.put(surface, Boolean.TRUE); } return peer; } @Override public void setContinuous(com.codename1.ui.PeerComponent peer, boolean continuous) { - JavaSEGpuSurface surface = glSurfaces.get(peer); + JavaSEGpuSurface surface = (JavaSEGpuSurface) peer.getClientProperty(GPU_SURFACE); if (surface != null) { surface.setContinuous(continuous); } @@ -22031,7 +22067,7 @@ public void setContinuous(com.codename1.ui.PeerComponent peer, boolean continuou @Override public void requestRender(com.codename1.ui.PeerComponent peer) { - JavaSEGpuSurface surface = glSurfaces.get(peer); + JavaSEGpuSurface surface = (JavaSEGpuSurface) peer.getClientProperty(GPU_SURFACE); if (surface != null) { surface.requestRender(); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESoftwareDevice.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESoftwareDevice.java index e88c59ad745..1dce55fed1a 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESoftwareDevice.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSESoftwareDevice.java @@ -23,6 +23,7 @@ package com.codename1.impl.javase; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -289,9 +290,26 @@ private void tri(int i0, int i1, int i2, Material material, float[] model, } float invArea = 1.0f / area; - boolean lit = material.getType() == Material.Type.LAMBERT - || material.getType() == Material.Type.PHONG; - boolean phong = material.getType() == Material.Type.PHONG; + boolean lit = material.isLit(); + boolean phong = material.getType() == Material.Type.PHONG + || material.getType() == Material.Type.PBR; + // The environment, approximated on the CPU: fog, the sky/ground + // ambient, exposure and the ACES curve, emissive, and the open sky + // as a gradient with the sun's glow. Shadows, clouds and the post + // chain are GPU only (GpuCapabilities says so). + Environment env = getEnvironment(); + boolean fog = env.isFog() && material.getType() != Material.Type.SKYBOX; + float fogR = ((env.getFogColor() >> 16) & 0xff) / 255.0f; + float fogG = ((env.getFogColor() >> 8) & 0xff) / 255.0f; + float fogB = (env.getFogColor() & 0xff) / 255.0f; + boolean hemi = lit && env.isHemisphere(); + boolean tone = lit && env.isToneMapping(); + float exposure = env.getExposure(); + boolean sky = env.isSky() && material.getType() == Material.Type.SKYBOX; + int em = material.getEmissive(); + float emR = ((em >> 16) & 0xff) / 255.0f; + float emG = ((em >> 8) & 0xff) / 255.0f; + float emB = (em & 0xff) / 255.0f; boolean blend = rs.getBlendMode() == RenderState.BlendMode.ALPHA || rs.getBlendMode() == RenderState.BlendMode.ADDITIVE; boolean additive = rs.getBlendMode() == RenderState.BlendMode.ADDITIVE; @@ -383,9 +401,18 @@ private void tri(int i0, int i1, int i2, Material material, float[] model, if (ndotl < 0) { ndotl = 0; } - float litR = ar + lr * ndotl; - float litG = ag + lg * ndotl; - float litB = ab + lb * ndotl; + float ambR = ar; + float ambG = ag; + float ambB = ab; + if (hemi) { + float up = ny * 0.5f + 0.5f; + ambR = channel(env.getGroundColor(), 16) * (1 - up) + channel(env.getSkyColor(), 16) * up; + ambG = channel(env.getGroundColor(), 8) * (1 - up) + channel(env.getSkyColor(), 8) * up; + ambB = channel(env.getGroundColor(), 0) * (1 - up) + channel(env.getSkyColor(), 0) * up; + } + float litR = ambR + lr * ndotl; + float litG = ambG + lg * ndotl; + float litB = ambB + lb * ndotl; r *= litR; g *= litG; b *= litB; @@ -422,6 +449,48 @@ private void tri(int i0, int i1, int i2, Material material, float[] model, } } + if (lit) { + r += emR; + g += emG; + b += emB; + } + if (tone) { + r = aces(r * exposure); + g = aces(g * exposure); + b = aces(b * exposure); + } + if (fog || sky) { + float wx = b0 * world[0][0] + b1 * world[1][0] + b2 * world[2][0]; + float wy = b0 * world[0][1] + b1 * world[1][1] + b2 * world[2][1]; + float wz = b0 * world[0][2] + b1 * world[1][2] + b2 * world[2][2]; + float dx = wx - eyeX; + float dy = wy - eyeY; + float dz = wz - eyeZ; + float dist = (float) Math.sqrt(dx * dx + dy * dy + dz * dz); + if (sky && dist > 0) { + float up = dy / dist; + float t = (float) Math.pow(up < 0 ? 0 : up, 0.55); + r = channel(env.getHorizonColor(), 16) * (1 - t) + channel(env.getZenithColor(), 16) * t; + g = channel(env.getHorizonColor(), 8) * (1 - t) + channel(env.getZenithColor(), 8) * t; + b = channel(env.getHorizonColor(), 0) * (1 - t) + channel(env.getZenithColor(), 0) * t; + // (lx, ly, lz) points back toward the sun + float sd = (dx * lx + dy * ly + dz * lz) / dist; + float glow = sd > 0 ? (float) Math.pow(sd, 64) : 0f; + r += channel(env.getSunColor(), 16) * glow; + g += channel(env.getSunColor(), 8) * glow; + b += channel(env.getSunColor(), 0) * glow; + } + if (fog) { + float d = env.getFogDensity() * dist; + float h = wy - env.getFogBaseHeight(); + float thin = h > 0 ? (float) Math.exp(-env.getFogHeightFalloff() * h) : 1f; + float f = 1f - (float) Math.exp(-d * d); + f *= thin; + r = r * (1 - f) + fogR * f; + g = g * (1 - f) + fogG * f; + b = b * (1 - f) + fogB * f; + } + } int out = packColor(r, g, b, a); if (blend) { out = blendPixel(color[di], out, additive, a); @@ -558,6 +627,16 @@ private static int blendPixel(int dst, int src, boolean additive, float srcAlpha return (0xff << 24) | (rr << 16) | (rg << 8) | rb; } + private static float channel(int argb, int shift) { + return ((argb >> shift) & 0xff) / 255.0f; + } + + // the same ACES fit the GLSL generator uses + private static float aces(float x) { + float v = (x * (2.51f * x + 0.03f)) / (x * (2.43f * x + 0.59f) + 0.14f); + return v < 0 ? 0 : v > 1 ? 1 : v; + } + private static int clamp255(int v) { if (v < 0) { return 0; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java b/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java index 153ff713654..33421653c2c 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/TestRunner.java @@ -193,6 +193,15 @@ private void init(String[] argv) { int passedTests = 0; int failedTests = 0; + // JavaCPP (FFmpeg) starts a deallocator thread on first use that + // never ends, and the thread keeps the security context of the + // code that started it: started from a test, it held that test's + // loader for the rest of the run. Start it here, before any. + try { + Class.forName("org.bytedeco.javacpp.Pointer"); + } catch (Throwable notOnTheClassPath) { + // no FFmpeg support in this simulator + } if(cleanMode) { for(String currentTestClass : tests) { ClassLoader ldr = createClassLoader(files); @@ -202,6 +211,7 @@ private void init(String[] argv) { // This test's Codename One is not used again: without this // its threads keep everything it loaded in memory. c.getDeclaredMethod("retire").invoke(null); + releaseContextLoader(ldr); if(passed) { passedTests++; } else { @@ -246,6 +256,25 @@ private void init(String[] argv) { } } + /** + * A thread inherits the context class loader of the thread that started + * it, so a timer or pool a test started and nothing stops holds that + * test's loader, and with it everything the application loaded, for as + * long as the thread lives. Point the survivors at the runner's loader. + */ + private static void releaseContextLoader(ClassLoader ldr) { + ClassLoader mine = TestRunner.class.getClassLoader(); + for (Thread t : Thread.getAllStackTraces().keySet()) { + try { + if (t.getContextClassLoader() == ldr) { + t.setContextClassLoader(mine); + } + } catch (SecurityException ignored) { + // not ours to change + } + } + } + private ClassLoader createClassLoader(File[] files) { ClassPathLoader ldr = new ClassPathLoader(files); ldr.addExclude("com.codename1.testing.TestReporting"); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/cef/CEFBrowserComponent.java b/Ports/JavaSE/src/com/codename1/impl/javase/cef/CEFBrowserComponent.java index 1ea96b56119..d5bd53fa327 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/cef/CEFBrowserComponent.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/cef/CEFBrowserComponent.java @@ -453,6 +453,13 @@ public void exposeInJavaScript(Object o, String name) { public void fireReady() { if (!CN.isEdt()) { + // With Codename One stopped, callSerially runs the call inline, on + // this same non-EDT thread, and the hop recursed until the stack + // overflowed: a browser that finishes loading after the app (or a + // test run) has shut down has nobody left to tell. + if (!com.codename1.ui.Display.isInitialized()) { + return; + } CN.callSerially(new Runnable() { public void run() { fireReady(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/cef/InputStreamSchemeHandler.java b/Ports/JavaSE/src/com/codename1/impl/javase/cef/InputStreamSchemeHandler.java index 77a8a0df066..2a8418577e1 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/cef/InputStreamSchemeHandler.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/cef/InputStreamSchemeHandler.java @@ -71,13 +71,21 @@ public synchronized boolean processRequest(CefRequest request, CefCallback callb if (streamId != null) { stream = BrowserPanel.getStreamRegistry().getStream(streamId); - if (range != null) { + if (range != null && stream != null) { if (range.indexOf("=") != -1) { rangeRequest = true; range = range.substring(range.indexOf("=")+1); String startStr = range.substring(0, range.indexOf("-")); long start = Long.parseLong(startStr); rangeStart = start; + if (stream.getOffset() > start && stream.getStream().markSupported()) { + // A request for an earlier part (Chromium probes the + // end and comes back): rewind a buffered stream. + try { + stream.getStream().reset(); + stream.setOffset(0); + } catch (IOException ex) {} + } if (stream.getOffset() < start) { InputStream inputStream = stream.getStream(); int seekTo = (int)(start- stream.getOffset()); @@ -93,7 +101,7 @@ public synchronized boolean processRequest(CefRequest request, CefCallback callb } } - System.out.println("Stream found "+stream+" offset "+stream.getOffset()); + System.out.println("Stream found "+stream+" offset "+(stream == null ? -1 : stream.getOffset())); callback.Continue(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/cef/JavaCEFSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/cef/JavaCEFSEPort.java index 4124be09105..35a8077fe46 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/cef/JavaCEFSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/cef/JavaCEFSEPort.java @@ -612,7 +612,19 @@ public AsyncResource createMediaAsync(InputStream inputStream, String mim } } - StreamWrapper stream = new StreamWrapper(inputStream, mimeType , -1); + // Chromium's media pipeline asks for the length and may re-read from + // the start (range requests), which a one-shot stream of unknown + // length cannot answer: the player failed with "data source error". + // Buffer it, so the length is known and InputStreamSchemeHandler can + // seek back. + StreamWrapper stream; + try { + byte[] data = com.codename1.io.Util.readInputStream(inputStream); + stream = new StreamWrapper(new java.io.ByteArrayInputStream(data), mimeType, data.length); + } catch (IOException ex) { + out.error(ex); + return out; + } String id = BrowserPanel.getStreamRegistry().registerStream(stream); String uriAddress = "https://cn1app/streams/"+id; try { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/record/SimulatorRecorder.java b/Ports/JavaSE/src/com/codename1/impl/javase/record/SimulatorRecorder.java index b2c1f1cf6d5..8e82e162bc9 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/record/SimulatorRecorder.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/record/SimulatorRecorder.java @@ -57,9 +57,11 @@ /// slow encode delays the file, not the next capture. Writing on the clock made /// every slow frame a repeated one, and the video stuttered. /// -/// Audio is assembled when recording stops. The track is the microphone when that -/// is on, silence otherwise, with a short synthesized click mixed in at every tap -/// when tap sounds are on. The application's own audio output is not captured. +/// Audio is assembled when recording stops. The track mixes what +/// `RecordingOptions.Audio` asks for -- the application's own sound (media and +/// the sound pool, through `AppAudioTap`), the microphone, or both -- with a +/// short synthesized click at every tap when tap sounds are on, and is silent +/// when nothing is chosen. /// /// Without ffmpeg the recorder writes a numbered PNG sequence next to the requested /// file and says so in its result, so a recording is never lost silently. diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLFramebuffer.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLFramebuffer.java new file mode 100644 index 00000000000..7ae3a73d48d --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLFramebuffer.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.html5.js.webgl; + +import com.codename1.html5.js.JSObject; + +/// Opaque handle for a WebGL Framebuffer object. Instances are bridge references +/// to the real object that lives on the browser main thread. +public interface WebGLFramebuffer extends JSObject { +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderbuffer.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderbuffer.java new file mode 100644 index 00000000000..47b8c3c5003 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderbuffer.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.html5.js.webgl; + +import com.codename1.html5.js.JSObject; + +/// Opaque handle for a WebGL Renderbuffer object. Instances are bridge references +/// to the real object that lives on the browser main thread. +public interface WebGLRenderbuffer extends JSObject { +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderingContext.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderingContext.java index f17ed58dec2..7e70889836b 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderingContext.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/webgl/WebGLRenderingContext.java @@ -101,4 +101,16 @@ void texImage2D(int target, int level, int internalformat, int width, int height int border, int format, int type, byte[] pixels); void texParameteri(int target, int pname, int param); void deleteTexture(WebGLTexture texture); + + WebGLFramebuffer createFramebuffer(); + void bindFramebuffer(int target, WebGLFramebuffer framebuffer); + void framebufferTexture2D(int target, int attachment, int textarget, WebGLTexture texture, int level); + void framebufferRenderbuffer(int target, int attachment, int renderbuffertarget, WebGLRenderbuffer renderbuffer); + void deleteFramebuffer(WebGLFramebuffer framebuffer); + WebGLRenderbuffer createRenderbuffer(); + void bindRenderbuffer(int target, WebGLRenderbuffer renderbuffer); + void renderbufferStorage(int target, int internalformat, int width, int height); + void deleteRenderbuffer(WebGLRenderbuffer renderbuffer); + void disableVertexAttribArray(int index); + void uniform2f(WebGLUniformLocation location, float x, float y); } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5GraphicsDevice.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5GraphicsDevice.java index f2c3c9d1d4e..bd6597bb798 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5GraphicsDevice.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5GraphicsDevice.java @@ -23,6 +23,7 @@ package com.codename1.impl.html5; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -30,6 +31,7 @@ import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.PrimitiveType; import com.codename1.gpu.RenderState; import com.codename1.gpu.Texture; @@ -38,7 +40,9 @@ import com.codename1.gpu.VertexFormat; import com.codename1.impl.gpu.GlslShaderGenerator; import com.codename1.html5.js.webgl.WebGLBuffer; +import com.codename1.html5.js.webgl.WebGLFramebuffer; import com.codename1.html5.js.webgl.WebGLProgram; +import com.codename1.html5.js.webgl.WebGLRenderbuffer; import com.codename1.html5.js.webgl.WebGLRenderingContext; import com.codename1.html5.js.webgl.WebGLShader; import com.codename1.html5.js.webgl.WebGLTexture; @@ -101,11 +105,44 @@ class HTML5GraphicsDevice extends GraphicsDevice { private static final int CLAMP_TO_EDGE = 0x812F; private static final int MAX_TEXTURE_SIZE = 0x0D33; private static final int MAX_VERTEX_ATTRIBS = 0x8869; + private static final int FRAMEBUFFER = 0x8D40; + private static final int RENDERBUFFER = 0x8D41; + private static final int COLOR_ATTACHMENT0 = 0x8CE0; + private static final int DEPTH_ATTACHMENT = 0x8D00; + private static final int DEPTH_COMPONENT16 = 0x81A5; private final WebGLRenderingContext gl; private final Map programs = new HashMap(); private final GpuCapabilities capabilities; + // an offscreen colour target (and optional depth) for the shadow map and + // the post processing chain + private static final class Target { + WebGLFramebuffer fbo; + WebGLTexture tex; + WebGLRenderbuffer depth; + int width; + int height; + } + + private ProgramEntry shadowProgram; + private ProgramEntry brightProgram; + private ProgramEntry blurProgram; + private ProgramEntry compositeProgram; + private Target shadowTarget; + private Target sceneTarget; + private Target bloomA; + private Target bloomB; + private WebGLBuffer quadVbo; + private boolean inShadowPass; + private boolean shadowReady; + private final float[] shadowMatrix = new float[16]; + private PostProcess activePost; + // The canvas is the default framebuffer (null). The viewport is tracked + // here because reading it back would need a typed array across the bridge. + private final int[] viewport = new int[4]; + private final int[] savedViewport = new int[4]; + /// Cached compiled program together with the locations the device binds. /// Attribute locations are GLint indices (-1 when absent); uniform locations /// are opaque handles (null when absent). @@ -124,6 +161,7 @@ private static final class ProgramEntry { WebGLUniformLocation uAmbient; WebGLUniformLocation uEye; WebGLUniformLocation uShininess; + final Map uniforms = new HashMap(); } HTML5GraphicsDevice(WebGLRenderingContext gl) { @@ -136,7 +174,7 @@ private static final class ProgramEntry { if (maxAttribs <= 0) { maxAttribs = 8; } - this.capabilities = new GpuCapabilities(maxTex, maxAttribs, false, false, false, "WebGL"); + this.capabilities = new GpuCapabilities(maxTex, maxAttribs, false, false, false, "WebGL", true, true); } public GpuCapabilities getCapabilities() { @@ -200,14 +238,26 @@ public void clear(int argbColor, boolean color, boolean depth) { } public void setViewport(int x, int y, int width, int height) { + viewport[0] = x; + viewport[1] = y; + viewport[2] = width; + viewport[3] = height; gl.viewport(x, y, width, height); } public void draw(Mesh mesh, Material material, float[] modelMatrix) { VertexBuffer vertices = mesh.getVertices(); VertexFormat format = vertices.getFormat(); - ProgramEntry entry = getProgram(material, format); + if (inShadowPass) { + drawShadowCaster(mesh, material, modelMatrix); + return; + } + Environment env = getEnvironment(); + boolean shadowed = shadowReady && getLight().isShadows() && material.isLit() + && material.isReceiveShadows(); + ProgramEntry entry = getProgram(material, format, env, shadowed); gl.useProgram(entry.program); + applyEnvironment(entry, material, env, shadowed); float[] model = modelMatrix != null ? modelMatrix : Matrix4.identity(); Camera camera = getCamera(); @@ -413,15 +463,22 @@ private int toGlPrimitive(PrimitiveType pt) { } } - private ProgramEntry getProgram(Material material, VertexFormat format) { - String key = material.getShaderKey() + "|" + formatKey(format); + private ProgramEntry getProgram(Material material, VertexFormat format, Environment env, boolean shadowed) { + String key = material.getShaderKey() + "|" + GlslShaderGenerator.variantKey(env, shadowed) + + "|" + formatKey(format); ProgramEntry entry = programs.get(key); if (entry != null) { return entry; } - GlslShaderGenerator gen = new GlslShaderGenerator(material, format); - WebGLShader vs = compileShader(VERTEX_SHADER, gen.getVertexSource()); - WebGLShader fs = compileShader(FRAGMENT_SHADER, gen.getFragmentSource()); + GlslShaderGenerator gen = new GlslShaderGenerator(material, format, env, shadowed); + entry = buildProgram(gen.getVertexSource(), gen.getFragmentSource()); + programs.put(key, entry); + return entry; + } + + private ProgramEntry buildProgram(String vertexSrc, String fragmentSrc) { + WebGLShader vs = compileShader(VERTEX_SHADER, vertexSrc); + WebGLShader fs = compileShader(FRAGMENT_SHADER, fragmentSrc); WebGLProgram program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); @@ -430,7 +487,7 @@ private ProgramEntry getProgram(Material material, VertexFormat format) { String log = gl.getProgramInfoLog(program); throw new RuntimeException("WebGL program link failed: " + log); } - entry = new ProgramEntry(); + ProgramEntry entry = new ProgramEntry(); entry.program = program; entry.aPosition = gl.getAttribLocation(program, GlslShaderGenerator.A_POSITION); entry.aNormal = gl.getAttribLocation(program, GlslShaderGenerator.A_NORMAL); @@ -445,10 +502,322 @@ private ProgramEntry getProgram(Material material, VertexFormat format) { entry.uAmbient = gl.getUniformLocation(program, "u_ambient"); entry.uEye = gl.getUniformLocation(program, "u_eye"); entry.uShininess = gl.getUniformLocation(program, "u_shininess"); - programs.put(key, entry); return entry; } + private WebGLUniformLocation loc(ProgramEntry p, String name) { + if (p.uniforms.containsKey(name)) { + return p.uniforms.get(name); + } + WebGLUniformLocation l = gl.getUniformLocation(p.program, name); + p.uniforms.put(name, l); + return l; + } + + private void uniformColor(ProgramEntry p, String name, int argb) { + WebGLUniformLocation l = loc(p, name); + if (l != null) { + gl.uniform3f(l, ((argb >> 16) & 0xff) / 255.0f, ((argb >> 8) & 0xff) / 255.0f, + (argb & 0xff) / 255.0f); + } + } + + private void uniform1(ProgramEntry p, String name, float v) { + WebGLUniformLocation l = loc(p, name); + if (l != null) { + gl.uniform1f(l, v); + } + } + + private void uniform2(ProgramEntry p, String name, float a, float b) { + WebGLUniformLocation l = loc(p, name); + if (l != null) { + gl.uniform2f(l, a, b); + } + } + + private void uniform3(ProgramEntry p, String name, float a, float b, float c) { + WebGLUniformLocation l = loc(p, name); + if (l != null) { + gl.uniform3f(l, a, b, c); + } + } + + private void sampler(ProgramEntry p, String name, int unit, WebGLTexture tex) { + WebGLUniformLocation l = loc(p, name); + if (l != null) { + gl.activeTexture(TEXTURE0 + unit); + gl.bindTexture(TEXTURE_2D, tex); + gl.uniform1i(l, unit); + gl.activeTexture(TEXTURE0); + } + } + + private void applyEnvironment(ProgramEntry p, Material material, Environment env, boolean shadowed) { + uniformColor(p, "u_fogColor", env.getFogColor()); + uniform3(p, "u_fog", env.getFogDensity(), env.getFogHeightFalloff(), env.getFogBaseHeight()); + uniformColor(p, "u_sky", env.getSkyColor()); + uniformColor(p, "u_ground", env.getGroundColor()); + uniform1(p, "u_exposure", env.getExposure()); + uniform2(p, "u_pbr", material.getMetallic(), material.getRoughness()); + if (env.isSky()) { + uniformColor(p, "u_zenith", env.getZenithColor()); + uniformColor(p, "u_horizon", env.getHorizonColor()); + int sun = env.getSunColor(); + WebGLUniformLocation l = loc(p, "u_sun"); + if (l != null) { + gl.uniform4f(l, ((sun >> 16) & 0xff) / 255f, ((sun >> 8) & 0xff) / 255f, (sun & 0xff) / 255f, + env.getCloudCover()); + } + } + uniformColor(p, "u_emissive", material.getEmissive()); + if (shadowed) { + WebGLUniformLocation l = loc(p, "u_shadowMatrix"); + if (l != null) { + gl.uniformMatrix4fv(l, false, shadowMatrix); + } + Light light = getLight(); + uniform3(p, "u_shadow", light.getShadowBias(), light.getShadowStrength(), + 1f / shadowTarget.width); + sampler(p, "u_shadowMap", 1, shadowTarget.tex); + } + } + + private Target target(Target t, int w, int h, boolean depth, int filter) { + if (t != null && t.width == w && t.height == h) { + return t; + } + if (t != null) { + releaseTarget(t); + } + Target n = new Target(); + n.width = w; + n.height = h; + n.tex = gl.createTexture(); + gl.bindTexture(TEXTURE_2D, n.tex); + gl.texImage2D(TEXTURE_2D, 0, RGBA, w, h, 0, RGBA, UNSIGNED_BYTE, null); + gl.texParameteri(TEXTURE_2D, TEXTURE_MIN_FILTER, filter); + gl.texParameteri(TEXTURE_2D, TEXTURE_MAG_FILTER, filter); + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_S, CLAMP_TO_EDGE); + gl.texParameteri(TEXTURE_2D, TEXTURE_WRAP_T, CLAMP_TO_EDGE); + gl.bindTexture(TEXTURE_2D, null); + n.fbo = gl.createFramebuffer(); + gl.bindFramebuffer(FRAMEBUFFER, n.fbo); + gl.framebufferTexture2D(FRAMEBUFFER, COLOR_ATTACHMENT0, TEXTURE_2D, n.tex, 0); + if (depth) { + n.depth = gl.createRenderbuffer(); + gl.bindRenderbuffer(RENDERBUFFER, n.depth); + gl.renderbufferStorage(RENDERBUFFER, DEPTH_COMPONENT16, w, h); + gl.framebufferRenderbuffer(FRAMEBUFFER, DEPTH_ATTACHMENT, RENDERBUFFER, n.depth); + gl.bindRenderbuffer(RENDERBUFFER, null); + } + return n; + } + + private void releaseTarget(Target t) { + if (t == null) { + return; + } + gl.deleteFramebuffer(t.fbo); + gl.deleteTexture(t.tex); + if (t.depth != null) { + gl.deleteRenderbuffer(t.depth); + } + } + + private void saveTarget() { + System.arraycopy(viewport, 0, savedViewport, 0, 4); + } + + private void restoreTarget() { + gl.bindFramebuffer(FRAMEBUFFER, null); + setViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3]); + } + + private boolean shadowUnavailable; + private boolean postUnavailable; + + private void passInto(Target t) { + gl.bindFramebuffer(FRAMEBUFFER, t.fbo); + setViewport(0, 0, t.width, t.height); + } + + public boolean beginShadowPass(Light light) { + if (shadowUnavailable) { + return false; + } + if (shadowProgram == null) { + try { + shadowProgram = buildProgram(GlslShaderGenerator.shadowVertex(), + GlslShaderGenerator.shadowFragment()); + } catch (RuntimeException rejected) { + // A browser that cannot link the depth shader draws without + // shadows, as a device without the feature does, and is not + // asked again every frame. + shadowUnavailable = true; + return false; + } + } + saveTarget(); + int size = light.getShadowMapSize(); + // packed depth must not be interpolated between texels + shadowTarget = target(shadowTarget, size, size, true, NEAREST); + gl.bindFramebuffer(FRAMEBUFFER, shadowTarget.fbo); + setViewport(0, 0, size, size); + gl.clearColor(1f, 1f, 1f, 1f); + gl.depthMask(true); + gl.clear(COLOR_BUFFER_BIT | DEPTH_BUFFER_BIT); + light.computeShadowMatrix(shadowMatrix); + inShadowPass = true; + return true; + } + + public void endShadowPass() { + if (!inShadowPass) { + return; + } + inShadowPass = false; + shadowReady = true; + restoreTarget(); + } + + private void drawShadowCaster(Mesh mesh, Material material, float[] modelMatrix) { + if (!material.isCastShadows() || material.getRenderState().getBlendMode() != RenderState.BlendMode.NONE + || material.getType() == Material.Type.SKYBOX) { + return; + } + VertexBuffer vb = mesh.getVertices(); + VertexFormat fmt = vb.getFormat(); + gl.useProgram(shadowProgram.program); + float[] model = modelMatrix != null ? modelMatrix : Matrix4.identity(); + float[] mvp = Matrix4.identity(); + Matrix4.multiply(shadowMatrix, model, mvp); + if (shadowProgram.uMvp != null) { + gl.uniformMatrix4fv(shadowProgram.uMvp, false, mvp); + } + gl.enable(DEPTH_TEST); + gl.depthMask(true); + gl.disable(BLEND); + gl.disable(CULL_FACE); + WebGLBuffer vbo = uploadVertexBuffer(vb); + gl.bindBuffer(ARRAY_BUFFER, vbo); + bindAttributes(shadowProgram, fmt); + int mode = toGlPrimitive(mesh.getPrimitiveType()); + if (mesh.isIndexed()) { + IndexBuffer ib = mesh.getIndices(); + WebGLBuffer ibo = uploadIndexBuffer(ib); + gl.bindBuffer(ELEMENT_ARRAY_BUFFER, ibo); + gl.drawElements(mode, ib.getIndexCount(), UNSIGNED_SHORT, 0); + } else { + gl.drawArrays(mode, 0, vb.getVertexCount()); + } + if (shadowProgram.aPosition >= 0) { + gl.disableVertexAttribArray(shadowProgram.aPosition); + } + } + + public boolean beginPostProcess(PostProcess effects, int width, int height) { + if (effects == null) { + return false; + } + if (postUnavailable) { + return false; + } + if (compositeProgram == null) { + String v = GlslShaderGenerator.postVertex(); + try { + brightProgram = buildProgram(v, GlslShaderGenerator.brightFragment()); + blurProgram = buildProgram(v, GlslShaderGenerator.blurFragment()); + compositeProgram = buildProgram(v, GlslShaderGenerator.compositeFragment()); + } catch (RuntimeException rejected) { + // Drawn straight to the screen instead, as without the feature. + brightProgram = null; + blurProgram = null; + compositeProgram = null; + postUnavailable = true; + return false; + } + } + saveTarget(); + int w = savedViewport[2]; + int h = savedViewport[3]; + if (w <= 0 || h <= 0) { + return false; + } + sceneTarget = target(sceneTarget, w, h, true, LINEAR); + if (effects.isBloom()) { + bloomA = target(bloomA, Math.max(1, w / 4), Math.max(1, h / 4), false, LINEAR); + bloomB = target(bloomB, bloomA.width, bloomA.height, false, LINEAR); + } + gl.bindFramebuffer(FRAMEBUFFER, sceneTarget.fbo); + setViewport(0, 0, w, h); + activePost = effects; + return true; + } + + public void endPostProcess(float timeSeconds) { + PostProcess pp = activePost; + if (pp == null) { + return; + } + activePost = null; + gl.disable(DEPTH_TEST); + gl.depthMask(false); + gl.disable(BLEND); + gl.disable(CULL_FACE); + WebGLTexture bloomTex = sceneTarget.tex; + if (pp.isBloom()) { + passInto(bloomA); + gl.useProgram(brightProgram.program); + sampler(brightProgram, "u_scene", 0, sceneTarget.tex); + uniform1(brightProgram, "u_threshold", pp.getBloomThreshold()); + uniform2(brightProgram, "u_texel", 1f / sceneTarget.width, 1f / sceneTarget.height); + drawQuad(brightProgram); + for (int i = 0; i < 2; i++) { + float spread = 1f + i; + passInto(bloomB); + gl.useProgram(blurProgram.program); + sampler(blurProgram, "u_scene", 0, bloomA.tex); + uniform2(blurProgram, "u_dir", spread / bloomA.width, 0f); + drawQuad(blurProgram); + passInto(bloomA); + sampler(blurProgram, "u_scene", 0, bloomB.tex); + uniform2(blurProgram, "u_dir", 0f, spread / bloomA.height); + drawQuad(blurProgram); + } + bloomTex = bloomA.tex; + } + restoreTarget(); + ProgramEntry c = compositeProgram; + gl.useProgram(c.program); + sampler(c, "u_scene", 0, sceneTarget.tex); + sampler(c, "u_bloom", 1, bloomTex); + uniform1(c, "u_bloomIntensity", pp.isBloom() ? pp.getBloomIntensity() : 0f); + uniform2(c, "u_grade", pp.getContrast(), pp.getSaturation()); + uniformColor(c, "u_lift", pp.getShadowTint()); + uniformColor(c, "u_gain", pp.getHighlightTint()); + uniform1(c, "u_vignette", pp.getVignette()); + uniform1(c, "u_grain", pp.getGrain()); + uniform1(c, "u_time", timeSeconds); + uniform2(c, "u_resolution", sceneTarget.width, sceneTarget.height); + drawQuad(c); + gl.depthMask(true); + } + + private void drawQuad(ProgramEntry p) { + if (quadVbo == null) { + quadVbo = gl.createBuffer(); + gl.bindBuffer(ARRAY_BUFFER, quadVbo); + gl.bufferData(ARRAY_BUFFER, new float[]{-1f, -1f, 0f, 1f, -1f, 0f, -1f, 1f, 0f, 1f, 1f, 0f}, + STATIC_DRAW); + } + gl.bindBuffer(ARRAY_BUFFER, quadVbo); + gl.enableVertexAttribArray(p.aPosition); + gl.vertexAttribPointer(p.aPosition, 3, FLOAT, false, 12, 0); + gl.drawArrays(TRIANGLE_STRIP, 0, 4); + gl.disableVertexAttribArray(p.aPosition); + } + private static String formatKey(VertexFormat format) { StringBuilder sb = new StringBuilder(); int count = format.getAttributeCount(); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_embedded_stubs.c b/Ports/LinuxPort/nativeSources/cn1_linux_embedded_stubs.c index 89e360c4266..9976f20b5ad 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_embedded_stubs.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_embedded_stubs.c @@ -476,14 +476,32 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dSetViewport___long_int_int_in (void) contextPeer; (void) x; (void) y; (void) width; (void) height; cn1LinuxStubOnce("gl3dSetViewport"); } -JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawIndexed___long_long_long_int_long_int_int_float_1ARRAY_int_long_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_LONG iboPeer, JAVA_INT indexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) { - (void) contextPeer; (void) pipelinePeer; (void) vboPeer; (void) strideBytes; (void) iboPeer; (void) indexCount; (void) primitive; (void) uniforms; (void) uniformFloats; (void) texturePeer; (void) texFilter; (void) texWrap; +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawIndexed___long_long_long_int_long_int_int_float_1ARRAY_int_long_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_LONG iboPeer, JAVA_INT indexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap, JAVA_INT shadowMap) { + (void) contextPeer; (void) pipelinePeer; (void) vboPeer; (void) strideBytes; (void) iboPeer; (void) indexCount; (void) primitive; (void) uniforms; (void) uniformFloats; (void) texturePeer; (void) texFilter; (void) texWrap; (void) shadowMap; cn1LinuxStubOnce("gl3dDrawIndexed"); } -JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawArrays___long_long_long_int_int_int_float_1ARRAY_int_long_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_INT vertexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) { - (void) contextPeer; (void) pipelinePeer; (void) vboPeer; (void) strideBytes; (void) vertexCount; (void) primitive; (void) uniforms; (void) uniformFloats; (void) texturePeer; (void) texFilter; (void) texWrap; +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawArrays___long_long_long_int_int_int_float_1ARRAY_int_long_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_INT vertexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap, JAVA_INT shadowMap) { + (void) contextPeer; (void) pipelinePeer; (void) vboPeer; (void) strideBytes; (void) vertexCount; (void) primitive; (void) uniforms; (void) uniformFloats; (void) texturePeer; (void) texFilter; (void) texWrap; (void) shadowMap; cn1LinuxStubOnce("gl3dDrawArrays"); } +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_gl3dBeginShadowPass___long_int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_INT size) { + (void) contextPeer; (void) size; + cn1LinuxStubOnce("gl3dBeginShadowPass"); + return JAVA_FALSE; +} +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dEndShadowPass___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer) { + (void) contextPeer; + cn1LinuxStubOnce("gl3dEndShadowPass"); +} +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_gl3dBeginPost___long_boolean_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_BOOLEAN bloom) { + (void) contextPeer; (void) bloom; + cn1LinuxStubOnce("gl3dBeginPost"); + return JAVA_FALSE; +} +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dEndPost___long_long_long_long_boolean_float_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG brightPeer, JAVA_LONG blurPeer, JAVA_LONG compositePeer, JAVA_BOOLEAN bloom, JAVA_OBJECT params) { + (void) contextPeer; (void) brightPeer; (void) blurPeer; (void) compositePeer; (void) bloom; (void) params; + cn1LinuxStubOnce("gl3dEndPost"); +} #endif /* CN1_EMBEDDED_HAS_GL */ /* ---- cn1_linux_media.c ---- */ diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gl.c b/Ports/LinuxPort/nativeSources/cn1_linux_gl.c index 1538f725a89..91e4ff34712 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_gl.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gl.c @@ -29,11 +29,14 @@ * gl3dCaptureFrame reads the pixels back and PNG-encodes them. libepoxy supplies * the GLES/EGL entry points. * - * The combined GLSL source from GlslShaderGenerator carries the vertex and - * fragment stages separated by "//@@CN1_FRAGMENT@@"; gl3dGetOrCreatePipeline - * splits, compiles and links them, and records which optional vertex attributes - * (normal at location 1, texcoord at location 2) the shader declared so the draw - * calls can set the interleaved attribute pointers. + * The shaders come from the core com.codename1.impl.gpu.GlslShaderGenerator + * (GLSL ES 1.00, which an ES 3.0 context accepts). LinuxGraphicsDevice joins the + * vertex and fragment stages with "//@@CN1_FRAGMENT@@"; gl3dGetOrCreatePipeline + * splits, compiles and links them (a_position/a_normal/a_texcoord are bound to + * locations 0/1/2), records which optional vertex attributes the shader declared + * and caches the named-uniform locations. Draws receive one packed float array + * whose layout is CN1_UNIFORMS below. The shadow pass and the post process chain + * (bright pass, blur, composite) run here against offscreen targets. */ #define _GNU_SOURCE @@ -55,28 +58,77 @@ extern int cn1LinuxSurfaceToPng(cairo_surface_t* surface, unsigned char** outDat #define CN1_STAGE_SEP "//@@CN1_FRAGMENT@@" +/* An offscreen colour target (plus optional depth) for the shadow map and the + * post processing chain. */ +typedef struct { + GLuint fbo; + GLuint tex; + GLuint depth; + int w, h; +} CN1GlTarget; + typedef struct { EGLDisplay dpy; EGLContext ctx; GLuint fbo; GLuint colorTex; GLuint depthRbo; - GLuint ubo; GLuint vao; int w, h; + CN1GlTarget shadow; + CN1GlTarget scene; + CN1GlTarget bloomA; + CN1GlTarget bloomB; + GLuint quadVbo; + GLint savedFbo; + GLint savedVp[4]; } CN1GlContext; +/* Layout of the packed uniform array a draw passes in; must match + * LinuxGraphicsDevice.packUniforms (112 floats). A size of 16 is a mat4. */ +typedef struct { + const char* name; + int size; +} CN1GlUniformDef; + +static const CN1GlUniformDef CN1_UNIFORMS[] = { + {"u_mvp", 16}, {"u_model", 16}, {"u_normalMatrix", 16}, {"u_shadowMatrix", 16}, + {"u_color", 4}, {"u_lightDir", 3}, {"u_lightColor", 3}, {"u_ambient", 3}, + {"u_eye", 3}, {"u_shininess", 1}, {"u_fogColor", 3}, {"u_fog", 3}, + {"u_sky", 3}, {"u_ground", 3}, {"u_exposure", 1}, {"u_pbr", 2}, + {"u_emissive", 3}, {"u_zenith", 3}, {"u_horizon", 3}, {"u_sun", 4}, + {"u_shadow", 3} +}; +#define CN1_UNIFORM_COUNT ((int) (sizeof(CN1_UNIFORMS) / sizeof(CN1_UNIFORMS[0]))) +#define CN1_UNIFORM_FLOATS 112 + typedef struct { GLuint program; int hasNormal; int hasTexcoord; int blend, cull, depthTest, depthWrite; + GLint loc[CN1_UNIFORM_COUNT]; + GLint uTexture; + GLint uShadowMap; } CN1GlPipeline; static int cn1GlMakeCurrent(CN1GlContext* c) { return eglMakeCurrent(c->dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, c->ctx); } +static void cn1GlReleaseTarget(CN1GlTarget* t) { + if (t->fbo) { + glDeleteFramebuffers(1, &t->fbo); + } + if (t->tex) { + glDeleteTextures(1, &t->tex); + } + if (t->depth) { + glDeleteRenderbuffers(1, &t->depth); + } + memset(t, 0, sizeof(*t)); +} + JAVA_LONG com_codename1_impl_linux_LinuxNative_gl3dCreateContext___R_long(CODENAME_ONE_THREAD_STATE) { CN1GlContext* c; EGLint major, minor; @@ -132,7 +184,6 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_gl3dCreateContext___R_long(CODENA glGenFramebuffers(1, &c->fbo); glGenTextures(1, &c->colorTex); glGenRenderbuffers(1, &c->depthRbo); - glGenBuffers(1, &c->ubo); glGenVertexArrays(1, &c->vao); return (JAVA_LONG) (intptr_t) c; } @@ -146,7 +197,13 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDestroyContext___long(CODENAM glDeleteFramebuffers(1, &c->fbo); glDeleteTextures(1, &c->colorTex); glDeleteRenderbuffers(1, &c->depthRbo); - glDeleteBuffers(1, &c->ubo); + cn1GlReleaseTarget(&c->shadow); + cn1GlReleaseTarget(&c->scene); + cn1GlReleaseTarget(&c->bloomA); + cn1GlReleaseTarget(&c->bloomB); + if (c->quadVbo) { + glDeleteBuffers(1, &c->quadVbo); + } glDeleteVertexArrays(1, &c->vao); eglMakeCurrent(c->dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); eglDestroyContext(c->dpy, c->ctx); @@ -365,7 +422,7 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_gl3dGetOrCreatePipeline___long_ja char* sep; GLuint vs, fs, prog; GLint linkOk = 0; - GLuint blockIndex; + int i; CN1GlPipeline* p; (void) key; if (!c || glslSource == JAVA_NULL) { @@ -392,6 +449,9 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_gl3dGetOrCreatePipeline___long_ja prog = glCreateProgram(); glAttachShader(prog, vs); glAttachShader(prog, fs); + glBindAttribLocation(prog, 0, "a_position"); + glBindAttribLocation(prog, 1, "a_normal"); + glBindAttribLocation(prog, 2, "a_texcoord"); glLinkProgram(prog); glGetProgramiv(prog, GL_LINK_STATUS, &linkOk); glDeleteShader(vs); @@ -404,16 +464,16 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_gl3dGetOrCreatePipeline___long_ja free(vsrc); return 0; } - /* Bind the CN1Uniforms block to binding point 0 (the context UBO). */ - blockIndex = glGetUniformBlockIndex(prog, "CN1Uniforms"); - if (blockIndex != GL_INVALID_INDEX) { - glUniformBlockBinding(prog, blockIndex, 0); - } p = (CN1GlPipeline*) calloc(1, sizeof(CN1GlPipeline)); p->program = prog; + for (i = 0; i < CN1_UNIFORM_COUNT; i++) { + p->loc[i] = glGetUniformLocation(prog, CN1_UNIFORMS[i].name); + } + p->uTexture = glGetUniformLocation(prog, "u_texture"); + p->uShadowMap = glGetUniformLocation(prog, "u_shadowMap"); /* Record which optional interleaved attributes the shader declared. */ - p->hasNormal = strstr(vsrc, "in vec3 normal") != 0; - p->hasTexcoord = strstr(vsrc, "in vec2 texcoord") != 0; + p->hasNormal = strstr(vsrc, "attribute vec3 a_normal") != 0; + p->hasTexcoord = strstr(vsrc, "attribute vec2 a_texcoord") != 0; p->blend = blendMode; p->cull = cullMode; p->depthTest = depthTest; @@ -436,6 +496,7 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dClear___long_int_boolean_bool } if (clearDepth) { glClearDepthf(1.0f); + glDepthMask(GL_TRUE); mask |= GL_DEPTH_BUFFER_BIT; } glClear(mask); @@ -463,7 +524,8 @@ static GLenum cn1GlPrimitive(int primitive) { } static void cn1GlBindDraw(CN1GlContext* c, CN1GlPipeline* p, GLuint vbo, int strideBytes, - float* uniforms, int uniformFloats, GLuint texture, int texFilter, int texWrap) { + float* uniforms, int uniformFloats, GLuint texture, int texFilter, int texWrap, + int useShadowMap) { glUseProgram(p->program); if (p->blend == 0) { @@ -502,10 +564,25 @@ static void cn1GlBindDraw(CN1GlContext* c, CN1GlPipeline* p, GLuint vbo, int str } glDepthMask(p->depthWrite ? GL_TRUE : GL_FALSE); - /* Uniform block upload. */ - glBindBuffer(GL_UNIFORM_BUFFER, c->ubo); - glBufferData(GL_UNIFORM_BUFFER, (GLsizeiptr) uniformFloats * 4, uniforms, GL_DYNAMIC_DRAW); - glBindBufferBase(GL_UNIFORM_BUFFER, 0, c->ubo); + /* Named uniforms from the packed array (locations were cached at link). */ + if (uniformFloats >= CN1_UNIFORM_FLOATS) { + const float* u = uniforms; + int i; + for (i = 0; i < CN1_UNIFORM_COUNT; i++) { + GLint l = p->loc[i]; + int n = CN1_UNIFORMS[i].size; + if (l >= 0) { + switch (n) { + case 16: glUniformMatrix4fv(l, 1, GL_FALSE, u); break; + case 4: glUniform4fv(l, 1, u); break; + case 3: glUniform3fv(l, 1, u); break; + case 2: glUniform2fv(l, 1, u); break; + default: glUniform1f(l, u[0]); break; + } + } + u += n; + } + } /* Interleaved vertex attributes (position @0, normal @12, texcoord after). */ glBindVertexArray(c->vao); @@ -522,6 +599,12 @@ static void cn1GlBindDraw(CN1GlContext* c, CN1GlPipeline* p, GLuint vbo, int str glDisableVertexAttribArray(1); } if (p->hasTexcoord) { + /* The texcoord is the last two floats of every VertexFormat, so its + * offset comes from the stride, not from what the shader declares: + * an unlit textured shader has no a_normal, yet its buffer may still + * carry normals (POSITION_NORMAL_TEXCOORD), and counting from the + * shader then sampled the normal as the texcoord. */ + offset = strideBytes - 8; glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, strideBytes, (void*) (intptr_t) offset); } else { @@ -536,11 +619,19 @@ static void cn1GlBindDraw(CN1GlContext* c, CN1GlPipeline* p, GLuint vbo, int str glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, texFilter ? GL_LINEAR : GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, texWrap ? GL_REPEAT : GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, texWrap ? GL_REPEAT : GL_CLAMP_TO_EDGE); - glUniform1i(glGetUniformLocation(p->program, "cn1_tex"), 0); + if (p->uTexture >= 0) { + glUniform1i(p->uTexture, 0); + } + } + if (useShadowMap && c->shadow.tex && p->uShadowMap >= 0) { + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, c->shadow.tex); + glUniform1i(p->uShadowMap, 1); + glActiveTexture(GL_TEXTURE0); } } -JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawIndexed___long_long_long_int_long_int_int_float_1ARRAY_int_long_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_LONG iboPeer, JAVA_INT indexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) { +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawIndexed___long_long_long_int_long_int_int_float_1ARRAY_int_long_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_LONG iboPeer, JAVA_INT indexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap, JAVA_INT shadowMap) { CN1GlContext* c = (CN1GlContext*) (intptr_t) contextPeer; CN1GlPipeline* p = (CN1GlPipeline*) (intptr_t) pipelinePeer; if (!c || !p || uniforms == JAVA_NULL) { @@ -548,12 +639,12 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawIndexed___long_long_long_ } cn1GlMakeCurrent(c); cn1GlBindDraw(c, p, (GLuint) vboPeer, strideBytes, (float*) CN1_ARRAY_DATA(uniforms), - uniformFloats, (GLuint) texturePeer, texFilter, texWrap); + uniformFloats, (GLuint) texturePeer, texFilter, texWrap, shadowMap); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, (GLuint) iboPeer); glDrawElements(cn1GlPrimitive(primitive), indexCount, GL_UNSIGNED_SHORT, 0); } -JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawArrays___long_long_long_int_int_int_float_1ARRAY_int_long_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_INT vertexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) { +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawArrays___long_long_long_int_int_int_float_1ARRAY_int_long_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_INT vertexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap, JAVA_INT shadowMap) { CN1GlContext* c = (CN1GlContext*) (intptr_t) contextPeer; CN1GlPipeline* p = (CN1GlPipeline*) (intptr_t) pipelinePeer; if (!c || !p || uniforms == JAVA_NULL) { @@ -561,6 +652,221 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dDrawArrays___long_long_long_i } cn1GlMakeCurrent(c); cn1GlBindDraw(c, p, (GLuint) vboPeer, strideBytes, (float*) CN1_ARRAY_DATA(uniforms), - uniformFloats, (GLuint) texturePeer, texFilter, texWrap); + uniformFloats, (GLuint) texturePeer, texFilter, texWrap, shadowMap); glDrawArrays(cn1GlPrimitive(primitive), 0, vertexCount); } + +/* ------------------------------------------------- shadow pass and post */ + +static void cn1GlSaveTarget(CN1GlContext* c) { + glGetIntegerv(GL_FRAMEBUFFER_BINDING, &c->savedFbo); + glGetIntegerv(GL_VIEWPORT, c->savedVp); +} + +static void cn1GlRestoreTarget(CN1GlContext* c) { + glBindFramebuffer(GL_FRAMEBUFFER, (GLuint) c->savedFbo); + glViewport(c->savedVp[0], c->savedVp[1], c->savedVp[2], c->savedVp[3]); +} + +/* (Re)creates an RGBA8 colour target, with a 16 bit depth renderbuffer when + * asked, unless one of that size exists. Leaves its FBO bound. */ +static int cn1GlEnsureTarget(CN1GlTarget* t, int w, int h, int depth, GLint filter) { + GLenum status; + if (t->fbo && t->w == w && t->h == h) { + glBindFramebuffer(GL_FRAMEBUFFER, t->fbo); + return 1; + } + cn1GlReleaseTarget(t); + t->w = w; + t->h = h; + glGenTextures(1, &t->tex); + glBindTexture(GL_TEXTURE_2D, t->tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glBindTexture(GL_TEXTURE_2D, 0); + glGenFramebuffers(1, &t->fbo); + glBindFramebuffer(GL_FRAMEBUFFER, t->fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, t->tex, 0); + if (depth) { + glGenRenderbuffers(1, &t->depth); + glBindRenderbuffer(GL_RENDERBUFFER, t->depth); + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT16, w, h); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, t->depth); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + } + status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + cn1GlReleaseTarget(t); + return 0; + } + return 1; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_gl3dBeginShadowPass___long_int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_INT size) { + CN1GlContext* c = (CN1GlContext*) (intptr_t) contextPeer; + if (!c || size <= 0) { + return JAVA_FALSE; + } + cn1GlMakeCurrent(c); + cn1GlSaveTarget(c); + /* packed depth must not be interpolated between texels */ + if (!cn1GlEnsureTarget(&c->shadow, size, size, 1, GL_NEAREST)) { + cn1GlRestoreTarget(c); + return JAVA_FALSE; + } + glViewport(0, 0, size, size); + glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + glClearDepthf(1.0f); + glDepthMask(GL_TRUE); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + return JAVA_TRUE; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dEndShadowPass___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer) { + CN1GlContext* c = (CN1GlContext*) (intptr_t) contextPeer; + if (c) { + cn1GlMakeCurrent(c); + cn1GlRestoreTarget(c); + } +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_gl3dBeginPost___long_boolean_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_BOOLEAN bloom) { + CN1GlContext* c = (CN1GlContext*) (intptr_t) contextPeer; + int w, h; + if (!c) { + return JAVA_FALSE; + } + cn1GlMakeCurrent(c); + cn1GlSaveTarget(c); + w = c->savedVp[2]; + h = c->savedVp[3]; + if (w <= 0 || h <= 0) { + return JAVA_FALSE; + } + if (!cn1GlEnsureTarget(&c->scene, w, h, 1, GL_LINEAR)) { + cn1GlRestoreTarget(c); + return JAVA_FALSE; + } + if (bloom) { + int bw = w / 4 > 1 ? w / 4 : 1; + int bh = h / 4 > 1 ? h / 4 : 1; + if (!cn1GlEnsureTarget(&c->bloomA, bw, bh, 0, GL_LINEAR) + || !cn1GlEnsureTarget(&c->bloomB, bw, bh, 0, GL_LINEAR)) { + cn1GlRestoreTarget(c); + return JAVA_FALSE; + } + } + glBindFramebuffer(GL_FRAMEBUFFER, c->scene.fbo); + glViewport(0, 0, w, h); + return JAVA_TRUE; +} + +static void cn1GlSampler(GLuint program, const char* name, int unit, GLuint tex) { + GLint l = glGetUniformLocation(program, name); + if (l >= 0) { + glActiveTexture(GL_TEXTURE0 + unit); + glBindTexture(GL_TEXTURE_2D, tex); + glUniform1i(l, unit); + glActiveTexture(GL_TEXTURE0); + } +} + +static void cn1GlUniform1(GLuint program, const char* name, float a) { + GLint l = glGetUniformLocation(program, name); + if (l >= 0) glUniform1f(l, a); +} + +static void cn1GlUniform2(GLuint program, const char* name, float a, float b) { + GLint l = glGetUniformLocation(program, name); + if (l >= 0) glUniform2f(l, a, b); +} + +static void cn1GlUniform3(GLuint program, const char* name, float a, float b, float d) { + GLint l = glGetUniformLocation(program, name); + if (l >= 0) glUniform3f(l, a, b, d); +} + +static void cn1GlPassInto(const CN1GlTarget* t) { + glBindFramebuffer(GL_FRAMEBUFFER, t->fbo); + glViewport(0, 0, t->w, t->h); +} + +/* Full screen triangle strip with a_position in clip space. */ +static void cn1GlQuad(CN1GlContext* c) { + static const float quad[12] = {-1.f, -1.f, 0.f, 1.f, -1.f, 0.f, -1.f, 1.f, 0.f, 1.f, 1.f, 0.f}; + glBindVertexArray(c->vao); + if (!c->quadVbo) { + glGenBuffers(1, &c->quadVbo); + glBindBuffer(GL_ARRAY_BUFFER, c->quadVbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + } + glBindBuffer(GL_ARRAY_BUFFER, c->quadVbo); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 12, (void*) 0); + glDisableVertexAttribArray(1); + glDisableVertexAttribArray(2); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindBuffer(GL_ARRAY_BUFFER, 0); +} + +/* params: threshold, bloomIntensity, contrast, saturation, lift rgb, gain rgb, + * vignette, grain, time (13 floats). */ +JAVA_VOID com_codename1_impl_linux_LinuxNative_gl3dEndPost___long_long_long_long_boolean_float_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_LONG contextPeer, JAVA_LONG brightPeer, JAVA_LONG blurPeer, JAVA_LONG compositePeer, JAVA_BOOLEAN bloom, JAVA_OBJECT params) { + CN1GlContext* c = (CN1GlContext*) (intptr_t) contextPeer; + CN1GlPipeline* bright = (CN1GlPipeline*) (intptr_t) brightPeer; + CN1GlPipeline* blur = (CN1GlPipeline*) (intptr_t) blurPeer; + CN1GlPipeline* comp = (CN1GlPipeline*) (intptr_t) compositePeer; + const float* pr; + GLuint bloomTex; + int i; + if (!c || !comp || params == JAVA_NULL || !c->scene.fbo) { + return; + } + pr = (const float*) CN1_ARRAY_DATA(params); + cn1GlMakeCurrent(c); + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + glDisable(GL_BLEND); + glDisable(GL_CULL_FACE); + bloomTex = c->scene.tex; + if (bloom && bright && blur && c->bloomA.fbo && c->bloomB.fbo) { + cn1GlPassInto(&c->bloomA); + glUseProgram(bright->program); + cn1GlSampler(bright->program, "u_scene", 0, c->scene.tex); + cn1GlUniform1(bright->program, "u_threshold", pr[0]); + cn1GlUniform2(bright->program, "u_texel", 1.0f / c->scene.w, 1.0f / c->scene.h); + cn1GlQuad(c); + for (i = 0; i < 2; i++) { + float spread = 1.0f + (float) i; + cn1GlPassInto(&c->bloomB); + glUseProgram(blur->program); + cn1GlSampler(blur->program, "u_scene", 0, c->bloomA.tex); + cn1GlUniform2(blur->program, "u_dir", spread / c->bloomA.w, 0.0f); + cn1GlQuad(c); + cn1GlPassInto(&c->bloomA); + cn1GlSampler(blur->program, "u_scene", 0, c->bloomB.tex); + cn1GlUniform2(blur->program, "u_dir", 0.0f, spread / c->bloomA.h); + cn1GlQuad(c); + } + bloomTex = c->bloomA.tex; + } else { + bloom = JAVA_FALSE; + } + cn1GlRestoreTarget(c); + glUseProgram(comp->program); + cn1GlSampler(comp->program, "u_scene", 0, c->scene.tex); + cn1GlSampler(comp->program, "u_bloom", 1, bloomTex); + cn1GlUniform1(comp->program, "u_bloomIntensity", bloom ? pr[1] : 0.0f); + cn1GlUniform2(comp->program, "u_grade", pr[2], pr[3]); + cn1GlUniform3(comp->program, "u_lift", pr[4], pr[5], pr[6]); + cn1GlUniform3(comp->program, "u_gain", pr[7], pr[8], pr[9]); + cn1GlUniform1(comp->program, "u_vignette", pr[10]); + cn1GlUniform1(comp->program, "u_grain", pr[11]); + cn1GlUniform1(comp->program, "u_time", pr[12]); + cn1GlUniform2(comp->program, "u_resolution", (float) c->scene.w, (float) c->scene.h); + cn1GlQuad(c); + glDepthMask(GL_TRUE); +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gpu.c b/Ports/LinuxPort/nativeSources/cn1_linux_gpu.c index e468fde7be4..68fad1fc18d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_gpu.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gpu.c @@ -108,6 +108,34 @@ static GLuint cn1gBatchTexture = 0; static int cn1gBatchNearest = 0; static pthread_mutex_t cn1gDeadLock = PTHREAD_MUTEX_INITIALIZER; + +/* Every public entry point holds this (recursive: Sync reads through ReadRect, + * Shutdown hides the cursor). The process exits through atexit on whichever + * thread called exit(), while the EDT may still be inside a draw; without it + * cn1GpuShutdown freed the mask atlas under a running memcpy. */ +static pthread_mutex_t cn1gUseLock; +static pthread_once_t cn1gUseOnce = PTHREAD_ONCE_INIT; + +static void cn1gUseInit(void) { + pthread_mutexattr_t a; + pthread_mutexattr_init(&a); + pthread_mutexattr_settype(&a, PTHREAD_MUTEX_RECURSIVE); + pthread_mutex_init(&cn1gUseLock, &a); + pthread_mutexattr_destroy(&a); +} + +static void cn1gUse(void) { + pthread_once(&cn1gUseOnce, cn1gUseInit); + pthread_mutex_lock(&cn1gUseLock); +} +static int cn1GpuAttachWindowUnlocked(cairo_surface_t* window); +static void cn1GpuDrawUnlocked(cairo_surface_t* target, int x0, int y0, int x1, int y1, + const CN1GpuSource* src, unsigned alpha, int op, const unsigned char* cov, int pitch); +static int cn1GpuReadRectUnlocked(cairo_surface_t* target, int x, int y, int w, int h); +static void cn1GpuSyncUnlocked(cairo_surface_t* target); +static void cn1GpuPresentUnlocked(void); +static int cn1GpuCursorUnlocked(int show, int px, int py); +static void cn1GpuShutdownUnlocked(void); static GLuint* cn1gDead = 0; static int cn1gDeadCount = 0, cn1gDeadCapacity = 0; @@ -493,7 +521,7 @@ int cn1GpuScansOut(void) { return cn1gScanout; } -int cn1GpuAttachWindow(cairo_surface_t* window) { +static int cn1GpuAttachWindowUnlocked(cairo_surface_t* window) { if (!cn1gActive || window == 0 || !cn1gMakeCurrent()) { return -1; } @@ -780,7 +808,7 @@ static void cn1gQuad(float x0, float y0, float x1, float y1, const CN1GpuSource* cn1gQuads++; } -void cn1GpuDraw(cairo_surface_t* target, int x0, int y0, int x1, int y1, +static void cn1GpuDrawUnlocked(cairo_surface_t* target, int x0, int y0, int x1, int y1, const CN1GpuSource* src, unsigned alpha, int op, const unsigned char* cov, int pitch) { GLuint texture = 0; int nearest = 0; @@ -879,7 +907,7 @@ static void cn1gSwizzle(uint32_t* p, size_t n) { } } -int cn1GpuReadRect(cairo_surface_t* target, int x, int y, int w, int h) { +static int cn1GpuReadRectUnlocked(cairo_surface_t* target, int x, int y, int w, int h) { /* A read from a thread that does not paint -- initDisplay's first present, * a capture -- leaves the context free for the one that does. */ int borrowed = eglGetCurrentContext() != cn1gCtx; @@ -917,7 +945,7 @@ static void cn1gReadRect(cairo_surface_t* target, int x, int y, int w, int h) { } } -void cn1GpuSync(cairo_surface_t* target) { +static void cn1GpuSyncUnlocked(cairo_surface_t* target) { if (target == 0 || target != cn1gWindow) { return; } @@ -1000,7 +1028,7 @@ static void cn1gCorner(float px, float py, float* u, float* v) { *v = ly / (float) cn1gH; } -void cn1GpuPresent(void) { +static void cn1GpuPresentUnlocked(void) { float quad[16]; float pxs[4], pys[4]; struct gbm_bo* bo; @@ -1122,7 +1150,7 @@ static int cn1gCursorBuffer(void) { return 0; } -int cn1GpuCursor(int show, int px, int py) { +static int cn1GpuCursorUnlocked(int show, int px, int py) { if (!cn1gScanout) { return 0; } @@ -1145,7 +1173,7 @@ int cn1GpuCursor(int show, int px, int py) { return drmModeMoveCursor(cn1gDrmFd, cn1gCrtc, px - 32, py - 32) == 0; } -void cn1GpuShutdown(void) { +static void cn1GpuShutdownUnlocked(void) { if (!cn1gActive) { return; } @@ -1159,6 +1187,57 @@ void cn1GpuShutdown(void) { cn1gTearDown(); } +/* ---- locked entry points ---- */ + +int cn1GpuAttachWindow(cairo_surface_t* window) { + int r; + cn1gUse(); + r = cn1GpuAttachWindowUnlocked(window); + pthread_mutex_unlock(&cn1gUseLock); + return r; +} + +void cn1GpuDraw(cairo_surface_t* target, int x0, int y0, int x1, int y1, + const CN1GpuSource* src, unsigned alpha, int op, const unsigned char* cov, int pitch) { + cn1gUse(); + cn1GpuDrawUnlocked(target, x0, y0, x1, y1, src, alpha, op, cov, pitch); + pthread_mutex_unlock(&cn1gUseLock); +} + +int cn1GpuReadRect(cairo_surface_t* target, int x, int y, int w, int h) { + int r; + cn1gUse(); + r = cn1GpuReadRectUnlocked(target, x, y, w, h); + pthread_mutex_unlock(&cn1gUseLock); + return r; +} + +void cn1GpuSync(cairo_surface_t* target) { + cn1gUse(); + cn1GpuSyncUnlocked(target); + pthread_mutex_unlock(&cn1gUseLock); +} + +void cn1GpuPresent(void) { + cn1gUse(); + cn1GpuPresentUnlocked(); + pthread_mutex_unlock(&cn1gUseLock); +} + +int cn1GpuCursor(int show, int px, int py) { + int r; + cn1gUse(); + r = cn1GpuCursorUnlocked(show, px, py); + pthread_mutex_unlock(&cn1gUseLock); + return r; +} + +void cn1GpuShutdown(void) { + cn1gUse(); + cn1GpuShutdownUnlocked(); + pthread_mutex_unlock(&cn1gUseLock); +} + void cn1GpuShutdownAtExit(void) { /* Runs from exit() on whichever thread called System.exit, while the EDT * may still be in the middle of a frame. Tearing the context down here diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/GlslShaderGenerator.java b/Ports/LinuxPort/src/com/codename1/impl/linux/GlslShaderGenerator.java deleted file mode 100644 index 954207ae080..00000000000 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/GlslShaderGenerator.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - */ -package com.codename1.impl.linux; - -import com.codename1.gpu.Material; -import com.codename1.gpu.VertexAttribute; -import com.codename1.gpu.VertexFormat; - -/// Runtime generator of GLSL ES 3.0 source for the native Linux 3D backend -/// (OpenGL ES via EGL, cn1_linux_gl.c). It mirrors the HLSL generator of the -/// Windows port and the iOS Metal generator, but emits the vertex and fragment -/// stages as GLSL ES 3.00 packed into a single source string separated by the -/// {@link #STAGE_SEPARATOR} marker; the native side splits on the marker, -/// compiles the two stages and caches the program as a pipeline keyed by the -/// material+format key. -/// -/// The generated shaders follow a fixed contract the native renderer relies on: -/// -/// - vertex attributes are bound by explicit location: 0 = position, 1 = normal, -/// 2 = texcoord, in the interleaved order the VertexFormat lists them -/// - one std140 uniform block {@code CN1Uniforms} (mvp, model, normalMatrix, -/// color, lightDir, lightColor, ambient, eye, shininess) uploaded from the same -/// column-major float[16] matrices the Java side packs (72 floats: 69 used + -/// std140 tail padding). GL's {@code mvp * v} needs no transpose or Z remap. -/// - the diffuse texture is the sampler2D {@code cn1_tex} -public final class GlslShaderGenerator { - /// Marker separating the vertex stage from the fragment stage in the combined - /// source; the native compiler splits on it. - public static final String STAGE_SEPARATOR = "//@@CN1_FRAGMENT@@"; - - private final String source; - - public GlslShaderGenerator(Material material, VertexFormat format) { - boolean hasNormal = format.findByUsage(VertexAttribute.Usage.NORMAL) != null; - boolean hasTexcoord = format.findByUsage(VertexAttribute.Usage.TEXCOORD) != null; - boolean textured = material.getTexture() != null && hasTexcoord; - Material.Type type = material.getType(); - boolean lit = (type == Material.Type.LAMBERT || type == Material.Type.PHONG) && hasNormal; - boolean phong = type == Material.Type.PHONG && hasNormal; - this.source = build(lit, phong, textured, hasNormal, hasTexcoord); - } - - private static String uniformBlock() { - // std140 layout; must match the float[] packed by LinuxGraphicsDevice and - // uploaded into the UBO by the native renderer. - return "layout(std140) uniform CN1Uniforms {\n" - + " mat4 mvp;\n" - + " mat4 model;\n" - + " mat4 normalMatrix;\n" - + " vec4 color;\n" - + " vec4 lightDir;\n" - + " vec4 lightColor;\n" - + " vec4 ambient;\n" - + " vec4 eye;\n" - + " float shininess;\n" - + "};\n"; - } - - private static String build(boolean lit, boolean phong, boolean textured, - boolean hasNormal, boolean hasTexcoord) { - StringBuilder sb = new StringBuilder(); - - // ---- vertex stage ---- - sb.append("#version 300 es\n"); - sb.append(uniformBlock()); - sb.append("layout(location = 0) in vec3 position;\n"); - if (hasNormal) { - sb.append("layout(location = 1) in vec3 normal;\n"); - } - if (hasTexcoord) { - sb.append("layout(location = 2) in vec2 texcoord;\n"); - } - if (lit) { - sb.append("out vec3 vWorldNormal;\n"); - sb.append("out vec3 vWorldPos;\n"); - } - if (textured) { - sb.append("out vec2 vTexcoord;\n"); - } - sb.append("void main() {\n"); - sb.append(" gl_Position = mvp * vec4(position, 1.0);\n"); - if (lit) { - sb.append(" vWorldNormal = (normalMatrix * vec4(normal, 0.0)).xyz;\n"); - sb.append(" vWorldPos = (model * vec4(position, 1.0)).xyz;\n"); - } - if (textured) { - sb.append(" vTexcoord = texcoord;\n"); - } - sb.append("}\n"); - - sb.append(STAGE_SEPARATOR).append("\n"); - - // ---- fragment stage ---- - sb.append("#version 300 es\n"); - sb.append("precision highp float;\n"); - sb.append(uniformBlock()); - if (textured) { - sb.append("uniform sampler2D cn1_tex;\n"); - } - if (lit) { - sb.append("in vec3 vWorldNormal;\n"); - sb.append("in vec3 vWorldPos;\n"); - } - if (textured) { - sb.append("in vec2 vTexcoord;\n"); - } - sb.append("out vec4 fragColor;\n"); - sb.append("void main() {\n"); - sb.append(" vec4 base = color;\n"); - if (textured) { - sb.append(" base = base * texture(cn1_tex, vTexcoord);\n"); - } - if (lit) { - sb.append(" vec3 n = normalize(vWorldNormal);\n"); - sb.append(" vec3 l = normalize(-lightDir.xyz);\n"); - sb.append(" float ndotl = max(dot(n, l), 0.0);\n"); - sb.append(" vec3 lighting = ambient.xyz + lightColor.xyz * ndotl;\n"); - sb.append(" vec3 rgb = base.rgb * lighting;\n"); - if (phong) { - sb.append(" if (ndotl > 0.0) {\n"); - sb.append(" vec3 v = normalize(eye.xyz - vWorldPos);\n"); - sb.append(" vec3 h = normalize(l + v);\n"); - sb.append(" float spec = pow(max(dot(n, h), 0.0), shininess);\n"); - sb.append(" rgb += lightColor.xyz * spec;\n"); - sb.append(" }\n"); - } - sb.append(" fragColor = vec4(rgb, base.a);\n"); - } else { - sb.append(" fragColor = base;\n"); - } - sb.append("}\n"); - return sb.toString(); - } - - /// Returns the combined GLSL source (vertex stage, {@link #STAGE_SEPARATOR}, - /// fragment stage). - public String getSource() { - return source; - } -} diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGraphicsDevice.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGraphicsDevice.java index a79d18b3e33..5ff4a3e9c90 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGraphicsDevice.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGraphicsDevice.java @@ -6,10 +6,24 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.linux; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -17,26 +31,28 @@ import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.PrimitiveType; import com.codename1.gpu.RenderState; import com.codename1.gpu.Texture; import com.codename1.gpu.VertexBuffer; import com.codename1.gpu.VertexFormat; +import com.codename1.impl.gpu.GlslShaderGenerator; import com.codename1.ui.Image; import java.util.HashMap; import java.util.Map; -/// Direct3D 11 implementation of the Codename One 3D `GraphicsDevice` for the -/// native Linux port. Like the iOS Metal device, it owns no GPU state itself: -/// it forwards every operation to a native D3D11 context (cn1_linux_d3d.cpp) -/// through the `LinuxNative` bridge, passing vertex/index/uniform payloads as -/// plain Java arrays that the native side copies into D3D buffers. +/// OpenGL ES implementation of the Codename One 3D `GraphicsDevice` for the +/// native Linux port. It owns no GPU state itself: it forwards every operation +/// to a native EGL/GLES context (cn1_linux_gl.c) through the `LinuxNative` +/// bridge, passing vertex/index/uniform payloads as plain Java arrays. /// -/// Shaders are generated here in Java (`GlslShaderGenerator`): the material plus -/// the mesh vertex format produce an HLSL source string the native context -/// compiles once (D3DCompile) and caches as a pipeline keyed by the material -/// shader key, the vertex stride and the render state. +/// Shaders come from the shared core `GlslShaderGenerator` (GLSL ES 1.00): the +/// material, the mesh vertex format and the environment produce a vertex and a +/// fragment source the native context compiles once and caches as a pipeline. +/// The shadow pass and the post process chain run natively against offscreen +/// targets; this class decides when and with which shaders and parameters. class LinuxGraphicsDevice extends GraphicsDevice { /// Opaque handle to the native D3D11 context. Zero before creation / after /// disposal. @@ -45,16 +61,34 @@ class LinuxGraphicsDevice extends GraphicsDevice { private final Map pipelines = new HashMap(); private final GpuCapabilities caps = new GpuCapabilities( - 8192, 16, true, true, true, "Codename One OpenGL ES 3.0 (Linux)"); + 8192, 16, true, true, true, "Codename One OpenGL ES 3.0 (Linux)", true, true); - private final float[] mvp = new float[16]; + // Separates the vertex stage from the fragment stage in the source handed + // to the native compiler; must equal CN1_STAGE_SEP in cn1_linux_gl.c. + private static final String STAGE_SEPARATOR = "//@@CN1_FRAGMENT@@"; - // Uniform block layout must match the CN1Uniforms cbuffer emitted by - // GlslShaderGenerator and copied on the native side: 3 mat4 (48) + 5 vec4 - // (20) + shininess (1), padded to a multiple of 4 floats (16 bytes). - private static final int UNIFORM_FLOATS = 72; + private final float[] mvp = new float[16]; + private final float[] shadowMatrix = new float[16]; + + // The packed uniform array; the order must match CN1_UNIFORMS in + // cn1_linux_gl.c: u_mvp, u_model, u_normalMatrix, u_shadowMatrix (16 each), + // u_color(4), u_lightDir(3), u_lightColor(3), u_ambient(3), u_eye(3), + // u_shininess(1), u_fogColor(3), u_fog(3), u_sky(3), u_ground(3), + // u_exposure(1), u_pbr(2), u_emissive(3), u_zenith(3), u_horizon(3), + // u_sun(4), u_shadow(3). + private static final int UNIFORM_FLOATS = 112; private final float[] uniforms = new float[UNIFORM_FLOATS]; + private long shadowPipeline; + private long brightPipeline; + private long blurPipeline; + private long compositePipeline; + private boolean inShadowPass; + private boolean shadowReady; + private int shadowSize; + private PostProcess activePost; + private final float[] postParams = new float[13]; + LinuxGraphicsDevice(long contextPeer) { this.contextPeer = contextPeer; } @@ -94,20 +128,27 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { if (contextPeer == 0) { return; } + if (inShadowPass) { + drawShadowCaster(mesh, material, modelMatrix); + return; + } VertexBuffer vb = mesh.getVertices(); VertexFormat fmt = vb.getFormat(); + Environment env = getEnvironment(); + boolean shadowed = shadowReady && isShadowMapCurrent() && getLight().isShadows() && material.isLit() + && material.isReceiveShadows(); long vboHandle = uploadVertexBuffer(vb); if (vboHandle == 0) { return; } - long pipeline = getOrCreatePipeline(material, fmt); + long pipeline = getOrCreatePipeline(material, fmt, env, shadowed); if (pipeline == 0) { return; } float[] model = modelMatrix != null ? modelMatrix : Matrix4.identity(); - packUniforms(material, model); + packUniforms(material, model, env, shadowed); long texHandle = 0; int texFilter = 0; @@ -131,12 +172,12 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { LinuxNative.gl3dDrawIndexed( contextPeer, pipeline, vboHandle, strideBytes, iboHandle, ib.getIndexCount(), primitive, uniforms, UNIFORM_FLOATS, - texHandle, texFilter, texWrap); + texHandle, texFilter, texWrap, shadowed ? 1 : 0); } else { LinuxNative.gl3dDrawArrays( contextPeer, pipeline, vboHandle, strideBytes, vb.getVertexCount(), primitive, uniforms, UNIFORM_FLOATS, - texHandle, texFilter, texWrap); + texHandle, texFilter, texWrap, shadowed ? 1 : 0); } } @@ -174,10 +215,13 @@ private long uploadIndexBuffer(IndexBuffer ib) { return peer; } - private long getOrCreatePipeline(Material material, VertexFormat fmt) { + private long getOrCreatePipeline(Material material, VertexFormat fmt, Environment env, + boolean shadowed) { RenderState rs = material.getRenderState(); String key = material.getShaderKey() + + "|" + GlslShaderGenerator.variantKey(env, shadowed) + "|s" + fmt.getStrideBytes() + + "|f" + System.identityHashCode(fmt) + "|b" + blendCode(rs.getBlendMode()) + "|c" + cullCode(rs.getCullMode()) + "|dt" + (rs.isDepthTest() ? 1 : 0) @@ -186,18 +230,38 @@ private long getOrCreatePipeline(Material material, VertexFormat fmt) { if (existing != null) { return existing.longValue(); } - GlslShaderGenerator gen = new GlslShaderGenerator(material, fmt); - long pipeline = LinuxNative.gl3dGetOrCreatePipeline( - contextPeer, key, gen.getSource(), + GlslShaderGenerator gen = new GlslShaderGenerator(material, fmt, env, shadowed); + long pipeline = createPipeline(key, gen.getVertexSource(), gen.getFragmentSource(), blendCode(rs.getBlendMode()), cullCode(rs.getCullMode()), rs.isDepthTest() ? 1 : 0, rs.isDepthWrite() ? 1 : 0); pipelines.put(key, Long.valueOf(pipeline)); return pipeline; } - // Packs the per-draw uniform block. Ordering matches the CN1Uniforms cbuffer - // in the generated HLSL. - private void packUniforms(Material material, float[] model) { + private long createPipeline(String key, String vertex, String fragment, + int blend, int cull, int depthTest, int depthWrite) { + return LinuxNative.gl3dGetOrCreatePipeline(contextPeer, key, + vertex + STAGE_SEPARATOR + "\n" + fragment, blend, cull, depthTest, depthWrite); + } + + private long namedPipeline(String key, String vertex, String fragment) { + Long existing = pipelines.get(key); + if (existing != null) { + return existing.longValue(); + } + long p = createPipeline(key, vertex, fragment, 0, 0, 1, 1); + pipelines.put(key, Long.valueOf(p)); + return p; + } + + private static void rgb(float[] u, int o, int argb) { + u[o] = ((argb >> 16) & 0xff) / 255.0f; + u[o + 1] = ((argb >> 8) & 0xff) / 255.0f; + u[o + 2] = (argb & 0xff) / 255.0f; + } + + // Packs the per-draw uniforms in the order of CN1_UNIFORMS in cn1_linux_gl.c. + private void packUniforms(Material material, float[] model, Environment env, boolean shadowed) { Camera cam = getCamera(); float[] vp = cam != null ? cam.getViewProjection() : Matrix4.identity(); Matrix4.multiply(vp, model, mvp); @@ -213,6 +277,9 @@ private void packUniforms(Material material, float[] model) { for (int i = 0; i < 16; i++) { uniforms[o++] = nm[i]; } + for (int i = 0; i < 16; i++) { + uniforms[o++] = shadowed ? shadowMatrix[i] : 0.0f; + } int mc = material.getColor(); uniforms[o++] = ((mc >> 16) & 0xff) / 255.0f; uniforms[o++] = ((mc >> 8) & 0xff) / 255.0f; @@ -223,22 +290,156 @@ private void packUniforms(Material material, float[] model) { uniforms[o++] = light.getDirectionX(); uniforms[o++] = light.getDirectionY(); uniforms[o++] = light.getDirectionZ(); - uniforms[o++] = 0.0f; - int lc = light.getColor(); - uniforms[o++] = ((lc >> 16) & 0xff) / 255.0f; - uniforms[o++] = ((lc >> 8) & 0xff) / 255.0f; - uniforms[o++] = (lc & 0xff) / 255.0f; - uniforms[o++] = 1.0f; - int ac = light.getAmbientColor(); - uniforms[o++] = ((ac >> 16) & 0xff) / 255.0f; - uniforms[o++] = ((ac >> 8) & 0xff) / 255.0f; - uniforms[o++] = (ac & 0xff) / 255.0f; - uniforms[o++] = 1.0f; + rgb(uniforms, o, light.getColor()); + o += 3; + rgb(uniforms, o, light.getAmbientColor()); + o += 3; uniforms[o++] = cam != null ? cam.getEyeX() : 0.0f; uniforms[o++] = cam != null ? cam.getEyeY() : 0.0f; uniforms[o++] = cam != null ? cam.getEyeZ() : 0.0f; - uniforms[o++] = 1.0f; uniforms[o++] = material.getShininess(); + + rgb(uniforms, o, env.getFogColor()); + o += 3; + uniforms[o++] = env.getFogDensity(); + uniforms[o++] = env.getFogHeightFalloff(); + uniforms[o++] = env.getFogBaseHeight(); + rgb(uniforms, o, env.getSkyColor()); + o += 3; + rgb(uniforms, o, env.getGroundColor()); + o += 3; + uniforms[o++] = env.getExposure(); + uniforms[o++] = material.getMetallic(); + uniforms[o++] = material.getRoughness(); + rgb(uniforms, o, material.getEmissive()); + o += 3; + if (env.isSky()) { + rgb(uniforms, o, env.getZenithColor()); + o += 3; + rgb(uniforms, o, env.getHorizonColor()); + o += 3; + rgb(uniforms, o, env.getSunColor()); + o += 3; + uniforms[o++] = env.getCloudCover(); + } else { + // zeroed rather than skipped: the array is reused, and a stale + // sky from the previous draw must not reach a shader that reads it + for (int i = 0; i < 10; i++) { + uniforms[o++] = 0f; + } + } + if (shadowed) { + uniforms[o++] = light.getShadowBias(); + uniforms[o++] = light.getShadowStrength(); + uniforms[o++] = shadowSize > 0 ? 1.0f / shadowSize : 0.0f; + } else { + uniforms[o++] = 0f; + uniforms[o++] = 0f; + uniforms[o++] = 0f; + } + } + + public boolean beginShadowPass(Light light) { + if (contextPeer == 0) { + return false; + } + if (shadowPipeline == 0) { + shadowPipeline = namedPipeline("cn1|shadow", GlslShaderGenerator.shadowVertex(), + GlslShaderGenerator.shadowFragment()); + } + int size = light.getShadowMapSize(); + if (shadowPipeline == 0 || !LinuxNative.gl3dBeginShadowPass(contextPeer, size)) { + return false; + } + shadowSize = size; + light.computeShadowMatrix(shadowMatrix); + inShadowPass = true; + return true; + } + + public void endShadowPass() { + if (!inShadowPass) { + return; + } + inShadowPass = false; + shadowReady = true; + shadowMapRendered(); + LinuxNative.gl3dEndShadowPass(contextPeer); + } + + // Depth only draw of one caster into the shadow map: the shadow program reads + // u_mvp (light space) and a_position only. + private void drawShadowCaster(Mesh mesh, Material material, float[] modelMatrix) { + if (!material.isCastShadows() + || material.getRenderState().getBlendMode() != RenderState.BlendMode.NONE + || material.getType() == Material.Type.SKYBOX) { + return; + } + VertexBuffer vb = mesh.getVertices(); + long vboHandle = uploadVertexBuffer(vb); + if (vboHandle == 0 || shadowPipeline == 0) { + return; + } + float[] model = modelMatrix != null ? modelMatrix : Matrix4.identity(); + Matrix4.multiply(shadowMatrix, model, mvp); + for (int i = 0; i < 16; i++) { + uniforms[i] = mvp[i]; + } + int primitive = primitiveCode(mesh.getPrimitiveType()); + int strideBytes = vb.getFormat().getStrideBytes(); + if (mesh.isIndexed()) { + IndexBuffer ib = mesh.getIndices(); + long iboHandle = uploadIndexBuffer(ib); + if (iboHandle == 0) { + return; + } + LinuxNative.gl3dDrawIndexed(contextPeer, shadowPipeline, vboHandle, strideBytes, + iboHandle, ib.getIndexCount(), primitive, uniforms, UNIFORM_FLOATS, 0, 0, 0, 0); + } else { + LinuxNative.gl3dDrawArrays(contextPeer, shadowPipeline, vboHandle, strideBytes, + vb.getVertexCount(), primitive, uniforms, UNIFORM_FLOATS, 0, 0, 0, 0); + } + } + + public boolean beginPostProcess(PostProcess effects, int width, int height) { + if (contextPeer == 0 || effects == null) { + return false; + } + if (compositePipeline == 0) { + String v = GlslShaderGenerator.postVertex(); + brightPipeline = namedPipeline("cn1|bright", v, GlslShaderGenerator.brightFragment()); + blurPipeline = namedPipeline("cn1|blur", v, GlslShaderGenerator.blurFragment()); + compositePipeline = namedPipeline("cn1|composite", v, + GlslShaderGenerator.compositeFragment()); + } + if (compositePipeline == 0 || brightPipeline == 0 || blurPipeline == 0) { + return false; + } + if (!LinuxNative.gl3dBeginPost(contextPeer, effects.isBloom())) { + return false; + } + activePost = effects; + return true; + } + + public void endPostProcess(float timeSeconds) { + PostProcess pp = activePost; + if (pp == null) { + return; + } + activePost = null; + float[] p = postParams; + p[0] = pp.getBloomThreshold(); + p[1] = pp.getBloomIntensity(); + p[2] = pp.getContrast(); + p[3] = pp.getSaturation(); + rgb(p, 4, pp.getShadowTint()); + rgb(p, 7, pp.getHighlightTint()); + p[10] = pp.getVignette(); + p[11] = pp.getGrain(); + p[12] = timeSeconds; + LinuxNative.gl3dEndPost(contextPeer, brightPipeline, blurPipeline, compositePipeline, + pp.isBloom(), p); } private static int primitiveCode(PrimitiveType type) { @@ -314,6 +515,15 @@ void destroy() { } } pipelines.clear(); + // the native context owned the shadow and post targets and the + // programs; none of them outlives it + shadowPipeline = 0; + brightPipeline = 0; + blurPipeline = 0; + compositePipeline = 0; + inShadowPass = false; + shadowReady = false; + activePost = null; LinuxNative.gl3dDestroyContext(contextPeer); contextPeer = 0; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 081b530632c..dde06a3054a 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -1215,10 +1215,24 @@ public static native long gl3dGetOrCreatePipeline(long contextPeer, String key, public static native void gl3dDrawIndexed(long contextPeer, long pipelinePeer, long vboPeer, int strideBytes, long iboPeer, int indexCount, int primitive, float[] uniforms, int uniformFloats, - long texturePeer, int texFilter, int texWrap); + long texturePeer, int texFilter, int texWrap, int shadowMap); public static native void gl3dDrawArrays(long contextPeer, long pipelinePeer, long vboPeer, int strideBytes, int vertexCount, int primitive, float[] uniforms, int uniformFloats, - long texturePeer, int texFilter, int texWrap); + long texturePeer, int texFilter, int texWrap, int shadowMap); + + /** Binds a size x size packed-depth shadow target and clears it. */ + public static native boolean gl3dBeginShadowPass(long contextPeer, int size); + /** Restores the target and viewport that were bound at gl3dBeginShadowPass. */ + public static native void gl3dEndShadowPass(long contextPeer); + /** Redirects subsequent draws into an offscreen scene target the size of the viewport. */ + public static native boolean gl3dBeginPost(long contextPeer, boolean bloom); + /** + * Runs the bloom passes and the composite into the target saved at + * gl3dBeginPost. params: threshold, bloomIntensity, contrast, saturation, + * lift rgb, gain rgb, vignette, grain, time. + */ + public static native void gl3dEndPost(long contextPeer, long brightPipeline, long blurPipeline, + long compositePipeline, boolean bloom, float[] params); // ---- SQLite bindings. Implemented in cn1_linux_db.c from the shared cn1_db_sqlite_impl.h. ---- diff --git a/Ports/MacPort/nativeSources/CN1AppKitGL3D.m b/Ports/MacPort/nativeSources/CN1AppKitGL3D.m index 5e7bd5918b0..608931ea394 100644 --- a/Ports/MacPort/nativeSources/CN1AppKitGL3D.m +++ b/Ports/MacPort/nativeSources/CN1AppKitGL3D.m @@ -100,3 +100,18 @@ JAVA_VOID com_codename1_impl_ios_IOSNative_gl3dUpdateFloatBuffer___long_float_1A JAVA_VOID com_codename1_impl_ios_IOSNative_gl3dUpdateShortBuffer___long_short_1ARRAY_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2, JAVA_INT __cn1Arg3) { } +/* Shadows and post processing: false makes the device draw directly. */ +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_gl3dBeginShadowPass___long_int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_LONG __cn1Arg1, JAVA_INT __cn1Arg2) { + return JAVA_FALSE; +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_gl3dEndShadowPass___long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_LONG __cn1Arg1) { +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_gl3dBeginPostProcess___long_java_lang_String_boolean_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2, JAVA_BOOLEAN __cn1Arg3) { + return JAVA_FALSE; +} + +JAVA_VOID com_codename1_impl_ios_IOSNative_gl3dEndPostProcess___long_float_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_LONG __cn1Arg1, JAVA_OBJECT __cn1Arg2) { +} + diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_d3d.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_d3d.cpp index b53b9b0cc0c..7aabfb6eab1 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_d3d.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_d3d.cpp @@ -57,6 +57,17 @@ struct CN1D3DPipeline { ID3D11DepthStencilState* depth; }; +// An auxiliary render target: the shadow map and the post processing buffers. +struct CN1D3DTarget { + ID3D11Texture2D* tex; + ID3D11RenderTargetView* rtv; + ID3D11ShaderResourceView* srv; + ID3D11Texture2D* dsTex; + ID3D11DepthStencilView* dsv; + int width; + int height; +}; + struct CN1D3DContext { ID3D11Device* device; ID3D11DeviceContext* ctx; @@ -70,6 +81,21 @@ struct CN1D3DContext { ID3D11Buffer* cbuffer; // Cached samplers: [filterLinear][wrapRepeat]. ID3D11SamplerState* samplers[2][2]; + // The target ordinary draws and clears go to: the frame target, or the post + // processing scene target between gl3dBeginPost and gl3dEndPost. + ID3D11RenderTargetView* activeRtv; + ID3D11DepthStencilView* activeDsv; + CN1D3DTarget shadow; + CN1D3DTarget scene; + CN1D3DTarget bloomA; + CN1D3DTarget bloomB; + ID3D11Buffer* postCb; + ID3D11Buffer* quad; + D3D11_VIEWPORT savedVp; + // the application's viewport when the post pass began, kept for the + // scene target and restored after the composite, as the other backends do + D3D11_VIEWPORT postVp; + int shadowOn; }; template static void cn1Release(T*& p) { @@ -93,6 +119,73 @@ static void cn1D3DReleaseTargets(CN1D3DContext* c) { cn1Release(c->dsTex); } +static void cn1D3DReleaseTarget(CN1D3DTarget* t) { + cn1Release(t->srv); + cn1Release(t->rtv); + cn1Release(t->tex); + cn1Release(t->dsv); + cn1Release(t->dsTex); + t->width = 0; + t->height = 0; +} + +// (Re)creates an auxiliary render target of the given colour format, with a +// depth buffer when asked. The colour texture can also be sampled. +static bool cn1D3DEnsureAux(CN1D3DContext* c, CN1D3DTarget* t, int w, int h, DXGI_FORMAT fmt, bool depth) { + if (w <= 0 || h <= 0) { + return false; + } + if (t->tex != NULL && t->width == w && t->height == h) { + return true; + } + cn1D3DReleaseTarget(t); + D3D11_TEXTURE2D_DESC td; + ZeroMemory(&td, sizeof(td)); + td.Width = (UINT) w; + td.Height = (UINT) h; + td.MipLevels = 1; + td.ArraySize = 1; + td.Format = fmt; + td.SampleDesc.Count = 1; + td.Usage = D3D11_USAGE_DEFAULT; + td.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + if (FAILED(c->device->CreateTexture2D(&td, NULL, &t->tex)) || + FAILED(c->device->CreateRenderTargetView(t->tex, NULL, &t->rtv)) || + FAILED(c->device->CreateShaderResourceView(t->tex, NULL, &t->srv))) { + cn1D3DReleaseTarget(t); + return false; + } + if (depth) { + D3D11_TEXTURE2D_DESC ds = td; + ds.Format = DXGI_FORMAT_D24_UNORM_S8_UINT; + ds.BindFlags = D3D11_BIND_DEPTH_STENCIL; + if (FAILED(c->device->CreateTexture2D(&ds, NULL, &t->dsTex)) || + FAILED(c->device->CreateDepthStencilView(t->dsTex, NULL, &t->dsv))) { + cn1D3DReleaseTarget(t); + return false; + } + } + t->width = w; + t->height = h; + return true; +} + +static void cn1D3DUnbindPs(CN1D3DContext* c) { + ID3D11ShaderResourceView* none[2] = { NULL, NULL }; + c->ctx->PSSetShaderResources(0, 2, none); +} + +static void cn1D3DSetViewport(CN1D3DContext* c, int w, int h) { + D3D11_VIEWPORT vp; + vp.TopLeftX = 0.0f; + vp.TopLeftY = 0.0f; + vp.Width = (FLOAT) w; + vp.Height = (FLOAT) h; + vp.MinDepth = 0.0f; + vp.MaxDepth = 1.0f; + c->ctx->RSSetViewports(1, &vp); +} + static bool cn1D3DEnsureTargets(CN1D3DContext* c, int w, int h) { if (w <= 0 || h <= 0) { return false; @@ -137,6 +230,8 @@ static bool cn1D3DEnsureTargets(CN1D3DContext* c, int w, int h) { if (FAILED(c->device->CreateDepthStencilView(c->dsTex, NULL, &c->dsv))) { return false; } + c->activeRtv = c->rtv; + c->activeDsv = c->dsv; c->ctx->OMSetRenderTargets(1, &c->rtv, c->dsv); return true; } @@ -269,6 +364,12 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dDestroyContext___long(COD } } cn1Release(c->cbuffer); + cn1Release(c->postCb); + cn1Release(c->quad); + cn1D3DReleaseTarget(&c->shadow); + cn1D3DReleaseTarget(&c->scene); + cn1D3DReleaseTarget(&c->bloomA); + cn1D3DReleaseTarget(&c->bloomB); cn1Release(c->wic); if (g_cn1D3DContext == c->ctx) { g_cn1D3DContext = NULL; } if (g_cn1D3DDevice == c->device) { g_cn1D3DDevice = NULL; } @@ -281,6 +382,8 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dBeginFrame___long_int_int CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; if (!c) return; if (cn1D3DEnsureTargets(c, width, height)) { + c->activeRtv = c->rtv; + c->activeDsv = c->dsv; c->ctx->OMSetRenderTargets(1, &c->rtv, c->dsv); } } @@ -301,16 +404,18 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dSetViewport___long_int_in JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dClear___long_int_boolean_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT argb, JAVA_BOOLEAN clearColor, JAVA_BOOLEAN clearDepth) { CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; if (!c || !c->ctx) return; - if (clearColor && c->rtv) { + ID3D11RenderTargetView* rtv = c->activeRtv ? c->activeRtv : c->rtv; + ID3D11DepthStencilView* dsv = c->activeDsv ? c->activeDsv : c->dsv; + if (clearColor && rtv) { float col[4]; col[0] = ((argb >> 16) & 0xff) / 255.0f; col[1] = ((argb >> 8) & 0xff) / 255.0f; col[2] = (argb & 0xff) / 255.0f; col[3] = ((argb >> 24) & 0xff) / 255.0f; - c->ctx->ClearRenderTargetView(c->rtv, col); + c->ctx->ClearRenderTargetView(rtv, col); } - if (clearDepth && c->dsv) { - c->ctx->ClearDepthStencilView(c->dsv, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0); + if (clearDepth && dsv) { + c->ctx->ClearDepthStencilView(dsv, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0); } } @@ -611,6 +716,17 @@ static void cn1D3DBind(CN1D3DContext* c, CN1D3DPipeline* p, ID3D11Buffer* vbo, i ctx->PSSetShaderResources(0, 1, &tex->srv); ctx->PSSetSamplers(0, 1, &samp); } + // The shadow map sits at t1/s1 with a point sampler (depth is not filtered); + // it is unbound whenever it is not sampled, so it is never bound while it is + // also a render target. + if (c->shadowOn && c->shadow.srv) { + ID3D11SamplerState* shs = cn1D3DSampler(c, 0, 0); + ctx->PSSetShaderResources(1, 1, &c->shadow.srv); + ctx->PSSetSamplers(1, 1, &shs); + } else { + ID3D11ShaderResourceView* none = NULL; + ctx->PSSetShaderResources(1, 1, &none); + } float blendFactor[4] = { 0, 0, 0, 0 }; ctx->OMSetBlendState(p->blend, blendFactor, 0xffffffff); @@ -644,6 +760,171 @@ JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dDrawArrays___long_long_lo c->ctx->Draw((UINT) vertexCount, 0); } +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_gl3dBeginShadowPass___long_int_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT size) { + CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; + if (!c || !c->ctx || !c->activeRtv) return JAVA_FALSE; + if (!cn1D3DEnsureAux(c, &c->shadow, size, size, DXGI_FORMAT_R32_FLOAT, true)) return JAVA_FALSE; + c->shadowOn = 0; + ID3D11ShaderResourceView* none = NULL; + c->ctx->PSSetShaderResources(1, 1, &none); + UINT n = 1; + c->ctx->RSGetViewports(&n, &c->savedVp); + c->ctx->OMSetRenderTargets(1, &c->shadow.rtv, c->shadow.dsv); + cn1D3DSetViewport(c, size, size); + float white[4] = { 1.0f, 1.0f, 1.0f, 1.0f }; + c->ctx->ClearRenderTargetView(c->shadow.rtv, white); + c->ctx->ClearDepthStencilView(c->shadow.dsv, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0); + return JAVA_TRUE; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dEndShadowPass___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { + CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; + if (!c || !c->ctx) return; + c->ctx->OMSetRenderTargets(1, &c->activeRtv, c->activeDsv); + c->ctx->RSSetViewports(1, &c->savedVp); +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dSetShadowSampling___long_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_BOOLEAN on) { + CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; + if (!c) return; + c->shadowOn = on ? 1 : 0; +} + +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_gl3dBeginPost___long_boolean_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_BOOLEAN bloom) { + CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; + if (!c || !c->ctx || !c->rtv || c->width <= 0 || c->height <= 0) return JAVA_FALSE; + int w = c->width; + int h = c->height; + if (!cn1D3DEnsureAux(c, &c->scene, w, h, DXGI_FORMAT_B8G8R8A8_UNORM, true)) return JAVA_FALSE; + if (bloom) { + int bw = w / 4 > 1 ? w / 4 : 1; + int bh = h / 4 > 1 ? h / 4 : 1; + if (!cn1D3DEnsureAux(c, &c->bloomA, bw, bh, DXGI_FORMAT_B8G8R8A8_UNORM, false) || + !cn1D3DEnsureAux(c, &c->bloomB, bw, bh, DXGI_FORMAT_B8G8R8A8_UNORM, false)) { + return JAVA_FALSE; + } + } + cn1D3DUnbindPs(c); + UINT vpCount = 1; + c->ctx->RSGetViewports(&vpCount, &c->postVp); + if (vpCount == 0) { + c->postVp.TopLeftX = 0.0f; + c->postVp.TopLeftY = 0.0f; + c->postVp.Width = (FLOAT) w; + c->postVp.Height = (FLOAT) h; + c->postVp.MinDepth = 0.0f; + c->postVp.MaxDepth = 1.0f; + } + c->activeRtv = c->scene.rtv; + c->activeDsv = c->scene.dsv; + c->ctx->OMSetRenderTargets(1, &c->activeRtv, c->activeDsv); + // the scene target is frame sized, so the application's viewport is + // the same rectangle in it + c->ctx->RSSetViewports(1, &c->postVp); + return JAVA_TRUE; +} + +// One full screen pass of the post chain: samples up to two textures with a +// linear clamp sampler, writes rtv (no depth), constants are 24 floats. +static void cn1D3DPostDraw(CN1D3DContext* c, CN1D3DPipeline* p, ID3D11RenderTargetView* rtv, + int w, int h, ID3D11ShaderResourceView* src0, ID3D11ShaderResourceView* src1, + const float* constants) { + ID3D11DeviceContext* ctx = c->ctx; + D3D11_MAPPED_SUBRESOURCE mapped; + if (SUCCEEDED(ctx->Map(c->postCb, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped))) { + memcpy(mapped.pData, constants, 24 * sizeof(float)); + ctx->Unmap(c->postCb, 0); + } + ctx->OMSetRenderTargets(1, &rtv, NULL); + cn1D3DSetViewport(c, w, h); + UINT stride = 12; + UINT offset = 0; + ctx->IASetVertexBuffers(0, 1, &c->quad, &stride, &offset); + ctx->IASetInputLayout(p->layout); + ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP); + ctx->VSSetShader(p->vs, NULL, 0); + ctx->PSSetShader(p->ps, NULL, 0); + ctx->VSSetConstantBuffers(0, 1, &c->postCb); + ctx->PSSetConstantBuffers(0, 1, &c->postCb); + ID3D11SamplerState* samp = cn1D3DSampler(c, 1, 0); + ctx->PSSetSamplers(0, 1, &samp); + ctx->PSSetShaderResources(0, 1, &src0); + ctx->PSSetShaderResources(1, 1, &src1); + float blendFactor[4] = { 0, 0, 0, 0 }; + ctx->OMSetBlendState(p->blend, blendFactor, 0xffffffff); + ctx->RSSetState(p->raster); + ctx->OMSetDepthStencilState(p->depth, 0); + ctx->Draw(4, 0); + cn1D3DUnbindPs(c); +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_gl3dEndPost___long_long_long_long_float_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_LONG brightPeer, JAVA_LONG blurPeer, JAVA_LONG compositePeer, JAVA_OBJECT params) { + CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; + CN1D3DPipeline* bright = (CN1D3DPipeline*) (intptr_t) brightPeer; + CN1D3DPipeline* blur = (CN1D3DPipeline*) (intptr_t) blurPeer; + CN1D3DPipeline* comp = (CN1D3DPipeline*) (intptr_t) compositePeer; + if (!c || !c->ctx || !c->scene.srv || !bright || !blur || !comp || params == JAVA_NULL) return; + // Whatever happens, drawing returns to the frame target. + if (c->postCb == NULL) { + D3D11_BUFFER_DESC cbd; + ZeroMemory(&cbd, sizeof(cbd)); + cbd.ByteWidth = 96; + cbd.Usage = D3D11_USAGE_DYNAMIC; + cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + c->device->CreateBuffer(&cbd, NULL, &c->postCb); + } + if (c->quad == NULL) { + static const float verts[12] = { -1.0f, -1.0f, 0.0f, 1.0f, -1.0f, 0.0f, + -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f }; + D3D11_BUFFER_DESC bd; + ZeroMemory(&bd, sizeof(bd)); + bd.ByteWidth = sizeof(verts); + bd.Usage = D3D11_USAGE_DEFAULT; + bd.BindFlags = D3D11_BIND_VERTEX_BUFFER; + D3D11_SUBRESOURCE_DATA init; + ZeroMemory(&init, sizeof(init)); + init.pSysMem = verts; + c->device->CreateBuffer(&bd, &init, &c->quad); + } + c->activeRtv = c->rtv; + c->activeDsv = c->dsv; + if (c->postCb && c->quad) { + float k[24]; + const JAVA_ARRAY_FLOAT* in = (const JAVA_ARRAY_FLOAT*) CN1_ARRAY_DATA(params); + for (int i = 0; i < 16; i++) { k[i] = in[i]; } + for (int i = 16; i < 24; i++) { k[i] = 0.0f; } + k[13] = (float) c->scene.width; + k[14] = (float) c->scene.height; + ID3D11ShaderResourceView* bloomSrv = c->scene.srv; + if (in[15] > 0.5f && c->bloomA.srv && c->bloomB.srv) { + k[16] = 1.0f / (float) c->scene.width; + k[17] = 1.0f / (float) c->scene.height; + cn1D3DPostDraw(c, bright, c->bloomA.rtv, c->bloomA.width, c->bloomA.height, + c->scene.srv, NULL, k); + k[16] = 0.0f; + k[17] = 0.0f; + for (int i = 0; i < 2; i++) { + float spread = 1.0f + (float) i; + k[20] = spread / (float) c->bloomA.width; + k[21] = 0.0f; + cn1D3DPostDraw(c, blur, c->bloomB.rtv, c->bloomB.width, c->bloomB.height, + c->bloomA.srv, NULL, k); + k[20] = 0.0f; + k[21] = spread / (float) c->bloomA.height; + cn1D3DPostDraw(c, blur, c->bloomA.rtv, c->bloomA.width, c->bloomA.height, + c->bloomB.srv, NULL, k); + } + k[20] = 0.0f; + k[21] = 0.0f; + bloomSrv = c->bloomA.srv; + } + cn1D3DPostDraw(c, comp, c->rtv, c->width, c->height, c->scene.srv, bloomSrv, k); + } + c->ctx->OMSetRenderTargets(1, &c->rtv, c->dsv); + c->ctx->RSSetViewports(1, &c->postVp); +} + JAVA_OBJECT com_codename1_impl_windows_WindowsNative_gl3dCaptureFrame___long_R_byte_1ARRAY(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { CN1D3DContext* c = (CN1D3DContext*) (intptr_t) peer; IWICImagingFactory* wic = cn1D3DWicFactory(c); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/HlslShaderGenerator.java b/Ports/WindowsPort/src/com/codename1/impl/windows/HlslShaderGenerator.java index 912c398151d..3127a993898 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/HlslShaderGenerator.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/HlslShaderGenerator.java @@ -6,9 +6,23 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.windows; +import com.codename1.gpu.Environment; import com.codename1.gpu.Material; import com.codename1.gpu.VertexAttribute; import com.codename1.gpu.VertexFormat; @@ -28,7 +42,8 @@ /// semantics are POSITION, NORMAL, TEXCOORD0 in the order the VertexFormat /// lists them /// - one constant buffer `CN1Uniforms` at register b0 (mvp, model, normalMatrix, -/// color, lightDir, lightColor, ambient, eye, shininess). It is uploaded from +/// color, lightDir, lightColor, ambient, eye, params, fog, hemisphere, emissive +/// and sky vectors, all float4). It is uploaded from /// the same column-major float[16] matrices the Java side packs; HLSL's default /// column-major cbuffer packing means `mul(mvp, v)` matches the GL/Metal /// `mvp * v`, so no transpose is needed. @@ -41,7 +56,8 @@ public final class HlslShaderGenerator { private final String source; - /// Generates the combined HLSL source for a material and vertex layout. + /// Generates the combined HLSL source for a material and vertex layout in + /// the default environment. /// /// #### Parameters /// @@ -49,21 +65,68 @@ public final class HlslShaderGenerator { /// /// - `format`: the mesh vertex layout public HlslShaderGenerator(Material material, VertexFormat format) { + this(material, format, null, false); + } + + /// Generates the combined HLSL source for a material, vertex layout and + /// environment. The features switched on match + /// `GlslShaderGenerator.variantKey`: fog, hemisphere ambient, tone mapping + /// and the procedural sky. + /// + /// #### Parameters + /// + /// - `material`: the material describing the lighting model and inputs + /// + /// - `format`: the mesh vertex layout + /// + /// - `environment`: the fog, ambient and tone mapping in effect, or null + public HlslShaderGenerator(Material material, VertexFormat format, Environment environment) { + this(material, format, environment, false); + } + + /// Generates the combined HLSL source for a material, vertex layout and + /// environment, optionally sampling the directional shadow map. + /// + /// #### Parameters + /// + /// - `material`: the material describing the lighting model and inputs + /// + /// - `format`: the mesh vertex layout + /// + /// - `environment`: the fog, ambient and tone mapping in effect, or null + /// + /// - `shadowed`: true if the shadow map (texture t1, sampler s1) is bound and + /// the material receives it + public HlslShaderGenerator(Material material, VertexFormat format, Environment environment, + boolean shadowed) { boolean hasNormal = format.findByUsage(VertexAttribute.Usage.NORMAL) != null; boolean hasTexcoord = format.findByUsage(VertexAttribute.Usage.TEXCOORD) != null; - boolean textured = material.getTexture() != null && hasTexcoord; Material.Type type = material.getType(); - boolean lit = (type == Material.Type.LAMBERT || type == Material.Type.PHONG) && hasNormal; + boolean sky = type == Material.Type.SKYBOX; + boolean lit = material.isLit() && hasNormal; boolean phong = type == Material.Type.PHONG && hasNormal; - this.source = build(lit, phong, textured, hasNormal, hasTexcoord); + boolean pbr = type == Material.Type.PBR && hasNormal; + boolean textured = material.getTexture() != null && hasTexcoord; + boolean fog = environment != null && environment.isFog() && !sky; + boolean skyOn = sky && environment != null && environment.isSky(); + boolean hemi = lit && environment != null && environment.isHemisphere(); + boolean tone = lit && environment != null && environment.isToneMapping(); + boolean emissive = lit && (material.getEmissive() & 0xffffff) != 0; + boolean shadow = lit && shadowed && material.isReceiveShadows(); + this.source = build(lit, phong, pbr, textured, hasNormal, hasTexcoord, fog, skyOn, + hemi, tone, emissive, shadow); } - private static String build(boolean lit, boolean phong, boolean textured, - boolean hasNormal, boolean hasTexcoord) { + private static String build(boolean lit, boolean phong, boolean pbr, boolean textured, + boolean hasNormal, boolean hasTexcoord, boolean fog, + boolean sky, boolean hemi, boolean tone, boolean emissive, + boolean shadow) { + boolean world = lit || fog || sky; StringBuilder sb = new StringBuilder(); // Constant buffer. Layout matches the float[] the Java side packs and the - // C struct the native renderer copies into the cbuffer. + // C struct the native renderer copies into the cbuffer. Every field is a + // whole float4 (or matrix) so packing never straddles a 16 byte row. sb.append("cbuffer CN1Uniforms : register(b0) {\n"); sb.append(" float4x4 mvp;\n"); sb.append(" float4x4 model;\n"); @@ -73,7 +136,17 @@ private static String build(boolean lit, boolean phong, boolean textured, sb.append(" float4 lightColor;\n"); sb.append(" float4 ambient;\n"); sb.append(" float4 eye;\n"); - sb.append(" float shininess;\n"); + sb.append(" float4 params;\n"); // shininess, metallic, roughness, exposure + sb.append(" float4 fogColor;\n"); + sb.append(" float4 fogParams;\n"); // density, height falloff, base height + sb.append(" float4 skyColor;\n"); + sb.append(" float4 groundColor;\n"); + sb.append(" float4 emissiveColor;\n"); + sb.append(" float4 zenith;\n"); + sb.append(" float4 horizon;\n"); + sb.append(" float4 sun;\n"); // sun colour, cloud cover + sb.append(" float4x4 shadowMatrix;\n"); + sb.append(" float4 shadowParams;\n"); // bias, strength, texel size sb.append("};\n"); if (textured) { @@ -81,6 +154,11 @@ private static String build(boolean lit, boolean phong, boolean textured, sb.append("SamplerState cn1_sampler : register(s0);\n"); } + if (shadow) { + sb.append("Texture2D cn1_shadowMap : register(t1);\n"); + sb.append("SamplerState cn1_shadowSampler : register(s1);\n"); + } + // Vertex input/output structs. sb.append("struct VSInput {\n"); sb.append(" float3 position : POSITION;\n"); @@ -96,11 +174,16 @@ private static String build(boolean lit, boolean phong, boolean textured, sb.append(" float4 position : SV_Position;\n"); if (lit) { sb.append(" float3 worldNormal : TEXCOORD1;\n"); + } + if (world) { sb.append(" float3 worldPos : TEXCOORD2;\n"); } if (textured) { sb.append(" float2 texcoord : TEXCOORD0;\n"); } + if (shadow) { + sb.append(" float4 shadowPos : TEXCOORD3;\n"); + } sb.append("};\n"); // Vertex shader. @@ -117,42 +200,285 @@ private static String build(boolean lit, boolean phong, boolean textured, sb.append(" output.position = clip;\n"); if (lit) { sb.append(" output.worldNormal = mul(normalMatrix, float4(input.normal, 0.0)).xyz;\n"); + } + if (world) { sb.append(" output.worldPos = mul(model, float4(input.position, 1.0)).xyz;\n"); } if (textured) { sb.append(" output.texcoord = input.texcoord;\n"); } + if (shadow) { + sb.append(" output.shadowPos = mul(shadowMatrix, mul(model, float4(input.position, 1.0)));\n"); + } sb.append(" return output;\n"); sb.append("}\n"); + if (sky) { + appendSkyNoise(sb); + } + if (tone) { + // Narkowicz's fit of the ACES filmic curve + sb.append("float3 aces(float3 x) {\n"); + sb.append(" return saturate((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14));\n"); + sb.append("}\n"); + } + + if (shadow) { + appendShadowLookup(sb); + } + // Pixel shader. sb.append("float4 ").append(FRAGMENT_FUNCTION).append("(VSOutput input) : SV_Target {\n"); sb.append(" float4 base = color;\n"); if (textured) { sb.append(" base = base * cn1_tex.Sample(cn1_sampler, input.texcoord);\n"); } + sb.append(" float3 rgb = base.rgb;\n"); + if (sky) { + appendSky(sb); + } if (lit) { sb.append(" float3 n = normalize(input.worldNormal);\n"); sb.append(" float3 l = normalize(-lightDir.xyz);\n"); sb.append(" float ndotl = max(dot(n, l), 0.0);\n"); - sb.append(" float3 lighting = ambient.xyz + lightColor.xyz * ndotl;\n"); - sb.append(" float3 rgb = base.rgb * lighting;\n"); - if (phong) { - sb.append(" if (ndotl > 0.0) {\n"); - sb.append(" float3 v = normalize(eye.xyz - input.worldPos);\n"); - sb.append(" float3 h = normalize(l + v);\n"); - sb.append(" float spec = pow(max(dot(n, h), 0.0), shininess);\n"); - sb.append(" rgb += lightColor.xyz * spec;\n"); - sb.append(" }\n"); + if (hemi) { + sb.append(" float3 amb = lerp(groundColor.xyz, skyColor.xyz, n.y * 0.5 + 0.5);\n"); + } else { + sb.append(" float3 amb = ambient.xyz;\n"); + } + sb.append(shadow ? " float shade = shadowAt(input.shadowPos, n, l);\n" : " float shade = 1.0;\n"); + if (pbr) { + appendPbr(sb); + } else { + sb.append(" rgb = base.rgb * (amb + lightColor.xyz * ndotl * shade);\n"); + if (phong) { + sb.append(" if (ndotl > 0.0) {\n"); + sb.append(" float3 v = normalize(eye.xyz - input.worldPos);\n"); + sb.append(" float3 h = normalize(l + v);\n"); + sb.append(" float spec = pow(max(dot(n, h), 0.0), params.x);\n"); + sb.append(" rgb += lightColor.xyz * spec * shade;\n"); + sb.append(" }\n"); + } + } + if (emissive) { + sb.append(" rgb += emissiveColor.xyz;\n"); + } + if (tone) { + sb.append(" rgb = aces(rgb * params.w);\n"); } - sb.append(" return float4(rgb, base.a);\n"); - } else { - sb.append(" return base;\n"); } + if (fog) { + sb.append(" float camDist = length(input.worldPos - eye.xyz);\n"); + sb.append(" float fd = fogParams.x * camDist;\n"); + sb.append(" float fogHeight = exp(-fogParams.y * max(input.worldPos.y - fogParams.z, 0.0));\n"); + sb.append(" float fogAmount = saturate((1.0 - exp(-fd * fd)) * fogHeight);\n"); + sb.append(" rgb = lerp(rgb, fogColor.xyz, fogAmount);\n"); + } + sb.append(" return float4(rgb, base.a);\n"); sb.append("}\n"); return sb.toString(); } + // The 3x3 PCF lookup of GlslShaderGenerator. The shadow matrix is in GL clip + // convention, so x, y and z are remapped with * 0.5 + 0.5 exactly as in GLSL; + // only v is flipped (1 - y), because Direct3D texture row 0 is the top of the + // render target the caster pass drew with +Y up. The map holds that same + // GL-convention depth in an R32_FLOAT target, so there is nothing to unpack. + private static void appendShadowLookup(StringBuilder sb) { + sb.append("float shadowAt(float4 sp, float3 n, float3 l) {\n"); + sb.append(" float3 p = sp.xyz / sp.w * 0.5 + 0.5;\n"); + sb.append(" if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) {\n"); + sb.append(" return 1.0;\n }\n"); + sb.append(" float bias = shadowParams.x * (1.0 + 2.0 * (1.0 - max(dot(n, l), 0.0)));\n"); + sb.append(" float lit = 0.0;\n"); + sb.append(" [unroll] for (int y = -1; y <= 1; y++) {\n"); + sb.append(" [unroll] for (int x = -1; x <= 1; x++) {\n"); + sb.append(" float2 uv = float2(p.x + float(x) * shadowParams.z, (1.0 - p.y) + float(y) * shadowParams.z);\n"); + sb.append(" float d = cn1_shadowMap.SampleLevel(cn1_shadowSampler, uv, 0).r;\n"); + sb.append(" lit += (p.z - bias > d) ? 0.0 : 1.0;\n"); + sb.append(" }\n }\n"); + sb.append(" return 1.0 - shadowParams.y * (1.0 - lit / 9.0);\n"); + sb.append("}\n"); + } + + /// Returns the HLSL for the shadow caster pass: depth only, written to an + /// R32_FLOAT target. The first uniform matrix is the light matrix times the + /// model matrix. + /// + /// #### Returns + /// + /// the combined vertex and pixel source + public static String shadowSource() { + StringBuilder sb = new StringBuilder(); + sb.append("cbuffer CN1Uniforms : register(b0) {\n float4x4 mvp;\n};\n"); + sb.append("struct VSInput {\n float3 position : POSITION;\n};\n"); + sb.append("struct VSOutput {\n float4 position : SV_Position;\n float depth : TEXCOORD0;\n};\n"); + sb.append("VSOutput ").append(VERTEX_FUNCTION).append("(VSInput input) {\n"); + sb.append(" VSOutput output;\n"); + sb.append(" float4 clip = mul(mvp, float4(input.position, 1.0));\n"); + sb.append(" output.depth = clip.z / clip.w * 0.5 + 0.5;\n"); + sb.append(" clip.z = (clip.z + clip.w) * 0.5;\n"); + sb.append(" output.position = clip;\n"); + sb.append(" return output;\n}\n"); + sb.append("float4 ").append(FRAGMENT_FUNCTION).append("(VSOutput input) : SV_Target {\n"); + sb.append(" float d = saturate(input.depth);\n"); + sb.append(" return float4(d, d, d, 1.0);\n}\n"); + return sb.toString(); + } + + // Common prologue of the post passes: the quad vertex shader and the + // constant buffer. v is flipped so uv (0, 0) is the top left texel of the + // render target, which is where Direct3D puts row 0. + private static String postPrologue() { + return "cbuffer CN1Post : register(b0) {\n" + + " float4 g0;\n" // threshold, bloom intensity, contrast, saturation + + " float4 g1;\n" // lift rgb, vignette + + " float4 g2;\n" // gain rgb, grain + + " float4 g3;\n" // time, resolution x, resolution y, bloom on + + " float4 g4;\n" // bright pass: texel in uv + + " float4 g5;\n" // blur pass: direction in uv + + "};\n" + + "Texture2D cn1_scene : register(t0);\n" + + "Texture2D cn1_bloom : register(t1);\n" + + "SamplerState cn1_sampler : register(s0);\n" + + "struct VSInput {\n float3 position : POSITION;\n};\n" + + "struct VSOutput {\n float4 position : SV_Position;\n float2 uv : TEXCOORD0;\n};\n" + + "VSOutput " + VERTEX_FUNCTION + "(VSInput input) {\n" + + " VSOutput output;\n" + + " output.uv = float2(input.position.x * 0.5 + 0.5, 0.5 - input.position.y * 0.5);\n" + + " output.position = float4(input.position.xy, 0.0, 1.0);\n" + + " return output;\n}\n" + + "float4 " + FRAGMENT_FUNCTION + "(VSOutput input) : SV_Target {\n"; + } + + /// Returns the bloom bright pass (see GlslShaderGenerator.brightFragment). + /// + /// #### Returns + /// + /// the combined vertex and pixel source + public static String postBrightSource() { + return postPrologue() + + " float2 t = g4.xy;\n" + + " float3 c = (cn1_scene.SampleLevel(cn1_sampler, input.uv + t * float2(-0.5, -0.5), 0).rgb\n" + + " + cn1_scene.SampleLevel(cn1_sampler, input.uv + t * float2(0.5, -0.5), 0).rgb\n" + + " + cn1_scene.SampleLevel(cn1_sampler, input.uv + t * float2(-0.5, 0.5), 0).rgb\n" + + " + cn1_scene.SampleLevel(cn1_sampler, input.uv + t * float2(0.5, 0.5), 0).rgb) * 0.25;\n" + + " float peak = max(c.r, max(c.g, c.b));\n" + + " float over = max(peak - g0.x, 0.0) / max(1.0 - g0.x, 0.0001);\n" + + " return float4(c * (over / max(peak, 0.0001)), 1.0);\n}\n"; + } + + /// Returns one direction of the separable blur (see + /// GlslShaderGenerator.blurFragment). + /// + /// #### Returns + /// + /// the combined vertex and pixel source + public static String postBlurSource() { + return postPrologue() + + " float2 d = g5.xy;\n" + + " float3 c = cn1_scene.SampleLevel(cn1_sampler, input.uv, 0).rgb * 0.2270270;\n" + + " c += cn1_scene.SampleLevel(cn1_sampler, input.uv + d * 1.3846154, 0).rgb * 0.3162162;\n" + + " c += cn1_scene.SampleLevel(cn1_sampler, input.uv - d * 1.3846154, 0).rgb * 0.3162162;\n" + + " c += cn1_scene.SampleLevel(cn1_sampler, input.uv + d * 3.2307692, 0).rgb * 0.0702703;\n" + + " c += cn1_scene.SampleLevel(cn1_sampler, input.uv - d * 3.2307692, 0).rgb * 0.0702703;\n" + + " return float4(c, 1.0);\n}\n"; + } + + /// Returns the final composite pass: bloom, grade, vignette and grain (see + /// GlslShaderGenerator.compositeFragment). + /// + /// #### Returns + /// + /// the combined vertex and pixel source + public static String postCompositeSource() { + return postPrologue() + + " float3 c = cn1_scene.SampleLevel(cn1_sampler, input.uv, 0).rgb;\n" + + " c += cn1_bloom.SampleLevel(cn1_sampler, input.uv, 0).rgb * g0.y;\n" + + " c = (c - 0.5) * g0.z + 0.5;\n" + + " float luma = dot(c, float3(0.2126, 0.7152, 0.0722));\n" + + " c = lerp(float3(luma, luma, luma), c, g0.w);\n" + + " c = saturate(c);\n" + + " c = (g1.xyz + c * (1.0 - g1.xyz)) * g2.xyz;\n" + + " float2 d = input.uv - 0.5;\n" + + " c *= 1.0 - g1.w * smoothstep(0.25, 0.75, dot(d, d) * 2.0);\n" + + " float2 px = floor(input.uv * g3.yz);\n" + + " float noise = frac(sin(dot(px + frac(g3.x * 7.0) * 61.0, float2(12.9898, 78.233))) * 43758.5453);\n" + + " c += (noise - 0.5) * g2.w;\n" + + " return float4(c, 1.0);\n}\n"; + } + + // Value noise and four octaves of it, for the clouds. Named cn1* because + // HLSL has an intrinsic called noise. + private static void appendSkyNoise(StringBuilder sb) { + sb.append("float cn1Hash(float2 p) {\n"); + sb.append(" return frac(sin(dot(p, float2(127.1, 311.7))) * 43758.5453);\n"); + sb.append("}\n"); + sb.append("float cn1Noise(float2 p) {\n"); + sb.append(" float2 i = floor(p);\n"); + sb.append(" float2 u = frac(p);\n"); + sb.append(" u = u * u * (3.0 - 2.0 * u);\n"); + sb.append(" return lerp(lerp(cn1Hash(i), cn1Hash(i + float2(1.0, 0.0)), u.x),\n"); + sb.append(" lerp(cn1Hash(i + float2(0.0, 1.0)), cn1Hash(i + float2(1.0, 1.0)), u.x), u.y);\n"); + sb.append("}\n"); + sb.append("float cn1Fbm(float2 p) {\n"); + sb.append(" float v = 0.0;\n"); + sb.append(" float a = 0.5;\n"); + sb.append(" [unroll] for (int i = 0; i < 4; i++) {\n"); + sb.append(" v += a * cn1Noise(p);\n"); + sb.append(" p = p * 2.03 + float2(1.7, 9.2);\n"); + sb.append(" a *= 0.5;\n"); + sb.append(" }\n"); + sb.append(" return v;\n"); + sb.append("}\n"); + } + + // The open sky, from the direction the pixel is seen in: a horizon to + // zenith gradient, clouds projected onto a plane overhead and lit from + // the sun's side, and the sun's disc with a wide glow around it. + private static void appendSky(StringBuilder sb) { + sb.append(" float3 dir = normalize(input.worldPos - eye.xyz);\n"); + sb.append(" float up = saturate(dir.y);\n"); + sb.append(" rgb = lerp(horizon.xyz, zenith.xyz, pow(up, 0.55));\n"); + sb.append(" float3 sunDir = normalize(-lightDir.xyz);\n"); + sb.append(" float sd = max(dot(dir, sunDir), 0.0);\n"); + sb.append(" if (dir.y > 0.0 && sun.w > 0.0) {\n"); + sb.append(" float2 cp = dir.xz / (dir.y + 0.12) * 1.6;\n"); + sb.append(" float cv = cn1Fbm(cp);\n"); + sb.append(" float cover = smoothstep(1.0 - sun.w, 1.0 - sun.w + 0.35, cv);\n"); + sb.append(" float cl = 0.75 + 0.25 * cn1Fbm(cp + sunDir.xz * 0.3);\n"); + sb.append(" float3 cloud = lerp(horizon.xyz * 0.82, float3(1.0, 1.0, 1.0), cl) + sun.xyz * pow(sd, 6.0) * 0.35;\n"); + sb.append(" rgb = lerp(rgb, cloud, cover * smoothstep(0.0, 0.25, dir.y));\n"); + sb.append(" }\n"); + sb.append(" rgb += sun.xyz * (pow(sd, 8.0) * 0.25 + pow(sd, 64.0) * 0.5 + smoothstep(0.9993, 0.9997, sd) * 2.0);\n"); + } + + // Cook-Torrance with a GGX distribution, Smith-Schlick geometry and + // Schlick's Fresnel, as in the GLSL generator. + private static void appendPbr(StringBuilder sb) { + sb.append(" float metallic = params.y;\n"); + sb.append(" float rough = params.z;\n"); + sb.append(" float3 v = normalize(eye.xyz - input.worldPos);\n"); + sb.append(" float3 h = normalize(l + v);\n"); + sb.append(" float ndotv = max(dot(n, v), 0.001);\n"); + sb.append(" float ndoth = max(dot(n, h), 0.0);\n"); + sb.append(" float vdoth = max(dot(v, h), 0.0);\n"); + sb.append(" float3 f0 = lerp(float3(0.04, 0.04, 0.04), base.rgb, metallic);\n"); + sb.append(" float a = rough * rough;\n"); + sb.append(" float a2 = a * a;\n"); + sb.append(" float dd = ndoth * ndoth * (a2 - 1.0) + 1.0;\n"); + sb.append(" float ggx = a2 / (3.14159265 * dd * dd);\n"); + sb.append(" float k = (rough + 1.0) * (rough + 1.0) / 8.0;\n"); + sb.append(" float geo = (ndotl / (ndotl * (1.0 - k) + k)) * (ndotv / (ndotv * (1.0 - k) + k));\n"); + sb.append(" float3 fres = f0 + (1.0 - f0) * pow(1.0 - vdoth, 5.0);\n"); + sb.append(" float3 spec = ggx * geo * fres / (4.0 * max(ndotl, 0.001) * ndotv);\n"); + sb.append(" float3 kd = (float3(1.0, 1.0, 1.0) - fres) * (1.0 - metallic);\n"); + sb.append(" float3 direct = (kd * base.rgb + spec * 3.14159265) * lightColor.xyz * ndotl * shade;\n"); + sb.append(" float3 fa = f0 + (max(float3(1.0 - rough, 1.0 - rough, 1.0 - rough), f0) - f0) * pow(1.0 - ndotv, 5.0);\n"); + sb.append(" float3 ambLit = amb * (base.rgb * (1.0 - metallic) * (float3(1.0, 1.0, 1.0) - fa) + fa * (1.0 - rough * 0.7));\n"); + sb.append(" rgb = direct + ambLit;\n"); + } + /// Returns the generated combined HLSL source. public String getSource() { return source; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGraphicsDevice.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGraphicsDevice.java index 4759384532d..a6017961ab7 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGraphicsDevice.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGraphicsDevice.java @@ -6,10 +6,24 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.windows; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -17,11 +31,13 @@ import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.PrimitiveType; import com.codename1.gpu.RenderState; import com.codename1.gpu.Texture; import com.codename1.gpu.VertexBuffer; import com.codename1.gpu.VertexFormat; +import com.codename1.impl.gpu.GlslShaderGenerator; import com.codename1.ui.Image; import java.util.HashMap; @@ -45,16 +61,32 @@ class WindowsGraphicsDevice extends GraphicsDevice { private final Map pipelines = new HashMap(); private final GpuCapabilities caps = new GpuCapabilities( - 8192, 16, true, true, true, "Codename One Direct3D 11 (Windows)"); + 8192, 16, true, true, true, "Codename One Direct3D 11 (Windows)", true, true); private final float[] mvp = new float[16]; // Uniform block layout must match the CN1Uniforms cbuffer emitted by - // HlslShaderGenerator and copied on the native side: 3 mat4 (48) + 5 vec4 - // (20) + shininess (1), padded to a multiple of 4 floats (16 bytes). - private static final int UNIFORM_FLOATS = 72; + // HlslShaderGenerator and copied on the native side: 3 mat4 (48) + 6 vec4 + // (24: color, light dir, light colour, ambient, eye, params) + 7 vec4 (28: + // fog colour, fog, sky, ground, emissive, zenith, horizon) + sun (4). + // + shadow matrix (16) + shadow params (4, bias / strength / texel size). + private static final int UNIFORM_FLOATS = 124; private final float[] uniforms = new float[UNIFORM_FLOATS]; + // Shadow state, as in JavaSEGLDevice: the light matrix of the last shadow + // pass, whether a pass is running, and whether its map may be sampled. + private final float[] shadowMatrix = new float[16]; + private final float[] model = new float[16]; + private boolean inShadowPass; + private boolean shadowReady; + private boolean shadowSampling; + private int shadowSize = 1024; + private PostProcess activePost; + private long postBright; + private long postBlur; + private long postComposite; + private final float[] postParams = new float[16]; + WindowsGraphicsDevice(long contextPeer) { this.contextPeer = contextPeer; } @@ -94,14 +126,24 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { if (contextPeer == 0) { return; } + if (inShadowPass) { + drawShadowCaster(mesh, material, modelMatrix); + return; + } VertexBuffer vb = mesh.getVertices(); VertexFormat fmt = vb.getFormat(); + boolean shadowed = shadowReady && isShadowMapCurrent() && getLight().isShadows() && material.isLit() + && material.isReceiveShadows(); + if (shadowed != shadowSampling) { + shadowSampling = shadowed; + WindowsNative.gl3dSetShadowSampling(contextPeer, shadowed); + } long vboHandle = uploadVertexBuffer(vb); if (vboHandle == 0) { return; } - long pipeline = getOrCreatePipeline(material, fmt); + long pipeline = getOrCreatePipeline(material, fmt, shadowed); if (pipeline == 0) { return; } @@ -174,9 +216,10 @@ private long uploadIndexBuffer(IndexBuffer ib) { return peer; } - private long getOrCreatePipeline(Material material, VertexFormat fmt) { + private long getOrCreatePipeline(Material material, VertexFormat fmt, boolean shadowed) { RenderState rs = material.getRenderState(); - String key = material.getShaderKey() + Environment env = getEnvironment(); + String key = material.getShaderKey() + "|" + GlslShaderGenerator.variantKey(env, shadowed) + "|s" + fmt.getStrideBytes() + "|b" + blendCode(rs.getBlendMode()) + "|c" + cullCode(rs.getCullMode()) @@ -186,7 +229,7 @@ private long getOrCreatePipeline(Material material, VertexFormat fmt) { if (existing != null) { return existing.longValue(); } - HlslShaderGenerator gen = new HlslShaderGenerator(material, fmt); + HlslShaderGenerator gen = new HlslShaderGenerator(material, fmt, env, shadowed); long pipeline = WindowsNative.gl3dGetOrCreatePipeline( contextPeer, key, gen.getSource(), blendCode(rs.getBlendMode()), cullCode(rs.getCullMode()), @@ -238,7 +281,163 @@ private void packUniforms(Material material, float[] model) { uniforms[o++] = cam != null ? cam.getEyeY() : 0.0f; uniforms[o++] = cam != null ? cam.getEyeZ() : 0.0f; uniforms[o++] = 1.0f; + Environment env = getEnvironment(); uniforms[o++] = material.getShininess(); + uniforms[o++] = material.getMetallic(); + uniforms[o++] = material.getRoughness(); + uniforms[o++] = env.getExposure(); + o = packColor(o, env.getFogColor()); + uniforms[o++] = env.getFogDensity(); + uniforms[o++] = env.getFogHeightFalloff(); + uniforms[o++] = env.getFogBaseHeight(); + uniforms[o++] = 0.0f; + o = packColor(o, env.getSkyColor()); + o = packColor(o, env.getGroundColor()); + o = packColor(o, material.getEmissive()); + o = packColor(o, env.getZenithColor()); + o = packColor(o, env.getHorizonColor()); + int sun = env.getSunColor(); + uniforms[o++] = ((sun >> 16) & 0xff) / 255.0f; + uniforms[o++] = ((sun >> 8) & 0xff) / 255.0f; + uniforms[o++] = (sun & 0xff) / 255.0f; + uniforms[o++] = env.getCloudCover(); + for (int i = 0; i < 16; i++) { + uniforms[o++] = shadowMatrix[i]; + } + Light sl = getLight(); + uniforms[o++] = sl.getShadowBias(); + uniforms[o++] = sl.getShadowStrength(); + uniforms[o++] = 1.0f / Math.max(1, shadowSize); + uniforms[o++] = 0.0f; + } + + public boolean beginShadowPass(Light light) { + if (contextPeer == 0) { + return false; + } + int size = light.getShadowMapSize(); + if (!WindowsNative.gl3dBeginShadowPass(contextPeer, size)) { + return false; + } + shadowSize = size; + shadowSampling = false; + light.computeShadowMatrix(shadowMatrix); + inShadowPass = true; + return true; + } + + public void endShadowPass() { + if (!inShadowPass) { + return; + } + inShadowPass = false; + shadowReady = true; + shadowMapRendered(); + WindowsNative.gl3dEndShadowPass(contextPeer); + } + + // Renders one caster's depth into the shadow map: only positions are read, + // the first uniform matrix is shadowMatrix * model. + private void drawShadowCaster(Mesh mesh, Material material, float[] modelMatrix) { + if (!material.isCastShadows() || material.getRenderState().getBlendMode() != RenderState.BlendMode.NONE + || material.getType() == Material.Type.SKYBOX) { + return; + } + VertexBuffer vb = mesh.getVertices(); + long vboHandle = uploadVertexBuffer(vb); + long pipeline = getOrCreateAuxPipeline("cn1-shadow", HlslShaderGenerator.shadowSource(), 0, 1); + if (vboHandle == 0 || pipeline == 0) { + return; + } + if (modelMatrix != null) { + Matrix4.copy(modelMatrix, model); + } else { + Matrix4.setIdentity(model); + } + Matrix4.multiply(shadowMatrix, model, mvp); + for (int i = 0; i < 16; i++) { + uniforms[i] = mvp[i]; + } + int primitive = primitiveCode(mesh.getPrimitiveType()); + int strideBytes = vb.getFormat().getStrideBytes(); + if (mesh.isIndexed()) { + IndexBuffer ib = mesh.getIndices(); + long iboHandle = uploadIndexBuffer(ib); + if (iboHandle != 0) { + WindowsNative.gl3dDrawIndexed(contextPeer, pipeline, vboHandle, strideBytes, iboHandle, + ib.getIndexCount(), primitive, uniforms, UNIFORM_FLOATS, 0, 0, 0); + } + } else { + WindowsNative.gl3dDrawArrays(contextPeer, pipeline, vboHandle, strideBytes, + vb.getVertexCount(), primitive, uniforms, UNIFORM_FLOATS, 0, 0, 0); + } + } + + // A pipeline for the shadow and post passes: no blending, no culling, depth + // test and write as given. + private long getOrCreateAuxPipeline(String key, String source, int depthTest, int depthWrite) { + Long existing = pipelines.get(key); + if (existing != null) { + return existing.longValue(); + } + long pipeline = WindowsNative.gl3dGetOrCreatePipeline(contextPeer, key, source, + 0, 0, depthTest, depthWrite); + pipelines.put(key, Long.valueOf(pipeline)); + return pipeline; + } + + public boolean beginPostProcess(PostProcess effects, int width, int height) { + if (contextPeer == 0 || effects == null) { + return false; + } + postBright = getOrCreateAuxPipeline("cn1-post-bright", HlslShaderGenerator.postBrightSource(), 0, 0); + postBlur = getOrCreateAuxPipeline("cn1-post-blur", HlslShaderGenerator.postBlurSource(), 0, 0); + postComposite = getOrCreateAuxPipeline("cn1-post-composite", + HlslShaderGenerator.postCompositeSource(), 0, 0); + if (postBright == 0 || postBlur == 0 || postComposite == 0 + || !WindowsNative.gl3dBeginPost(contextPeer, effects.isBloom())) { + return false; + } + activePost = effects; + return true; + } + + public void endPostProcess(float timeSeconds) { + PostProcess pp = activePost; + if (pp == null) { + return; + } + activePost = null; + float[] p = postParams; + p[0] = pp.getBloomThreshold(); + p[1] = pp.isBloom() ? pp.getBloomIntensity() : 0f; + p[2] = pp.getContrast(); + p[3] = pp.getSaturation(); + int lift = pp.getShadowTint(); + p[4] = ((lift >> 16) & 0xff) / 255.0f; + p[5] = ((lift >> 8) & 0xff) / 255.0f; + p[6] = (lift & 0xff) / 255.0f; + p[7] = pp.getVignette(); + int gain = pp.getHighlightTint(); + p[8] = ((gain >> 16) & 0xff) / 255.0f; + p[9] = ((gain >> 8) & 0xff) / 255.0f; + p[10] = (gain & 0xff) / 255.0f; + p[11] = pp.getGrain(); + p[12] = timeSeconds; + p[13] = 0f; + p[14] = 0f; + p[15] = pp.isBloom() ? 1f : 0f; + WindowsNative.gl3dEndPost(contextPeer, postBright, postBlur, postComposite, p); + shadowSampling = false; + } + + // Writes an RGB colour as a float4 with a zero fourth component. + private int packColor(int o, int argb) { + uniforms[o++] = ((argb >> 16) & 0xff) / 255.0f; + uniforms[o++] = ((argb >> 8) & 0xff) / 255.0f; + uniforms[o++] = (argb & 0xff) / 255.0f; + uniforms[o++] = 0.0f; + return o; } private static int primitiveCode(PrimitiveType type) { diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index a9c840953d9..45d8a1e8bf1 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -1139,6 +1139,21 @@ public static native void gl3dDrawArrays(long contextPeer, long pipelinePeer, lo int vertexCount, int primitive, float[] uniforms, int uniformFloats, long texturePeer, int texFilter, int texWrap); + /** Starts the shadow pass: binds a size x size R32_FLOAT depth map as the target and clears it; false if it cannot. */ + public static native boolean gl3dBeginShadowPass(long contextPeer, int size); + /** Ends the shadow pass and rebinds the scene target and viewport. */ + public static native void gl3dEndShadowPass(long contextPeer); + /** Binds (or unbinds) the shadow map at t1/s1 for the following draws. */ + public static native void gl3dSetShadowSampling(long contextPeer, boolean on); + /** Redirects drawing into an offscreen scene target (plus bloom targets if bloom); false if it cannot. */ + public static native boolean gl3dBeginPost(long contextPeer, boolean bloom); + /** + * Runs bloom (bright, two blur rounds) and the composite into the frame target. + * The 16 float parameters are documented in WindowsGraphicsDevice.endPostProcess. + */ + public static native void gl3dEndPost(long contextPeer, long brightPipeline, long blurPipeline, + long compositePipeline, float[] params); + // ---- SQLite bindings. Implemented in cn1_windows_db.c from the shared cn1_db_sqlite_impl.h. ---- /** True when a database file exists at this absolute path. */ diff --git a/Ports/iOSPort/nativeSources/CN1GL3D.m b/Ports/iOSPort/nativeSources/CN1GL3D.m index 6abe9dd5d62..ec8b815c3d4 100644 --- a/Ports/iOSPort/nativeSources/CN1GL3D.m +++ b/Ports/iOSPort/nativeSources/CN1GL3D.m @@ -45,6 +45,20 @@ @interface CN1GL3DPipeline : NSObject @implementation CN1GL3DPipeline @end +// --------------------------------------------------------------------------- +// The post processing chain: bright, blur and composite pipelines (all render +// to BGRA8Unorm with no depth) compiled once from the MSL the Java side hands +// over. +// --------------------------------------------------------------------------- +@interface CN1GL3DPost : NSObject +@property (nonatomic, strong) id bright; +@property (nonatomic, strong) id blur; +@property (nonatomic, strong) id composite; +@end + +@implementation CN1GL3DPost +@end + // --------------------------------------------------------------------------- // The Metal 3D view / context. // --------------------------------------------------------------------------- @@ -57,12 +71,34 @@ @interface CN1GL3DView () { BOOL _continuous; int _depthWidth; int _depthHeight; -} + // Per frame state. The main encoder is created lazily at the first draw so + // a shadow pass or a post process target can come first in the frame. + id _frameCB; + id _frameDrawable; + int _frameW; + int _frameH; + BOOL _encodedThisFrame; + BOOL _inShadow; + BOOL _postActive; + BOOL _shadowReady; + BOOL _postBloom; +} +@property (nonatomic, strong) id shadowTexture; +@property (nonatomic, strong) id sceneTexture; +@property (nonatomic, strong) id bloomA; +@property (nonatomic, strong) id bloomB; +@property (nonatomic, strong) CN1GL3DPost *post; @property (nonatomic, strong) id depthTexture; @property (nonatomic, strong) CADisplayLink *displayLink; @property (nonatomic, strong) NSMutableDictionary *pipelineCache; @property (nonatomic, strong) id currentEncoder; - (id)activeEncoder; +- (BOOL)beginShadowPass:(int)size; +- (void)endShadowPass; +- (BOOL)isInShadowPass; +- (BOOL)shadowAvailable; +- (BOOL)beginPostProcess:(NSString *)source bloom:(BOOL)bloom; +- (void)endPostProcess:(const float *)params; - (void)teardown; - (CN1GL3DPipeline *)pipelineForKey:(NSString *)key source:(NSString *)mslSource blendMode:(int)blendMode cullMode:(int)cullMode @@ -175,36 +211,42 @@ - (void)renderFrame { return; } - MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor renderPassDescriptor]; - rpd.colorAttachments[0].texture = drawable.texture; - rpd.colorAttachments[0].clearColor = _clearColor; - rpd.colorAttachments[0].loadAction = _pendingClearColor ? MTLLoadActionClear : MTLLoadActionLoad; - rpd.colorAttachments[0].storeAction = MTLStoreActionStore; - rpd.depthAttachment.texture = _depthTexture; - rpd.depthAttachment.clearDepth = 1.0; - rpd.depthAttachment.loadAction = _pendingClearDepth ? MTLLoadActionClear : MTLLoadActionDontCare; - rpd.depthAttachment.storeAction = MTLStoreActionDontCare; - _pendingClearColor = NO; - _pendingClearDepth = NO; - - id cb = [self.commandQueue commandBuffer]; - id encoder = [cb renderCommandEncoderWithDescriptor:rpd]; - - if (!_hasViewport) { - _viewport = (MTLViewport){0.0, 0.0, (double) w, (double) h, 0.0, 1.0}; - } - [encoder setViewport:_viewport]; - self.currentEncoder = encoder; + _frameCB = [self.commandQueue commandBuffer]; + _frameDrawable = drawable; + _frameW = w; + _frameH = h; + _encodedThisFrame = NO; + _inShadow = NO; + _postActive = NO; + self.currentEncoder = nil; // Hand control to the Java renderer; its draw calls route back through the - // gl3dDraw* bridge functions and use self.currentEncoder. + // gl3dDraw* bridge functions and use the encoder activeEncoder opens. com_codename1_impl_ios_IOSGLSurface_onFrameNative___long_int_int( CN1_THREAD_GET_STATE_PASS_ARG (JAVA_LONG) self.contextHandle, w, h); - [encoder endEncoding]; - [cb presentDrawable:drawable]; - [cb commit]; + // A pass the renderer left open is closed here; an unfinished post process + // is abandoned and the frame presents what the screen held. + if (_inShadow) { + [self endShadowPass]; + } + if (_postActive) { + _postActive = NO; + [self.currentEncoder endEncoding]; + self.currentEncoder = nil; + _pendingClearColor = YES; + _pendingClearDepth = YES; + } + if (!_encodedThisFrame) { + // nothing drew: still open the pass so the pending clear is presented + [self activeEncoder]; + } + [self.currentEncoder endEncoding]; self.currentEncoder = nil; + [_frameCB presentDrawable:drawable]; + [_frameCB commit]; + _frameCB = nil; + _frameDrawable = nil; } - (void)recordClear:(int)argb color:(BOOL)clearColor depth:(BOOL)clearDepth { @@ -226,8 +268,217 @@ - (void)recordViewport:(int)x y:(int)y width:(int)width height:(int)height { _hasViewport = YES; } +// Opens the main pass on first use in a frame (the drawable, or the post +// process scene target) and applies the pending clear. A shadow pass in +// progress answers its own encoder. Depth is never stored, so a shadow pass or +// post process begun AFTER opaque scene draws loses the depth of those draws; +// both are expected first in the frame, before the scene is drawn. - (id)activeEncoder { - return self.currentEncoder; + if (self.currentEncoder != nil || _frameCB == nil) { + return self.currentEncoder; + } + MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor renderPassDescriptor]; + id colorTex = _postActive ? self.sceneTexture : _frameDrawable.texture; + rpd.colorAttachments[0].texture = colorTex; + rpd.colorAttachments[0].clearColor = _clearColor; + rpd.colorAttachments[0].loadAction = _pendingClearColor ? MTLLoadActionClear : MTLLoadActionLoad; + rpd.colorAttachments[0].storeAction = MTLStoreActionStore; + rpd.depthAttachment.texture = _depthTexture; + rpd.depthAttachment.clearDepth = 1.0; + rpd.depthAttachment.loadAction = _pendingClearDepth ? MTLLoadActionClear + : (_encodedThisFrame ? MTLLoadActionLoad : MTLLoadActionDontCare); + rpd.depthAttachment.storeAction = MTLStoreActionDontCare; + _pendingClearColor = NO; + _pendingClearDepth = NO; + id encoder = [_frameCB renderCommandEncoderWithDescriptor:rpd]; + if (!_hasViewport) { + _viewport = (MTLViewport){0.0, 0.0, (double) _frameW, (double) _frameH, 0.0, 1.0}; + } + [encoder setViewport:_viewport]; + self.currentEncoder = encoder; + _encodedThisFrame = YES; + return encoder; +} + +- (BOOL)isInShadowPass { + return _inShadow; +} + +- (BOOL)shadowAvailable { + return _shadowReady && !_inShadow && self.shadowTexture != nil; +} + +// Ends whichever pass is open so the next one can begin. +- (void)closeEncoder { + if (self.currentEncoder != nil) { + [self.currentEncoder endEncoding]; + self.currentEncoder = nil; + } +} + +- (BOOL)beginShadowPass:(int)size { + if (_frameCB == nil || _inShadow || size < 16 || size > 8192) { + return NO; + } + if (self.shadowTexture == nil || (int) self.shadowTexture.width != size) { + MTLTextureDescriptor *td = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:CN1GL3D_DEPTH_FORMAT + width:size height:size mipmapped:NO]; + td.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; + td.storageMode = MTLStorageModePrivate; + self.shadowTexture = [_device newTextureWithDescriptor:td]; + if (self.shadowTexture == nil) { + return NO; + } + } + [self closeEncoder]; + MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor renderPassDescriptor]; + rpd.depthAttachment.texture = self.shadowTexture; + rpd.depthAttachment.clearDepth = 1.0; + rpd.depthAttachment.loadAction = MTLLoadActionClear; + rpd.depthAttachment.storeAction = MTLStoreActionStore; + id enc = [_frameCB renderCommandEncoderWithDescriptor:rpd]; + [enc setViewport:(MTLViewport){0.0, 0.0, (double) size, (double) size, 0.0, 1.0}]; + self.currentEncoder = enc; + _inShadow = YES; + return YES; +} + +- (void)endShadowPass { + if (!_inShadow) { + return; + } + [self closeEncoder]; + _inShadow = NO; + _shadowReady = YES; +} + +- (BOOL)buildPost:(NSString *)source { + NSError *err = nil; + id lib = [self.device newLibraryWithSource:source options:nil error:&err]; + if (lib == nil) { + NSLog(@"[CN1GL3D] post process shader compile failed: %@", err); + return NO; + } + id vfn = [lib newFunctionWithName:@"cn1_post_vertex"]; + NSArray *names = @[@"cn1_bright_fragment", @"cn1_blur_fragment", @"cn1_composite_fragment"]; + id states[3]; + for (int i = 0; i < 3; i++) { + id ffn = [lib newFunctionWithName:names[i]]; + if (vfn == nil || ffn == nil) { + NSLog(@"[CN1GL3D] missing post process entry point %@", names[i]); + return NO; + } + MTLRenderPipelineDescriptor *desc = [[MTLRenderPipelineDescriptor alloc] init]; + desc.vertexFunction = vfn; + desc.fragmentFunction = ffn; + desc.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm; + states[i] = [self.device newRenderPipelineStateWithDescriptor:desc error:&err]; + if (states[i] == nil) { + NSLog(@"[CN1GL3D] post process pipeline failed: %@", err); + return NO; + } + } + CN1GL3DPost *p = [[CN1GL3DPost alloc] init]; + p.bright = states[0]; + p.blur = states[1]; + p.composite = states[2]; + self.post = p; + return YES; +} + +- (id)colorTarget:(id)existing w:(int)w h:(int)h { + if (existing != nil && (int) existing.width == w && (int) existing.height == h) { + return existing; + } + MTLTextureDescriptor *td = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm + width:w height:h mipmapped:NO]; + td.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; + td.storageMode = MTLStorageModePrivate; + return [_device newTextureWithDescriptor:td]; +} + +- (BOOL)beginPostProcess:(NSString *)source bloom:(BOOL)bloom { + if (_frameCB == nil || _postActive || _inShadow || _frameW < 1 || _frameH < 1) { + return NO; + } + if (self.post == nil && (source == nil || ![self buildPost:source])) { + return NO; + } + self.sceneTexture = [self colorTarget:self.sceneTexture w:_frameW h:_frameH]; + if (bloom) { + int bw = _frameW / 4 < 1 ? 1 : _frameW / 4; + int bh = _frameH / 4 < 1 ? 1 : _frameH / 4; + self.bloomA = [self colorTarget:self.bloomA w:bw h:bh]; + self.bloomB = [self colorTarget:self.bloomB w:bw h:bh]; + } + if (self.sceneTexture == nil || (bloom && (self.bloomA == nil || self.bloomB == nil))) { + return NO; + } + // Whatever was drawn to the screen so far is overwritten by the composite. + [self closeEncoder]; + _postActive = YES; + _postBloom = bloom; + _pendingClearColor = YES; + _pendingClearDepth = YES; + return YES; +} + +// One full screen pass into dst. bytes is 16 bytes (bright, blur) or the +// composite block. +- (void)postPass:(id)dst pipeline:(id)pso + tex0:(id)t0 tex1:(id)t1 bytes:(const void *)bytes length:(int)length { + MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor renderPassDescriptor]; + rpd.colorAttachments[0].texture = dst; + rpd.colorAttachments[0].loadAction = MTLLoadActionDontCare; + rpd.colorAttachments[0].storeAction = MTLStoreActionStore; + id enc = [_frameCB renderCommandEncoderWithDescriptor:rpd]; + [enc setRenderPipelineState:pso]; + [enc setFragmentBytes:bytes length:length atIndex:0]; + [enc setFragmentTexture:t0 atIndex:0]; + if (t1 != nil) { + [enc setFragmentTexture:t1 atIndex:1]; + } + [enc drawPrimitives:MTLPrimitiveTypeTriangleStrip vertexStart:0 vertexCount:4]; + [enc endEncoding]; +} + +// params: 0 threshold, 1 bloom intensity, 2 contrast, 3 saturation, 4-7 lift, +// 8-11 gain, 12 vignette, 13 grain, 14 time, 15 bloom on. +- (void)endPostProcess:(const float *)p { + if (!_postActive) { + return; + } + [self activeEncoder]; // opens the scene pass if nothing drew, applying the clear + [self closeEncoder]; + _postActive = NO; + id scene = self.sceneTexture; + id bloomTex = scene; + BOOL bloom = _postBloom && p[15] > 0.5f; + if (bloom) { + float bw = (float) self.bloomA.width; + float bh = (float) self.bloomA.height; + float bright[4] = {p[0], 1.0f / (float) scene.width, 1.0f / (float) scene.height, 0.0f}; + [self postPass:self.bloomA pipeline:self.post.bright tex0:scene tex1:nil bytes:bright length:16]; + for (int i = 0; i < 2; i++) { + float spread = 1.0f + (float) i; + float dx[4] = {spread / bw, 0.0f, 0.0f, 0.0f}; + [self postPass:self.bloomB pipeline:self.post.blur tex0:self.bloomA tex1:nil bytes:dx length:16]; + float dy[4] = {0.0f, spread / bh, 0.0f, 0.0f}; + [self postPass:self.bloomA pipeline:self.post.blur tex0:self.bloomB tex1:nil bytes:dy length:16]; + } + bloomTex = self.bloomA; + } + float u[16] = { + bloom ? p[1] : 0.0f, p[2], p[3], p[12], + p[4], p[5], p[6], 0.0f, + p[8], p[9], p[10], 0.0f, + p[13], p[14], (float) scene.width, (float) scene.height + }; + [self postPass:_frameDrawable.texture pipeline:self.post.composite tex0:scene tex1:bloomTex bytes:u length:64]; + // Drawing continues on the screen, which now holds the composite; its depth + // was never stored, so it starts clean. + _pendingClearColor = NO; + _pendingClearDepth = YES; } - (CN1GL3DPipeline *)pipelineForKey:(NSString *)key source:(NSString *)mslSource @@ -245,8 +496,11 @@ - (CN1GL3DPipeline *)pipelineForKey:(NSString *)key source:(NSString *)mslSource return nil; } id vfn = [lib newFunctionWithName:@"cn1_vertex_main"]; - id ffn = [lib newFunctionWithName:@"cn1_fragment_main"]; - if (vfn == nil || ffn == nil) { + // A "shadow|" key is a depth-only caster pipeline: no fragment function and + // no colour attachment, to match the shadow map pass. + BOOL depthOnly = [key hasPrefix:@"shadow|"]; + id ffn = depthOnly ? nil : [lib newFunctionWithName:@"cn1_fragment_main"]; + if (vfn == nil || (ffn == nil && !depthOnly)) { NSLog(@"[CN1GL3D] missing shader entry points for %@", key); return nil; } @@ -254,10 +508,12 @@ - (CN1GL3DPipeline *)pipelineForKey:(NSString *)key source:(NSString *)mslSource MTLRenderPipelineDescriptor *desc = [[MTLRenderPipelineDescriptor alloc] init]; desc.vertexFunction = vfn; desc.fragmentFunction = ffn; - desc.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm; + desc.colorAttachments[0].pixelFormat = depthOnly ? MTLPixelFormatInvalid : MTLPixelFormatBGRA8Unorm; desc.depthAttachmentPixelFormat = CN1GL3D_DEPTH_FORMAT; - if (blendMode == 1) { // ALPHA (source-over) + if (depthOnly) { + desc.colorAttachments[0].blendingEnabled = NO; + } else if (blendMode == 1) { // ALPHA (source-over) desc.colorAttachments[0].blendingEnabled = YES; desc.colorAttachments[0].rgbBlendOperation = MTLBlendOperationAdd; desc.colorAttachments[0].alphaBlendOperation = MTLBlendOperationAdd; @@ -340,6 +596,11 @@ - (void)teardown { [self.displayLink invalidate]; self.displayLink = nil; [self.pipelineCache removeAllObjects]; + self.shadowTexture = nil; + self.sceneTexture = nil; + self.bloomA = nil; + self.bloomB = nil; + self.post = nil; } @end @@ -538,7 +799,9 @@ void com_codename1_impl_ios_IOSNative_gl3dSetViewport___long_int_int_int_int( if (contextPeer == 0) return; CN1GL3DView *view = (__bridge CN1GL3DView *)(void *) contextPeer; [view recordViewport:x y:y width:width height:height]; - id enc = [view activeEncoder]; + // Only an already open main pass is touched; opening one here would run + // ahead of a shadow pass or post process target the frame is about to begin. + id enc = [view isInShadowPass] ? nil : view.currentEncoder; if (enc != nil) { MTLViewport vp = (MTLViewport){(double) x, (double) y, (double) width, (double) height, 0.0, 1.0}; [enc setViewport:vp]; @@ -576,16 +839,35 @@ static void CN1GL3DBindCommon(CN1GL3DView *view, CN1GL3DPipeline *p, [enc setVertexBytes:uptr length:ubytes atIndex:1]; [enc setFragmentBytes:uptr length:ubytes atIndex:1]; + // The shadow map rides along at fragment texture 1 for every draw once one + // has been rendered; shaders that do not sample it ignore the binding. + if ([view shadowAvailable]) { + [enc setFragmentTexture:view.shadowTexture atIndex:1]; + } if (texturePeer != 0) { id tex = (__bridge id)(void *) texturePeer; [enc setFragmentTexture:tex atIndex:0]; - MTLSamplerDescriptor *sd = [[MTLSamplerDescriptor alloc] init]; - sd.minFilter = texFilter ? MTLSamplerMinMagFilterLinear : MTLSamplerMinMagFilterNearest; - sd.magFilter = texFilter ? MTLSamplerMinMagFilterLinear : MTLSamplerMinMagFilterNearest; - sd.sAddressMode = texWrap ? MTLSamplerAddressModeRepeat : MTLSamplerAddressModeClampToEdge; - sd.tAddressMode = texWrap ? MTLSamplerAddressModeRepeat : MTLSamplerAddressModeClampToEdge; - id sampler = [view.device newSamplerStateWithDescriptor:sd]; - [enc setFragmentSamplerState:sampler atIndex:0]; + // One sampler per filter/wrap pair, made once per device: a scene of + // sprites draws hundreds of textured quads a frame, and a new sampler + // state for each was an allocation per draw. + static id cn1Samplers[4]; + static void *cn1SamplerDevice = NULL; + if (cn1SamplerDevice != (__bridge void *) view.device) { + for (int i = 0; i < 4; i++) { + cn1Samplers[i] = nil; + } + cn1SamplerDevice = (__bridge void *) view.device; + } + int key = (texFilter ? 1 : 0) | (texWrap ? 2 : 0); + if (cn1Samplers[key] == nil) { + MTLSamplerDescriptor *sd = [[MTLSamplerDescriptor alloc] init]; + sd.minFilter = texFilter ? MTLSamplerMinMagFilterLinear : MTLSamplerMinMagFilterNearest; + sd.magFilter = texFilter ? MTLSamplerMinMagFilterLinear : MTLSamplerMinMagFilterNearest; + sd.sAddressMode = texWrap ? MTLSamplerAddressModeRepeat : MTLSamplerAddressModeClampToEdge; + sd.tAddressMode = texWrap ? MTLSamplerAddressModeRepeat : MTLSamplerAddressModeClampToEdge; + cn1Samplers[key] = [view.device newSamplerStateWithDescriptor:sd]; + } + [enc setFragmentSamplerState:cn1Samplers[key] atIndex:0]; } } @@ -622,6 +904,37 @@ void com_codename1_impl_ios_IOSNative_gl3dDrawArrays___long_long_long_int_int_in [[view activeEncoder] drawPrimitives:CN1GL3DPrimitive(primitive) vertexStart:0 vertexCount:vertexCount]; } +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_gl3dBeginShadowPass___long_int_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_INT size) { + if (contextPeer == 0) return 0; + CN1GL3DView *view = (__bridge CN1GL3DView *)(void *) contextPeer; + return [view beginShadowPass:size] ? 1 : 0; +} + +void com_codename1_impl_ios_IOSNative_gl3dEndShadowPass___long( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer) { + if (contextPeer == 0) return; + CN1GL3DView *view = (__bridge CN1GL3DView *)(void *) contextPeer; + [view endShadowPass]; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_gl3dBeginPostProcess___long_java_lang_String_boolean_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, + JAVA_OBJECT mslSource, JAVA_BOOLEAN bloom) { + if (contextPeer == 0) return 0; + CN1GL3DView *view = (__bridge CN1GL3DView *)(void *) contextPeer; + NSString *src = mslSource == JAVA_NULL ? nil : toNSString(CN1_THREAD_GET_STATE_PASS_ARG mslSource); + return [view beginPostProcess:src bloom:bloom ? YES : NO] ? 1 : 0; +} + +void com_codename1_impl_ios_IOSNative_gl3dEndPostProcess___long_float_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_OBJECT params) { + if (contextPeer == 0) return; + CN1GL3DView *view = (__bridge CN1GL3DView *)(void *) contextPeer; + JAVA_ARRAY_FLOAT *p = (JAVA_ARRAY_FLOAT *)CN1_ARRAY_DATA((JAVA_ARRAY)params); + [view endPostProcess:p]; +} + #endif /* CN1_USE_METAL */ #else diff --git a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m index 8dcb97d93ea..dd116b2f71e 100644 --- a/Ports/iOSPort/nativeSources/CN1WatchRuntime.m +++ b/Ports/iOSPort/nativeSources/CN1WatchRuntime.m @@ -465,5 +465,9 @@ void com_codename1_impl_ios_IOSNative_gl3dClear___long_int_boolean_boolean(CN1_T void com_codename1_impl_ios_IOSNative_gl3dSetViewport___long_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height) {} void com_codename1_impl_ios_IOSNative_gl3dDrawIndexed___long_long_long_int_long_int_int_float_1ARRAY_int_long_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_LONG iboPeer, JAVA_INT indexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) {} void com_codename1_impl_ios_IOSNative_gl3dDrawArrays___long_long_long_int_int_int_float_1ARRAY_int_long_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_LONG pipelinePeer, JAVA_LONG vboPeer, JAVA_INT strideBytes, JAVA_INT vertexCount, JAVA_INT primitive, JAVA_OBJECT uniforms, JAVA_INT uniformFloats, JAVA_LONG texturePeer, JAVA_INT texFilter, JAVA_INT texWrap) {} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_gl3dBeginShadowPass___long_int_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_INT size) { return 0; } +void com_codename1_impl_ios_IOSNative_gl3dEndShadowPass___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer) {} +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_gl3dBeginPostProcess___long_java_lang_String_boolean_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_OBJECT mslSource, JAVA_BOOLEAN bloom) { return 0; } +void com_codename1_impl_ios_IOSNative_gl3dEndPostProcess___long_float_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG contextPeer, JAVA_OBJECT params) {} #endif // TARGET_OS_WATCH diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSGraphicsDevice.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSGraphicsDevice.java index 17840b93125..01cd36fc3e8 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSGraphicsDevice.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSGraphicsDevice.java @@ -6,10 +6,24 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.ios; import com.codename1.gpu.Camera; +import com.codename1.gpu.Environment; import com.codename1.gpu.GpuCapabilities; import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.IndexBuffer; @@ -17,11 +31,13 @@ import com.codename1.gpu.Material; import com.codename1.gpu.Matrix4; import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; import com.codename1.gpu.PrimitiveType; import com.codename1.gpu.RenderState; import com.codename1.gpu.Texture; import com.codename1.gpu.VertexBuffer; import com.codename1.gpu.VertexFormat; +import com.codename1.impl.gpu.GlslShaderGenerator; import com.codename1.ui.Image; import java.util.HashMap; @@ -52,16 +68,28 @@ class IOSGraphicsDevice extends GraphicsDevice { private final Map pipelines = new HashMap(); private final GpuCapabilities caps = new GpuCapabilities( - 8192, 16, true, true, true, "Codename One Metal (iOS)"); + 8192, 16, true, true, true, "Codename One Metal (iOS)", true, true); // Scratch matrices reused every draw to avoid per-frame allocation. private final float[] mvp = new float[16]; // SIMD aligned uniform block handed straight to Metal. Layout must match the // CN1Uniforms struct emitted by IOSMetalShaderGenerator and copied on the - // native side: 4 mat4 (64 floats) + 4 vec4 (16 floats) + shininess + pad. + // native side: 3 mat4 (48 floats) + 5 vec4 (20 floats) + shininess + pad to 72, then 9 vec4 of + // environment, PBR and sky values (see packEnvironment), then the shadow + // matrix (16 floats) and a vec4 of bias, strength and texel size. // We pad to a multiple of 16 for the aligned allocator. - private static final int UNIFORM_FLOATS = 96; + private static final int UNIFORM_FLOATS = 128; + + // Shadow map state: the light's view-projection (also read by receivers + // through the uniform block), and whether a map has been rendered yet. + private final float[] shadowMatrix = new float[16]; + private int shadowSize = 1; + private boolean inShadowPass; + private boolean shadowReady; + private PostProcess activePost; + private String postSource; + private final float[] postParams = new float[16]; private final float[] uniforms = allocAligned(UNIFORM_FLOATS); IOSGraphicsDevice(long contextPeer) { @@ -114,6 +142,10 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { if (contextPeer == 0) { return; } + if (inShadowPass) { + drawShadowCaster(mesh, material, modelMatrix); + return; + } PrimitiveType type = mesh.getPrimitiveType(); VertexBuffer vb = mesh.getVertices(); @@ -123,13 +155,18 @@ public void draw(Mesh mesh, Material material, float[] modelMatrix) { if (vboHandle == 0) { return; } - long pipeline = getOrCreatePipeline(material, fmt); + boolean shadowed = shadowReady && isShadowMapCurrent() && getLight().isShadows() && material.isLit() + && material.isReceiveShadows(); + long pipeline = getOrCreatePipeline(material, fmt, getEnvironment(), shadowed); if (pipeline == 0) { return; } float[] model = modelMatrix != null ? modelMatrix : Matrix4.identity(); packUniforms(material, model); + if (shadowed) { + packShadow(getLight()); + } long texHandle = 0; int texFilter = 0; @@ -196,9 +233,10 @@ private long uploadIndexBuffer(IndexBuffer ib) { return peer; } - private long getOrCreatePipeline(Material material, VertexFormat fmt) { + private long getOrCreatePipeline(Material material, VertexFormat fmt, Environment env, + boolean shadowed) { RenderState rs = material.getRenderState(); - String key = material.getShaderKey() + String key = material.getShaderKey() + "|" + GlslShaderGenerator.variantKey(env, shadowed) + "|s" + fmt.getStrideBytes() + "|b" + blendCode(rs.getBlendMode()) + "|c" + cullCode(rs.getCullMode()) @@ -208,7 +246,7 @@ private long getOrCreatePipeline(Material material, VertexFormat fmt) { if (existing != null) { return existing.longValue(); } - IOSMetalShaderGenerator gen = new IOSMetalShaderGenerator(material, fmt); + IOSMetalShaderGenerator gen = new IOSMetalShaderGenerator(material, fmt, env, shadowed); String src = gen.getSource(); long pipeline = IOSImplementation.nativeInstance.gl3dGetOrCreatePipeline( contextPeer, key, src, @@ -271,7 +309,168 @@ private void packUniforms(Material material, float[] model) { uniforms[o++] = 1.0f; // shininess (1) + pad to keep the layout stable uniforms[o++] = material.getShininess(); - // remaining floats are padding for alignment; leave as-is + o = 72; + packEnvironment(material, o); + // a draw that does not receive shadows must not see the previous one's + for (int i = 108; i < UNIFORM_FLOATS; i++) { + uniforms[i] = 0.0f; + } + } + + private void packShadow(Light light) { + for (int i = 0; i < 16; i++) { + uniforms[108 + i] = shadowMatrix[i]; + } + uniforms[124] = light.getShadowBias(); + uniforms[125] = light.getShadowStrength(); + uniforms[126] = 1.0f / Math.max(1, shadowSize); + uniforms[127] = 0.0f; + } + + public boolean beginShadowPass(Light light) { + if (contextPeer == 0 || light == null || inShadowPass) { + return false; + } + int size = light.getShadowMapSize(); + if (!IOSImplementation.nativeInstance.gl3dBeginShadowPass(contextPeer, size)) { + return false; + } + shadowSize = size; + light.computeShadowMatrix(shadowMatrix); + inShadowPass = true; + return true; + } + + public void endShadowPass() { + if (!inShadowPass) { + return; + } + inShadowPass = false; + shadowReady = true; + shadowMapRendered(); + IOSImplementation.nativeInstance.gl3dEndShadowPass(contextPeer); + } + + // Depth only draw from the light. The pipeline key's "shadow|" prefix is + // what tells the native side to build it without a colour attachment. + private void drawShadowCaster(Mesh mesh, Material material, float[] modelMatrix) { + if (!material.isCastShadows() || material.getRenderState().getBlendMode() != RenderState.BlendMode.NONE + || material.getType() == Material.Type.SKYBOX) { + return; + } + VertexBuffer vb = mesh.getVertices(); + VertexFormat fmt = vb.getFormat(); + long vboHandle = uploadVertexBuffer(vb); + if (vboHandle == 0) { + return; + } + String key = "shadow|s" + fmt.getStrideBytes() + "|b0|c0|dt1|dw1"; + Long existing = pipelines.get(key); + long pipeline; + if (existing != null) { + pipeline = existing.longValue(); + } else { + pipeline = IOSImplementation.nativeInstance.gl3dGetOrCreatePipeline( + contextPeer, key, IOSMetalShaderGenerator.shadowSource(fmt), 0, 0, 1, 1); + pipelines.put(key, Long.valueOf(pipeline)); + } + if (pipeline == 0) { + return; + } + float[] model = modelMatrix != null ? modelMatrix : Matrix4.identity(); + Matrix4.multiply(shadowMatrix, model, mvp); + for (int i = 0; i < 16; i++) { + uniforms[i] = mvp[i]; + } + int primitive = primitiveCode(mesh.getPrimitiveType()); + int strideBytes = fmt.getStrideBytes(); + if (mesh.isIndexed()) { + IndexBuffer ib = mesh.getIndices(); + long iboHandle = uploadIndexBuffer(ib); + if (iboHandle == 0) { + return; + } + IOSImplementation.nativeInstance.gl3dDrawIndexed( + contextPeer, pipeline, vboHandle, strideBytes, iboHandle, + ib.getIndexCount(), primitive, uniforms, UNIFORM_FLOATS, 0, 0, 0); + } else { + IOSImplementation.nativeInstance.gl3dDrawArrays( + contextPeer, pipeline, vboHandle, strideBytes, + vb.getVertexCount(), primitive, uniforms, UNIFORM_FLOATS, 0, 0, 0); + } + } + + public boolean beginPostProcess(PostProcess effects, int width, int height) { + if (contextPeer == 0 || effects == null || activePost != null) { + return false; + } + if (postSource == null) { + postSource = IOSMetalShaderGenerator.postSource(); + } + if (!IOSImplementation.nativeInstance.gl3dBeginPostProcess(contextPeer, postSource, + effects.isBloom())) { + return false; + } + activePost = effects; + return true; + } + + public void endPostProcess(float timeSeconds) { + PostProcess pp = activePost; + if (pp == null) { + return; + } + activePost = null; + float[] p = postParams; + p[0] = pp.getBloomThreshold(); + p[1] = pp.getBloomIntensity(); + p[2] = pp.getContrast(); + p[3] = pp.getSaturation(); + int lift = pp.getShadowTint(); + p[4] = ((lift >> 16) & 0xff) / 255.0f; + p[5] = ((lift >> 8) & 0xff) / 255.0f; + p[6] = (lift & 0xff) / 255.0f; + p[7] = 0.0f; + int gain = pp.getHighlightTint(); + p[8] = ((gain >> 16) & 0xff) / 255.0f; + p[9] = ((gain >> 8) & 0xff) / 255.0f; + p[10] = (gain & 0xff) / 255.0f; + p[11] = 0.0f; + p[12] = pp.getVignette(); + p[13] = pp.getGrain(); + p[14] = timeSeconds; + p[15] = pp.isBloom() ? 1.0f : 0.0f; + IOSImplementation.nativeInstance.gl3dEndPostProcess(contextPeer, p); + } + + private void rgb4(int o, int c, float w) { + uniforms[o] = ((c >> 16) & 0xff) / 255.0f; + uniforms[o + 1] = ((c >> 8) & 0xff) / 255.0f; + uniforms[o + 2] = (c & 0xff) / 255.0f; + uniforms[o + 3] = w; + } + + // Environment block, in the order of the trailing CN1Uniforms float4s. + private void packEnvironment(Material material, int o) { + Environment env = getEnvironment(); + if (env == null) { + env = new Environment(); + } + rgb4(o, env.getFogColor(), env.getExposure()); + uniforms[o + 4] = env.getFogDensity(); + uniforms[o + 5] = env.getFogHeightFalloff(); + uniforms[o + 6] = env.getFogBaseHeight(); + uniforms[o + 7] = 0.0f; + rgb4(o + 8, env.getSkyColor(), 1.0f); + rgb4(o + 12, env.getGroundColor(), 1.0f); + uniforms[o + 16] = material.getMetallic(); + uniforms[o + 17] = material.getRoughness(); + uniforms[o + 18] = 0.0f; + uniforms[o + 19] = 0.0f; + rgb4(o + 20, material.getEmissive(), 1.0f); + rgb4(o + 24, env.getZenithColor(), 1.0f); + rgb4(o + 28, env.getHorizonColor(), 1.0f); + rgb4(o + 32, env.getSunColor(), env.getCloudCover()); } private static int primitiveCode(PrimitiveType type) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSMetalShaderGenerator.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSMetalShaderGenerator.java index 377bec4f14d..541cb027b28 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSMetalShaderGenerator.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSMetalShaderGenerator.java @@ -6,9 +6,23 @@ * published by the Free Software Foundation. Codename One designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. */ package com.codename1.impl.ios; +import com.codename1.gpu.Environment; import com.codename1.gpu.Material; import com.codename1.gpu.VertexAttribute; import com.codename1.gpu.VertexFormat; @@ -29,7 +43,8 @@ /// [[stage_in]] VertexIn struct whose attribute indices match the float /// offsets supplied by the VertexFormat /// - a single Uniforms struct (mvp, model, normalMatrix, color, lightDir, -/// lightColor, ambient, eye, shininess) is bound at buffer index 1 for both +/// lightColor, ambient, eye, shininess, then the environment, PBR and sky +/// float4s) is bound at buffer index 1 for both /// stages /// - the diffuse texture is bound at texture index 0 with a sampler at index 0 public final class IOSMetalShaderGenerator { @@ -48,17 +63,81 @@ public final class IOSMetalShaderGenerator { /// /// - `format`: the mesh vertex layout public IOSMetalShaderGenerator(Material material, VertexFormat format) { + this(material, format, null); + } + + /// Generates the combined MSL source for a material and vertex layout in + /// an environment, without shadows. + /// + /// #### Parameters + /// + /// - `material`: the material describing the lighting model and inputs + /// + /// - `format`: the mesh vertex layout + /// + /// - `environment`: the fog, ambient and tone mapping in effect, or null + /// for the default + public IOSMetalShaderGenerator(Material material, VertexFormat format, Environment environment) { + this(material, format, environment, false); + } + + /// Generates the combined MSL source for a material and vertex layout in + /// an environment, optionally sampling the directional shadow map. + /// + /// #### Parameters + /// + /// - `material`: the material describing the lighting model and inputs + /// + /// - `format`: the mesh vertex layout + /// + /// - `environment`: the fog, ambient and tone mapping in effect, or null + /// for the default + /// + /// - `shadowed`: true if a shadow map is bound (texture 1) and the + /// material receives it + public IOSMetalShaderGenerator(Material material, VertexFormat format, Environment environment, + boolean shadowed) { boolean hasNormal = format.findByUsage(VertexAttribute.Usage.NORMAL) != null; boolean hasTexcoord = format.findByUsage(VertexAttribute.Usage.TEXCOORD) != null; - boolean textured = material.getTexture() != null && hasTexcoord; + Features f = new Features(); + f.textured = material.getTexture() != null && hasTexcoord; Material.Type type = material.getType(); - boolean lit = (type == Material.Type.LAMBERT || type == Material.Type.PHONG) && hasNormal; - boolean phong = type == Material.Type.PHONG && hasNormal; - this.source = build(format, lit, phong, textured, hasNormal, hasTexcoord); + f.lit = material.isLit() && hasNormal; + f.phong = type == Material.Type.PHONG && hasNormal; + f.pbr = type == Material.Type.PBR && hasNormal; + boolean sky = type == Material.Type.SKYBOX; + f.fog = environment != null && environment.isFog() && !sky; + f.sky = sky && environment != null && environment.isSky(); + f.hemisphere = f.lit && environment != null && environment.isHemisphere(); + f.toneMap = f.lit && environment != null && environment.isToneMapping(); + f.shadow = f.lit && shadowed && material.isReceiveShadows(); + f.emissive = f.lit && (material.getEmissive() & 0xffffff) != 0; + f.hasNormal = hasNormal; + f.hasTexcoord = hasTexcoord; + this.source = build(format, f); } - private static String build(VertexFormat format, boolean lit, boolean phong, - boolean textured, boolean hasNormal, boolean hasTexcoord) { + private static final class Features { + boolean textured; + boolean lit; + boolean phong; + boolean pbr; + boolean fog; + boolean hemisphere; + boolean toneMap; + boolean emissive; + boolean sky; + boolean shadow; + boolean hasNormal; + boolean hasTexcoord; + } + + private static String build(VertexFormat format, Features f) { + boolean lit = f.lit; + boolean textured = f.textured; + boolean hasNormal = f.hasNormal; + boolean hasTexcoord = f.hasTexcoord; + boolean world = f.lit || f.fog || f.sky; int posOff = offsetOf(format, VertexAttribute.Usage.POSITION); int normOff = offsetOf(format, VertexAttribute.Usage.NORMAL); int uvOff = offsetOf(format, VertexAttribute.Usage.TEXCOORD); @@ -79,6 +158,18 @@ private static String build(VertexFormat format, boolean lit, boolean phong, sb.append(" float4 ambient;\n"); sb.append(" float4 eye;\n"); sb.append(" float shininess;\n"); + // environment block: always present, only read by the variants that need it + sb.append(" float4 fogColor;\n"); // rgb, exposure + sb.append(" float4 fog;\n"); // density, height falloff, base height + sb.append(" float4 skyC;\n"); + sb.append(" float4 ground;\n"); + sb.append(" float4 pbrP;\n"); // metallic, roughness + sb.append(" float4 emissive;\n"); + sb.append(" float4 zenith;\n"); + sb.append(" float4 horizon;\n"); + sb.append(" float4 sun;\n"); // rgb, cloud cover + sb.append(" float4x4 shadowMatrix;\n"); + sb.append(" float4 shadowP;\n"); // bias, strength, texel size sb.append("};\n"); // Vertex input. The interleaved buffer is decoded with explicit @@ -97,11 +188,16 @@ private static String build(VertexFormat format, boolean lit, boolean phong, sb.append(" float4 position [[position]];\n"); if (lit) { sb.append(" float3 worldNormal;\n"); + } + if (world) { sb.append(" float3 worldPos;\n"); } if (textured) { sb.append(" float2 texcoord;\n"); } + if (f.shadow) { + sb.append(" float4 shadowPos;\n"); + } sb.append("};\n"); // Vertex function. @@ -120,14 +216,32 @@ private static String build(VertexFormat format, boolean lit, boolean phong, sb.append(" out.position = clip;\n"); if (lit) { sb.append(" out.worldNormal = (u.normalMatrix * float4(in.normal, 0.0)).xyz;\n"); + } + if (world) { sb.append(" out.worldPos = (u.model * float4(in.position, 1.0)).xyz;\n"); } if (textured) { sb.append(" out.texcoord = in.texcoord;\n"); } + if (f.shadow) { + sb.append(" out.shadowPos = u.shadowMatrix * float4(out.worldPos, 1.0);\n"); + } sb.append(" return out;\n"); sb.append("}\n"); + // Fragment helpers. + if (f.sky) { + appendSkyNoise(sb); + } + if (f.shadow) { + appendShadowLookup(sb); + } + if (f.toneMap) { + // Narkowicz's fit of the ACES filmic curve + sb.append("float3 aces(float3 x) {\n"); + sb.append(" return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);\n"); + sb.append("}\n"); + } // Fragment function. sb.append("fragment float4 ").append(FRAGMENT_FUNCTION).append("(\n"); sb.append(" CN1VertexOut in [[stage_in]],\n"); @@ -136,33 +250,250 @@ private static String build(VertexFormat format, boolean lit, boolean phong, sb.append(",\n texture2d tex [[texture(0)]],\n"); sb.append(" sampler texSampler [[sampler(0)]]"); } + if (f.shadow) { + sb.append(",\n depth2d shadowMap [[texture(1)]]"); + } sb.append(") {\n"); sb.append(" float4 base = u.color;\n"); if (textured) { sb.append(" base = base * tex.sample(texSampler, in.texcoord);\n"); } + sb.append(" float3 rgb = base.rgb;\n"); + if (f.sky) { + appendSky(sb); + } if (lit) { sb.append(" float3 n = normalize(in.worldNormal);\n"); sb.append(" float3 l = normalize(-u.lightDir.xyz);\n"); sb.append(" float ndotl = max(dot(n, l), 0.0);\n"); - sb.append(" float3 lighting = u.ambient.xyz + u.lightColor.xyz * ndotl;\n"); - sb.append(" float3 rgb = base.rgb * lighting;\n"); - if (phong) { - sb.append(" if (ndotl > 0.0) {\n"); - sb.append(" float3 v = normalize(u.eye.xyz - in.worldPos);\n"); - sb.append(" float3 h = normalize(l + v);\n"); - sb.append(" float spec = pow(max(dot(n, h), 0.0), u.shininess);\n"); - sb.append(" rgb += u.lightColor.xyz * spec;\n"); - sb.append(" }\n"); + if (f.hemisphere) { + sb.append(" float3 ambient = mix(u.ground.xyz, u.skyC.xyz, n.y * 0.5 + 0.5);\n"); + } else { + sb.append(" float3 ambient = u.ambient.xyz;\n"); + } + sb.append(f.shadow ? " float shade = cn1ShadowAt(in.shadowPos, n, l, u, shadowMap);\n" + : " float shade = 1.0;\n"); + if (f.pbr) { + appendPbr(sb); + } else { + sb.append(" rgb = base.rgb * (ambient + u.lightColor.xyz * ndotl * shade);\n"); + if (f.phong) { + sb.append(" if (ndotl > 0.0) {\n"); + sb.append(" float3 v = normalize(u.eye.xyz - in.worldPos);\n"); + sb.append(" float3 h = normalize(l + v);\n"); + sb.append(" float spec = pow(max(dot(n, h), 0.0), u.shininess);\n"); + sb.append(" rgb += u.lightColor.xyz * spec * shade;\n"); + sb.append(" }\n"); + } + } + if (f.emissive) { + sb.append(" rgb += u.emissive.xyz;\n"); } - sb.append(" return float4(rgb, base.a);\n"); - } else { - sb.append(" return base;\n"); + if (f.toneMap) { + sb.append(" rgb = aces(rgb * u.fogColor.w);\n"); + } + } + if (f.fog) { + sb.append(" float dist = length(in.worldPos - u.eye.xyz);\n"); + sb.append(" float fd = u.fog.x * dist;\n"); + sb.append(" float height = exp(-u.fog.y * max(in.worldPos.y - u.fog.z, 0.0));\n"); + sb.append(" float fogAmount = clamp((1.0 - exp(-fd * fd)) * height, 0.0, 1.0);\n"); + sb.append(" rgb = mix(rgb, u.fogColor.xyz, fogAmount);\n"); } + sb.append(" return float4(rgb, base.a);\n"); sb.append("}\n"); return sb.toString(); } + // 3x3 percentage closer filtering against the depth texture, comparing by + // hand exactly as the GLSL does. The map was rendered with +Y at the top of + // the texture, so the lookup flips v. + private static void appendShadowLookup(StringBuilder sb) { + sb.append("float cn1ShadowAt(float4 sp, float3 n, float3 l, constant CN1Uniforms& u, depth2d map) {\n"); + sb.append(" constexpr sampler ss(coord::normalized, address::clamp_to_edge, filter::nearest);\n"); + sb.append(" float3 p = sp.xyz / sp.w * 0.5 + 0.5;\n"); + sb.append(" if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) {\n"); + sb.append(" return 1.0;\n }\n"); + sb.append(" float bias = u.shadowP.x * (1.0 + 2.0 * (1.0 - max(dot(n, l), 0.0)));\n"); + sb.append(" float lit = 0.0;\n"); + sb.append(" for (int y = -1; y <= 1; y++) {\n"); + sb.append(" for (int x = -1; x <= 1; x++) {\n"); + sb.append(" float d = map.sample(ss, float2(p.x, 1.0 - p.y) + float2(float(x), float(y)) * u.shadowP.z);\n"); + sb.append(" lit += p.z - bias > d ? 0.0 : 1.0;\n"); + sb.append(" }\n }\n"); + sb.append(" return 1.0 - u.shadowP.y * (1.0 - lit / 9.0);\n"); + sb.append("}\n"); + } + + /// Returns the MSL of the shadow caster pipeline: a vertex function that + /// transforms by the matrix in the first uniform slot (the shadow matrix + /// times the model matrix) and no fragment function, so only depth is + /// written into the shadow map. + /// + /// #### Parameters + /// + /// - `format`: the mesh vertex layout + /// + /// #### Returns + /// + /// the MSL source with the `cn1_vertex_main` entry point + public static String shadowSource(VertexFormat format) { + int posOff = offsetOf(format, VertexAttribute.Usage.POSITION); + StringBuilder sb = new StringBuilder(); + sb.append("#include \n"); + sb.append("using namespace metal;\n"); + sb.append("struct CN1ShadowUniforms {\n float4x4 mvp;\n};\n"); + sb.append("struct CN1ShadowIn {\n float3 position [[attribute(").append(posOff).append(")]];\n};\n"); + sb.append("vertex float4 ").append(VERTEX_FUNCTION).append("(\n"); + sb.append(" CN1ShadowIn in [[stage_in]],\n"); + sb.append(" constant CN1ShadowUniforms& u [[buffer(1)]]) {\n"); + sb.append(" float4 clip = u.mvp * float4(in.position, 1.0);\n"); + sb.append(" clip.z = (clip.z + clip.w) * 0.5;\n"); + sb.append(" return clip;\n"); + sb.append("}\n"); + return sb.toString(); + } + + /// Returns the MSL of the post processing chain: one full screen vertex + /// function (`cn1_post_vertex`, a triangle strip of four vertices generated + /// from the vertex id) and the bright, blur and composite fragment + /// functions. Each fragment function reads one `float4` (bright, blur) or a + /// `CN1PostU` (composite) at fragment buffer 0 and the scene at texture 0, + /// the bloom at texture 1. Mirrors the GLSL passes term for term. + /// + /// #### Returns + /// + /// the MSL source + public static String postSource() { + StringBuilder sb = new StringBuilder(); + sb.append("#include \n"); + sb.append("using namespace metal;\n"); + sb.append("struct CN1PostV {\n float4 position [[position]];\n float2 uv;\n};\n"); + sb.append("struct CN1PostU {\n"); + sb.append(" float4 a;\n"); // bloom intensity, contrast, saturation, vignette + sb.append(" float4 lift;\n"); + sb.append(" float4 gain;\n"); + sb.append(" float4 b;\n"); // grain, time, width, height + sb.append("};\n"); + sb.append("vertex CN1PostV cn1_post_vertex(uint vid [[vertex_id]]) {\n"); + sb.append(" float2 p = float2((vid & 1u) != 0u ? 1.0 : -1.0, (vid & 2u) != 0u ? 1.0 : -1.0);\n"); + sb.append(" CN1PostV o;\n"); + sb.append(" o.position = float4(p, 0.0, 1.0);\n"); + // texture space has v = 0 at the top, clip space has +Y at the top + sb.append(" o.uv = float2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5);\n"); + sb.append(" return o;\n"); + sb.append("}\n"); + sb.append("constexpr sampler cn1PostS(coord::normalized, address::clamp_to_edge, filter::linear);\n"); + // a.x = threshold, a.yz = one source texel in uv + sb.append("fragment float4 cn1_bright_fragment(CN1PostV in [[stage_in]], constant float4& a [[buffer(0)]],\n"); + sb.append(" texture2d scene [[texture(0)]]) {\n"); + sb.append(" float2 t = a.yz;\n"); + sb.append(" float3 c = (scene.sample(cn1PostS, in.uv + t * float2(-0.5, -0.5)).rgb\n"); + sb.append(" + scene.sample(cn1PostS, in.uv + t * float2(0.5, -0.5)).rgb\n"); + sb.append(" + scene.sample(cn1PostS, in.uv + t * float2(-0.5, 0.5)).rgb\n"); + sb.append(" + scene.sample(cn1PostS, in.uv + t * float2(0.5, 0.5)).rgb) * 0.25;\n"); + sb.append(" float peak = max(c.r, max(c.g, c.b));\n"); + sb.append(" float over = max(peak - a.x, 0.0) / max(1.0 - a.x, 0.0001);\n"); + sb.append(" return float4(c * (over / max(peak, 0.0001)), 1.0);\n"); + sb.append("}\n"); + // a.xy = one texel along the blur direction in uv + sb.append("fragment float4 cn1_blur_fragment(CN1PostV in [[stage_in]], constant float4& a [[buffer(0)]],\n"); + sb.append(" texture2d scene [[texture(0)]]) {\n"); + sb.append(" float3 c = scene.sample(cn1PostS, in.uv).rgb * 0.2270270;\n"); + sb.append(" c += scene.sample(cn1PostS, in.uv + a.xy * 1.3846154).rgb * 0.3162162;\n"); + sb.append(" c += scene.sample(cn1PostS, in.uv - a.xy * 1.3846154).rgb * 0.3162162;\n"); + sb.append(" c += scene.sample(cn1PostS, in.uv + a.xy * 3.2307692).rgb * 0.0702703;\n"); + sb.append(" c += scene.sample(cn1PostS, in.uv - a.xy * 3.2307692).rgb * 0.0702703;\n"); + sb.append(" return float4(c, 1.0);\n"); + sb.append("}\n"); + sb.append("fragment float4 cn1_composite_fragment(CN1PostV in [[stage_in]], constant CN1PostU& u [[buffer(0)]],\n"); + sb.append(" texture2d scene [[texture(0)]], texture2d bloom [[texture(1)]]) {\n"); + sb.append(" float3 c = scene.sample(cn1PostS, in.uv).rgb;\n"); + sb.append(" c += bloom.sample(cn1PostS, in.uv).rgb * u.a.x;\n"); + sb.append(" c = (c - 0.5) * u.a.y + 0.5;\n"); + sb.append(" float luma = dot(c, float3(0.2126, 0.7152, 0.0722));\n"); + sb.append(" c = mix(float3(luma), c, u.a.z);\n"); + sb.append(" c = clamp(c, 0.0, 1.0);\n"); + sb.append(" c = (u.lift.xyz + c * (1.0 - u.lift.xyz)) * u.gain.xyz;\n"); + sb.append(" float2 d = in.uv - 0.5;\n"); + sb.append(" c *= 1.0 - u.a.w * smoothstep(0.25, 0.75, dot(d, d) * 2.0);\n"); + sb.append(" float2 px = floor(in.uv * u.b.zw);\n"); + sb.append(" float noise = fract(sin(dot(px + fract(u.b.y * 7.0) * 61.0, float2(12.9898, 78.233))) * 43758.5453);\n"); + sb.append(" c += (noise - 0.5) * u.b.x;\n"); + sb.append(" return float4(c, 1.0);\n"); + sb.append("}\n"); + return sb.toString(); + } + + // Value noise and four octaves of it, for the clouds. + private static void appendSkyNoise(StringBuilder sb) { + sb.append("float cn1Hash(float2 p) {\n"); + sb.append(" return fract(sin(dot(p, float2(127.1, 311.7))) * 43758.5453);\n"); + sb.append("}\n"); + sb.append("float cn1Noise(float2 p) {\n"); + sb.append(" float2 i = floor(p);\n"); + sb.append(" float2 fr = fract(p);\n"); + sb.append(" fr = fr * fr * (3.0 - 2.0 * fr);\n"); + sb.append(" return mix(mix(cn1Hash(i), cn1Hash(i + float2(1.0, 0.0)), fr.x),\n"); + sb.append(" mix(cn1Hash(i + float2(0.0, 1.0)), cn1Hash(i + float2(1.0, 1.0)), fr.x), fr.y);\n"); + sb.append("}\n"); + sb.append("float cn1Fbm(float2 p) {\n"); + sb.append(" float v = 0.0;\n"); + sb.append(" float a = 0.5;\n"); + sb.append(" for (int i = 0; i < 4; i++) {\n"); + sb.append(" v += a * cn1Noise(p);\n"); + sb.append(" p = p * 2.03 + float2(1.7, 9.2);\n"); + sb.append(" a *= 0.5;\n"); + sb.append(" }\n"); + sb.append(" return v;\n"); + sb.append("}\n"); + } + + // The open sky from the direction the pixel is seen in; u.sun is the sun + // colour and the cloud cover. + private static void appendSky(StringBuilder sb) { + sb.append(" float3 dir = normalize(in.worldPos - u.eye.xyz);\n"); + sb.append(" float up = clamp(dir.y, 0.0, 1.0);\n"); + sb.append(" rgb = mix(u.horizon.xyz, u.zenith.xyz, pow(up, 0.55));\n"); + sb.append(" float3 sunDir = normalize(-u.lightDir.xyz);\n"); + sb.append(" float sd = max(dot(dir, sunDir), 0.0);\n"); + sb.append(" if (dir.y > 0.0 && u.sun.w > 0.0) {\n"); + sb.append(" float2 cp = dir.xz / (dir.y + 0.12) * 1.6;\n"); + sb.append(" float c = cn1Fbm(cp);\n"); + sb.append(" float cover = smoothstep(1.0 - u.sun.w, 1.0 - u.sun.w + 0.35, c);\n"); + sb.append(" float cl = 0.75 + 0.25 * cn1Fbm(cp + sunDir.xz * 0.3);\n"); + sb.append(" float3 cloud = mix(u.horizon.xyz * 0.82, float3(1.0), cl) + u.sun.xyz * pow(sd, 6.0) * 0.35;\n"); + sb.append(" rgb = mix(rgb, cloud, cover * smoothstep(0.0, 0.25, dir.y));\n"); + sb.append(" }\n"); + sb.append(" rgb += u.sun.xyz * (pow(sd, 8.0) * 0.25 + pow(sd, 64.0) * 0.5 + smoothstep(0.9993, 0.9997, sd) * 2.0);\n"); + } + + // Cook-Torrance with a GGX distribution, Smith-Schlick geometry and + // Schlick's Fresnel, matching the GLSL generator term for term. + private static void appendPbr(StringBuilder sb) { + sb.append(" float metallic = u.pbrP.x;\n"); + sb.append(" float rough = u.pbrP.y;\n"); + sb.append(" float3 v = normalize(u.eye.xyz - in.worldPos);\n"); + sb.append(" float3 h = normalize(l + v);\n"); + sb.append(" float ndotv = max(dot(n, v), 0.001);\n"); + sb.append(" float ndoth = max(dot(n, h), 0.0);\n"); + sb.append(" float vdoth = max(dot(v, h), 0.0);\n"); + sb.append(" float3 f0 = mix(float3(0.04), base.rgb, metallic);\n"); + sb.append(" float a = rough * rough;\n"); + sb.append(" float a2 = a * a;\n"); + sb.append(" float dd = ndoth * ndoth * (a2 - 1.0) + 1.0;\n"); + sb.append(" float ggx = a2 / (3.14159265 * dd * dd);\n"); + sb.append(" float k = (rough + 1.0) * (rough + 1.0) / 8.0;\n"); + sb.append(" float geo = (ndotl / (ndotl * (1.0 - k) + k)) * (ndotv / (ndotv * (1.0 - k) + k));\n"); + sb.append(" float3 fres = f0 + (1.0 - f0) * pow(1.0 - vdoth, 5.0);\n"); + sb.append(" float3 spec = ggx * geo * fres / (4.0 * max(ndotl, 0.001) * ndotv);\n"); + sb.append(" float3 kd = (float3(1.0) - fres) * (1.0 - metallic);\n"); + sb.append(" float3 direct = (kd * base.rgb + spec * 3.14159265) * u.lightColor.xyz * ndotl * shade;\n"); + sb.append(" float3 fa = f0 + (max(float3(1.0 - rough), f0) - f0) * pow(1.0 - ndotv, 5.0);\n"); + sb.append(" float3 amb = ambient * (base.rgb * (1.0 - metallic) * (float3(1.0) - fa) + fa * (1.0 - rough * 0.7));\n"); + sb.append(" rgb = direct + amb;\n"); + } + private static int offsetOf(VertexFormat fmt, VertexAttribute.Usage usage) { for (int i = 0; i < fmt.getAttributeCount(); i++) { if (fmt.getAttribute(i).getUsage() == usage) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 044771fbc62..cd5f5e4aadc 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -833,6 +833,11 @@ native void gl3dDrawArrays(long contextPeer, long pipelinePeer, long vboPeer, in int vertexCount, int primitive, float[] uniforms, int uniformFloats, long texturePeer, int texFilter, int texWrap); + native boolean gl3dBeginShadowPass(long contextPeer, int size); + native void gl3dEndShadowPass(long contextPeer); + native boolean gl3dBeginPostProcess(long contextPeer, String mslSource, boolean bloom); + native void gl3dEndPostProcess(long contextPeer, float[] params); + native void destroyAudioUnit(long peer); native long createAudioUnit(String path, int audioChannels, float sampleRate, float[] f); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/ThreeDGraphicsJava007Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/ThreeDGraphicsJava007Snippet.java new file mode 100644 index 00000000000..adb7bfaf4e7 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/ThreeDGraphicsJava007Snippet.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gaming.GameView; +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class ThreeDGraphicsJava007Snippet { + + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + com.codename1.gaming.GameView view; + + void snippet() throws Exception { + // tag::3d-graphics-java-007[] + GameView view = new GameView() { + protected void update(double deltaSeconds) { + } + }; + view.getEnvironment() + .setFog(0xffb9c6cf, 0.0075f, 0.05f) + .setHemisphere(0xffa9bdd2, 0xff4f5a3c) + .setToneMapping(true) + .setExposure(1.3f) + .setSky(0xff4f7fb8, 0xffb9c6cf, 0xfffff0d0, 0.45f); + view.getLight().setShadows(true).setShadowMap(2048, 18f); + view.setPostProcess(new PostProcess() + .setBloom(0.78f, 0.55f) + .setGrade(1.12f, 0.86f, 0xff0b1720, 0xfffff2dc) + .setVignette(0.42f) + .setGrain(0.035f)); + // end::3d-graphics-java-007[] + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/splat.sh b/docs/demos/common/src/main/snippets/developer-guide/splat.sh new file mode 100644 index 00000000000..8242fc06cec --- /dev/null +++ b/docs/demos/common/src/main/snippets/developer-guide/splat.sh @@ -0,0 +1,19 @@ +// Snippets of the Splat chapter of the developer guide. Edit them here, not inline. + +// tag::splat-sh-server[] +CN1_PROFILE=dev backend/server.sh run +// end::splat-sh-server[] + +// tag::splat-sh-simulator[] +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +// end::splat-sh-simulator[] + +// tag::splat-sh-ios[] +mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false +// end::splat-sh-ios[] + +// tag::splat-sh-tests[] +mvn -pl rules test +mvn -pl rules,shared,backend -Dcodename1.platform=backend test +./run-e2e.sh +// end::splat-sh-tests[] diff --git a/docs/developer-guide/3D-Graphics.asciidoc b/docs/developer-guide/3D-Graphics.asciidoc index 9b126d9c774..4acce8c6f9d 100644 --- a/docs/developer-guide/3D-Graphics.asciidoc +++ b/docs/developer-guide/3D-Graphics.asciidoc @@ -79,6 +79,7 @@ lighting model: | `UNLIT` | Flat color/texture, no lighting. Ideal for UI, emissive surfaces. | `LAMBERT` | Diffuse (Lambert) lighting from one directional light. | `PHONG` | Diffuse + specular highlight (uses `setShininess`). +| `PBR` | Physically based: metallic and roughness (`setMetallic`, `setRoughness`) with a GGX highlight, plus `setEmissive` for surfaces that glow. `GltfLoader` fills these from a model's own materials. | `SPRITE` | Unlit, for screen aligned sprites and billboards. | `SKYBOX` | Unlit background, rendered behind the scene. |=== @@ -151,6 +152,48 @@ https://www.codenameone.com/javadoc/com/codename1/gpu/Matrix4.html[Matrix4] `lookAt`, `perspective`, `ortho`. Pass a model matrix as the third argument to `draw`, or `null` for the identity. +=== Atmosphere, shadows and post processing + +Three settings turn a correctly lit scene into one that looks shot outdoors +through a lens. All three start off, so a scene that never touches them +renders exactly as before. + +An `Environment` (`GraphicsDevice.setEnvironment`, or `getEnvironment()` on a +`GameView`) holds the atmosphere every draw is shaded in: + +* *Fog* -- exponential squared in distance, thinned with height, so distant + geometry melts into the haze. +* *Hemisphere ambient* -- a sky color lights upward faces and a ground color + downward ones, which gives shape to everything in shadow. +* *Tone mapping and exposure* -- a filmic (ACES) curve, so bright light rolls + off instead of clipping. +* *An open sky* -- `setSky` paints `SKYBOX` surfaces as a gradient from the + horizon to the zenith, with soft clouds and the sun where the light comes + from. Draw it on a large sphere around the camera; it needs no texture. + +`Light.setShadows(true)` renders the scene from the light into a shadow map +before each frame, and lit materials are darkened where it's blocked. Keep +`setShadowMap`'s extent as small as the view allows and move +`setShadowCenter` with the camera, since the map's resolution is spread over +the area it covers. A material that shouldn't cast (grass, particles) says +so with `setCastShadows(false)`. + +A `PostProcess` on the view renders the scene offscreen and adds bloom, a +color grade, a vignette and film grain on the way to the screen. Touch +controls are drawn after it and are never graded. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/ThreeDGraphicsJava007Snippet.java[tag=3d-graphics-java-007,indent=0] +---- + +Match the horizon color to the fog color and the far edge of the world +disappears into the sky. A device that can't render shadows or offscreen +says so through `GpuCapabilities.isShadowsSupported()` and +`isPostProcessSupported()`, and the scene is then drawn directly. The JavaSE +software rasterizer applies the fog, the ambient, tone mapping and the sky +gradient, but not shadows, clouds or the post chain. + === Platform notes * *JavaSE simulator* - OpenGL through JOGL, loaded from an isolated class so a diff --git a/docs/developer-guide/Splat-Implementors-Guide.asciidoc b/docs/developer-guide/Splat-Implementors-Guide.asciidoc new file mode 100644 index 00000000000..2aa22859988 --- /dev/null +++ b/docs/developer-guide/Splat-Implementors-Guide.asciidoc @@ -0,0 +1,366 @@ +[[splat-implementors-guide]] +== Splat: Making the paintball game your own + +Splat is a first-person team paintball shooter, four against four, that takes itself +with the same gravity as a dad in a borrowed tactical vest. Every match opens with a +grim mission briefing ("Operation: Borrowed Bedsheet"). Every hit lands a big red +*PAINTED* stamp, and every player who is out may try to wipe the paint off before the +referee notices. Under the jokes it's a real-time shooter that most game frameworks +would split across an engine, a networking product and a separate server: + +* The server referees every match. Phones send their inputs, the server runs the + match and sends snapshots back, and each phone predicts its own player so the + marker fires with no wait (<>). +* A shot from a phone with a slow connection still lands where it was aimed: the + rules make up for the shooter's lag (<>). +* Bots think inside the rules, so a practice match, a match the server fills with + bots and a replay all play the same way from the same seed. + +Everything is ordinary Codename One code in one project: the 3D field drawn with +`GameView`, the HUD as a component layered over it, and the server as a Codename One +Backend. Splat shares the module layout, the accounts, push notifications, the shop and +the operations console with <> and +<>. This chapter shows where each part lives and +what to change to make the game yours. + +[[splat-launch]] +=== Running it + +The project is in `scripts/splat`, and the Initializr generates it under your own +package and name. Start the server on the development profile, which keeps its +database in memory, seeds the demo accounts and simulates purchases and push +notifications: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/splat.sh[tag=splat-sh-server,indent=0] +---- + +Then the app in the simulator: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/splat.sh[tag=splat-sh-simulator,indent=0] +---- + +For a device build, generate the Xcode project: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/splat.sh[tag=splat-sh-ios,indent=0] +---- + +The rules have their own tests, the server has its own, and `run-e2e.sh` drives the app +through its real screens against a running server: + +[source,bash] +---- +include::../demos/common/src/main/snippets/developer-guide/splat.sh[tag=splat-sh-tests,indent=0] +---- + +[[splat-tour]] +=== What the sample does + +A player signs in, or plays a practice match as a guest, and lands on the main menu: a +vertical list of modes on the left over a 3D operator holding the equipped marker, and a +rank card with level, XP and currencies on the right. *OPERATIONS* opens the playlist: two +fields, the Lot 7 Self Storage yard and the Cul-de-Sac Siege, each with three modes and a +tactical map of the field drawn from its cover. Team Paint is first to 25 paints, Capture +the Flag is three captures with a flag that's clearly a bedsheet, and Hold the Snack Bar +is king of the hill. Each operation can be played as a quick match, with a party of +friends or as practice against bots. + +image::img/splat-home.png["The main menu: modes on the left, the operator and rank card behind them",scaledwidth=30%] +image::img/splat-operations.png["The operations playlist: a briefing and a tactical map for every field and mode",scaledwidth=30%] + +A match opens on a letterboxed briefing that types itself out, redactions included, then +hands over to the field in first person: the marker in hand bobs, sways and kicks, and +aiming down the sights zooms in and tightens the spread. The HUD has a minimap, the score +bar and clock, a killfeed, the hopper count, hit markers and damage arcs. The controls +are a virtual stick on the left, look by dragging on the right, and buttons to fire, +aim, jump, throw a paint grenade +and wipe. On a desktop the keyboard and mouse do the same. A hit player is out until +the respawn, arms up the way the rules of every field require, and may try to wipe +the paint off. One try in three goes unnoticed; the others cost three more seconds. + +image::img/splat-briefing.png["The briefing over the field before the whistle",scaledwidth=30%] +image::img/splat-match-practice.png["A practice match: minimap, score bar and killfeed above, the marker and hopper below",scaledwidth=30%] +image::img/splat-scoreboard.png["The scoreboard: both teams, best painter first",scaledwidth=30%] + +The view button lifts the camera to a bird's-eye view of the whole field, in a match, while +spectating and in a replay. When a ball finds you, the match shows an instant replay of +the last seconds from the shooter's eyes, at half speed, while the live match runs on +under it. + +image::img/splat-match-overhead.png["The overhead view: the whole field from above, the paint on the ground",scaledwidth=30%] +image::img/splat-instant-replay.png["An instant replay of a hit, seen from behind the shooter",scaledwidth=30%] + +The Mission Builder lets a player lay out half a field on a grid: drag props, place spawns +and the flag, pick the mode and write the briefing. The other half is the mirror image, so +every mission is fair. A play test puts the draft in front of bots at once, and publishing +it lets friends and parties play it like any built-in field. + +image::img/splat-mission-editor.png["The mission editor: the props palette, the grid and the mirrored enemy half",scaledwidth=30%] +image::img/splat-mission-playtest.png["Play testing a mission against bots",scaledwidth=30%] + +A party is a private lobby: a code, two rosters and a chat. The friends who join it play the next match together, +talk over WebRTC voice and trade emotes during it ("It bounced!" and "REF!"). + +image::img/splat-party-chat.png["A party: the code, the members and the chat",scaledwidth=30%] +image::img/splat-party-match.png["A party match with a friend's emote and chat on screen",scaledwidth=30%] + +On a desktop the same app is a window, not a phone. The menus keep to a readable column, +small text is sized for a monitor instead of being scaled down from a phone, and the match +drops the touch controls for the keyboard and mouse, with a wider view and a fuller HUD. + +image::img/splat-desktop-home.png["The main menu on a desktop",scaledwidth=60%] +image::img/splat-desktop-match.png["A match on a desktop: keyboard and mouse, no touch controls",scaledwidth=60%] +image::img/splat-desktop-shop.png["The store on a desktop, with its title bar and back arrow",scaledwidth=60%] + +[[splat-modules]] +=== The modules + +* `rules` is the game: a deterministic simulation in fixed-point integers with no + dependencies. The app predicts with it, the server runs it as the authority and the + replay viewer re-runs it. +* `shared` holds the REST interfaces and the objects they carry. +* `backend` is the server: accounts, matchmaking, the match rooms, results, the shop, + social features and the admin API. +* `common` is the app. `play/` is the match itself, `ui/` the surrounding screens, + `engage/` ads, analytics, push and the store, and `admin/` the operations console. + +What every full-stack sample needs and none of them is about comes from the +<> library: `Net`, the app's error handling around a REST call, and on the +server `Caller`, `Text` and the push senders. + +[[splat-rules]] +=== The rules are data + +The fields and markers are tables. A marker is a handful of numbers (time between +shots, muzzle speed, spread, hopper size, reload and how fast you move carrying it), +and rebalancing the game is editing one of them: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/rules/src/main/java/com/codenameone/examples/splat/rules/Catalog.java[tag=splat-markers,indent=0] +---- + +A field is a size, a base, a hill for the snack bar mode, the spawns, and boxes of +cover given by center and size in decimeters. `mirror` places each box twice, once per +side, so a field is fair by construction: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/rules/src/main/java/com/codenameone/examples/splat/rules/Catalog.java[tag=splat-arena,indent=0] +---- + +A box's kind (`bunker`, `minivan`, `snackbar`) picks its color and shape in the +client; the rules only see its extent. Change any of these and run +`mvn -pl rules test`: the golden replays report which recorded matches no longer end +the same way. Bump `RulesVersion.ID` when you change the rules themselves, so old +replays aren't judged by new rules. + +[[splat-field]] +=== Drawing the field + +`ArenaView` is a `GameView`. It builds the field from the arena's boxes when it starts, +keeps a pool of paintballs and paint splats, and places the camera at the player's eyes +on every frame. It tells the HUD what happened through one small interface, once per +drawn frame and once per event: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ArenaView.java[tag=splat-arena-view,indent=0] +---- + +The simulation runs at a fixed 30 ticks a second, whatever the frame rate, and the +view draws between the last two ticks: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ArenaView.java[tag=splat-fixed-tick,indent=0] +---- + +The look is the engine's, not a texture pack's: haze that thins with height, +an open sky with clouds and the sun, shadows from the props and trees, and a +color grade with bloom on the paint. It's a dozen lines at the start of the +view, and the menu backdrop uses the same calls with dusk colors: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ArenaView.java[tag=splat-atmosphere,indent=0] +---- + +What ticks the match is a `Driver`. Practice, an online match and a replay are three +drivers behind the same view and the same screen: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Driver.java[tag=splat-driver,indent=0] +---- + +The HUD is a plain component layered over the view, so it's styled from `theme.css` +and respects the safe area like any other screen. On a phone it's compact -- a +small minimap, a two line killfeed, the hopper in one tab -- and on a desktop or +tablet it shows everything. It steps aside entirely for the briefing and the +result card, so nothing in a match ever draws over a menu: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchForm.java[tag=splat-match-form,indent=0] +---- + +[[splat-prediction]] +=== The server referees, the phone predicts + +An online match is a room on the server. It collects each player's newest input, steps +the match, and every second tick sends a snapshot of the whole state with the newest +input it has applied for that player: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/MatchRoom.java[tag=splat-match-room,indent=0] +---- + +The phone doesn't wait for the snapshots. It applies its own input at once, and when a +snapshot arrives it puts the match back where the server says it's and plays the +inputs the server hasn't seen yet on top of it again: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OnlineDriver.java[tag=splat-prediction,indent=0] +---- + +[[splat-lag-comp]] +==== Making up for lag + +Every input carries the tick the player was looking at. When it reaches the server a +few ticks later, the ball is flown forward the ticks it lost on the way: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/rules/src/main/java/com/codenameone/examples/splat/rules/Match.java[tag=splat-lag-comp,indent=0] +---- + +The cap keeps a player with a slow connection from painting people around corners. +The match test plays a full party match and checks that the server's result replays to +the same paints: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/OnlineMatchTest.java[tag=splat-match-test,indent=0] +---- + +[[splat-parties]] +=== Parties, voice and replays + +The lobby is a socket to the server, shared by the party code, the chat and the start +of the match: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchLobby.java[tag=splat-lobby,indent=0] +---- + +Voice goes peer to peer over WebRTC, with the server passing only the offers and +answers: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Voice.java[tag=splat-voice,indent=0] +---- + +A replay is the seed and the human inputs, nothing more. The bots aren't in the file: +they think inside the rules, so they make the same choices again. The replay screen +follows any player, pauses, plays at double speed and skips ahead. + +=== The Mission Builder + +Players build fields of their own. The editor shows one half of the field from above; +the other half is that half turned around, built by the same `Arena.Builder.mirror` the +built-in yards use, so a player-made field is fair by construction. A `Mission` in the +rules module holds the props, spawns, flag, mode and briefing, travels as JSON, and is +checked by `Mission.problem()` on the phone and again on the server before it's +stored: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionEditor.java[tag=splat-mission-editor,indent=0] +---- + +Play-testing registers the draft as an arena and starts a practice match on it, so the +bots, the briefing and the camera see it as any other yard. Publishing stores it under a +code the server assigns, and the published list shows your missions and your friends'. +A mission is played online as the level `m-:`: before the server opens a +lobby for such a level it loads the mission and registers it, and from there the match, +the replay and the results are the same as for a built-in yard. + +[[splat-ops]] +=== Engagement, moderation and the console + +Results, trophies, coins and the season are settled on the server: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Results.java[tag=splat-results,indent=0] +---- + +The server also watches for what a modified client would do. A client that sends +inputs faster than the match ticks, or keeps turning faster than a wrist can, raises a +flag. A marker can't fire faster than its definition allows, because the server's rules +are the ones that fire it: + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Flags.java[tag=splat-flags,indent=0] +---- + +Flags land in the operations console, where an admin can warn, mute, suspend or ban, +and every action goes to the audit log: + +image::img/splat-admin-overview.png["The console's overview: players, live matches and open flags",scaledwidth=30%] + +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Moderation.java[tag=splat-moderation,indent=0] +---- + +The engagement tools (campaign links, invitations, the home screen spotlight, pushes +to an audience and scheduled pipelines) are the ones +<> walks through step by step, and they +work in Splat unchanged. + +[[splat-reskin]] +=== Making it yours + +* *Fields*: add an `Arena.Builder` to `Catalog.ARENAS`. The mission list, the server's + playlist and the bots pick it up at once. Give a new box kind a color in + `ArenaView`. +* *Markers*: add a `MarkerDef`, then sell it in the backend's shop `Catalog`. The + shipped markers are side-grades, not upgrades: the pump hits harder at range and the + ramping marker sprays. +* *Characters*: every operator is a rigged glTF model, `model_op-*.glb` in + `common/src/main/resources`, listed by team in `Models`. `tools/pack-art.py` packs + them from the CC0 sources in `ART.md`, keeps the clips the game plays, and smooths + their normals so a low-poly body shades as a round one; `tools/pack-art.py --smooth` + re-shades models already packed. Swap a model by packing your own under the same name. +* *The jokes*: the briefings are in `Mode`, the killfeed's excuses in `Hud`, and the + emotes in `MatchForm`. +* *The look*: every color is a variable at the top of `theme.css`: ++ +[source,css] +---- +include::../../scripts/splat/common/src/main/css/theme.css[tag=splat-palette,indent=0] +---- +* *Store, ads and push*: the product ids are in `AppConfig`, and the receipt validator + is chosen in `Services`: ++ +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/common/src/main/java/com/codenameone/examples/splat/AppConfig.java[tag=splat-monetization-ids,indent=0] +---- ++ +[source,java,role=cn1-no-try] +---- +include::../../scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Services.java[tag=splat-receipt-choice,indent=0] +---- diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 2c08b8dcacc..d0201cc391d 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -162,6 +162,8 @@ include::Skirmish-Implementors-Guide.asciidoc[] include::Dash-Implementors-Guide.asciidoc[] +include::Splat-Implementors-Guide.asciidoc[] + include::Parley-Users-Guide.asciidoc[] include::Parley-Implementors-Guide.asciidoc[] diff --git a/docs/developer-guide/img/splat-admin-overview.png b/docs/developer-guide/img/splat-admin-overview.png new file mode 100644 index 00000000000..a9fce401ef2 Binary files /dev/null and b/docs/developer-guide/img/splat-admin-overview.png differ diff --git a/docs/developer-guide/img/splat-briefing.png b/docs/developer-guide/img/splat-briefing.png new file mode 100644 index 00000000000..e5fd6b94870 Binary files /dev/null and b/docs/developer-guide/img/splat-briefing.png differ diff --git a/docs/developer-guide/img/splat-desktop-home.png b/docs/developer-guide/img/splat-desktop-home.png new file mode 100644 index 00000000000..4f513e34f67 Binary files /dev/null and b/docs/developer-guide/img/splat-desktop-home.png differ diff --git a/docs/developer-guide/img/splat-desktop-match.png b/docs/developer-guide/img/splat-desktop-match.png new file mode 100644 index 00000000000..34174cc4eb5 Binary files /dev/null and b/docs/developer-guide/img/splat-desktop-match.png differ diff --git a/docs/developer-guide/img/splat-desktop-shop.png b/docs/developer-guide/img/splat-desktop-shop.png new file mode 100644 index 00000000000..a2bffb4d3d6 Binary files /dev/null and b/docs/developer-guide/img/splat-desktop-shop.png differ diff --git a/docs/developer-guide/img/splat-home.png b/docs/developer-guide/img/splat-home.png new file mode 100644 index 00000000000..a428e26d85d Binary files /dev/null and b/docs/developer-guide/img/splat-home.png differ diff --git a/docs/developer-guide/img/splat-instant-replay.png b/docs/developer-guide/img/splat-instant-replay.png new file mode 100644 index 00000000000..37edf444f5e Binary files /dev/null and b/docs/developer-guide/img/splat-instant-replay.png differ diff --git a/docs/developer-guide/img/splat-match-overhead.png b/docs/developer-guide/img/splat-match-overhead.png new file mode 100644 index 00000000000..6ad183ab608 Binary files /dev/null and b/docs/developer-guide/img/splat-match-overhead.png differ diff --git a/docs/developer-guide/img/splat-match-practice.png b/docs/developer-guide/img/splat-match-practice.png new file mode 100644 index 00000000000..094c522b583 Binary files /dev/null and b/docs/developer-guide/img/splat-match-practice.png differ diff --git a/docs/developer-guide/img/splat-mission-editor.png b/docs/developer-guide/img/splat-mission-editor.png new file mode 100644 index 00000000000..09e8a7c5f1f Binary files /dev/null and b/docs/developer-guide/img/splat-mission-editor.png differ diff --git a/docs/developer-guide/img/splat-mission-playtest.png b/docs/developer-guide/img/splat-mission-playtest.png new file mode 100644 index 00000000000..dab0cc6bb2e Binary files /dev/null and b/docs/developer-guide/img/splat-mission-playtest.png differ diff --git a/docs/developer-guide/img/splat-operations.png b/docs/developer-guide/img/splat-operations.png new file mode 100644 index 00000000000..87cfe391924 Binary files /dev/null and b/docs/developer-guide/img/splat-operations.png differ diff --git a/docs/developer-guide/img/splat-party-chat.png b/docs/developer-guide/img/splat-party-chat.png new file mode 100644 index 00000000000..a0264ee13ef Binary files /dev/null and b/docs/developer-guide/img/splat-party-chat.png differ diff --git a/docs/developer-guide/img/splat-party-match.png b/docs/developer-guide/img/splat-party-match.png new file mode 100644 index 00000000000..39c35be9425 Binary files /dev/null and b/docs/developer-guide/img/splat-party-match.png differ diff --git a/docs/developer-guide/img/splat-scoreboard.png b/docs/developer-guide/img/splat-scoreboard.png new file mode 100644 index 00000000000..05c1028d99b Binary files /dev/null and b/docs/developer-guide/img/splat-scoreboard.png differ diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 6b4dff2e4d9..5b1645e104d 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -903,4 +903,8 @@ picocli subcommand subcommands transpiles +minimap +killfeed +paintballs +Paintballs gamepad diff --git a/docs/website/content/blog/build-a-paintball-shooter-in-java.md b/docs/website/content/blog/build-a-paintball-shooter-in-java.md new file mode 100644 index 00000000000..4602dda7ae5 --- /dev/null +++ b/docs/website/content/blog/build-a-paintball-shooter-in-java.md @@ -0,0 +1,31 @@ +--- +title: "Splat: A Team Paintball Shooter Template in Java, Refereed by Its Own Server" +slug: build-a-paintball-shooter-in-java +url: /blog/build-a-paintball-shooter-in-java/ +date: '2026-11-05' +author: Shai Almog +draft: true +description: "Splat is a full-stack, first-person team paintball shooter in Java: 3D matches four against four, a server that referees every match while the phone predicts, lag-compensated shots, parties with voice chat, replays, a shop and an admin console. Generate it from the Initializr and make it your own, royalty free." +--- + +Skirmish was a card battler and Dash a runner. Splat is the third game template, and it is the kind of game people assume needs a commercial engine: a first-person team shooter. Four against four, in 3D, online. The twist is that it's paintball, and it never stops making fun of itself. Every match opens with a mission briefing read in the gravest voice available ("Operation: Borrowed Bedsheet"). Every hit lands a big red **PAINTED** stamp across your goggles. A player who is out can try to wipe the paint off, and the referee catches them two times in three. You can generate it from the [Initializr](/initializr/) today, under your own name and package, and the source is MIT-0. + +## The server is the referee + +In a shooter you can't trust the phone. Splat's server runs every online match: each phone sends only its inputs (move, look, fire) and the server steps the match and sends a snapshot back every other tick. A modified client can send whatever it likes, but the server's rules decide what its marker does. + +Waiting for the server would make the marker feel like it fires through mud, so the phone doesn't wait. It runs the same rules module, applies its own input at once, and when a snapshot arrives it rewinds to the server's state and replays the inputs the server hasn't seen yet. When the guess was right, nothing moves. When someone else's ball got there first, the correction is one snapshot old. + +## Shots that land where you aimed + +Paintballs in Splat are real projectiles with an arc and a drop, not instant hits. The server checks every one, but that means a player on a slow connection would always aim at where people *were*. Every input carries the tick the player was looking at, and the server flies the ball forward the ticks it lost on the way. The catch-up is capped at a fifth of a second, so a very slow connection can't paint people around corners. + +## One rules module, three ways to play + +The rules are a plain Java module in fixed-point integers, with no floating point and no dependencies. The phone predicts with it, the server runs it as the authority, and the replay viewer runs it again from the seed and the human inputs. The bots think inside the rules too, which is why a replay doesn't need to record them: they make the same choices again. The same code runs a practice match on a plane, a quick match the server fills with bots, and a party of friends talking over WebRTC voice. + +## Make it your own + +A field is a table of boxes, mirrored so both sides are fair. A marker is six numbers. The briefings, the killfeed excuses and the emotes are strings in one place each. Change a field and the mission list, the server's playlist and the bots all pick it up. Around the game is everything a live game needs and every template here shares: accounts, a shop with server-checked receipts, rewarded ads, pushes, crews, quests, seasons and an operations console with flags for clients that send too many inputs or turn faster than a wrist can. + +The [Splat guide](/developer-guide/splat-implementors-guide/) walks through each part, and the [Splat page](/splat/) has the screenshots. diff --git a/docs/website/content/blog/splat-paintball-shooter-sample.md b/docs/website/content/blog/splat-paintball-shooter-sample.md new file mode 100644 index 00000000000..cd935d10893 --- /dev/null +++ b/docs/website/content/blog/splat-paintball-shooter-sample.md @@ -0,0 +1,27 @@ +--- +title: "Splat: A First-Person Team Shooter in Java" +slug: splat-paintball-shooter-sample +url: /blog/splat-paintball-shooter-sample/ +date: '2026-11-05' +author: Shai Almog +draft: true +--- + +![Splat](/developer-guide/img/splat-home.png) + +Splat is our new full-stack sample: a first-person team shooter that plays paintball and takes itself exactly as seriously as a dad in a borrowed tactical vest. Four against four in a self-storage yard or a suburban cul-de-sac, in Team Paint, Capture the Flag or Hold the Snack Bar. Every match opens on a grim, letterboxed briefing, and every hit lands a big red **PAINTED** stamp. + +It's one Java codebase for iOS, Android, desktop and the web, plus its own server. + +## What's in it + +- **A real 3D shooter.** Skinned, animated operators from glTF, a first-person marker that bobs, sways and kicks, aim down the sights, paint that sticks to cover, and a HUD with a minimap, killfeed, hit markers and damage direction. +- **An authoritative server.** The client predicts its own player and the server, which runs the same deterministic rules, decides. Hits are lag compensated, and replays are just a seed plus inputs. +- **The rest of a live game.** Accounts, parties with chat and voice, matchmaking, ranks, leaderboards, a shop with receipts validated on the server, ads, analytics, push and an operations console for the people running it. + +![A briefing](/developer-guide/img/splat-briefing.png) +![A match](/developer-guide/img/splat-match-practice.png) + +## Make it your own + +Fields and markers are tables, the art is plain glTF and the look is one CSS file. The [Splat Implementor's Guide](/developer-guide/#splat-implementors-guide) walks through swapping each of them, and you can start a project from the Splat template in the [Initializr](https://start.codenameone.com/). diff --git a/docs/website/content/splat.md b/docs/website/content/splat.md new file mode 100644 index 00000000000..dcfa7cffbf2 --- /dev/null +++ b/docs/website/content/splat.md @@ -0,0 +1,68 @@ +--- +title: "Build a Team Paintball Shooter in Java with Splat" +description: "Splat is a first-person team paintball shooter in Java: 3D matches four against four, a server that referees every match while the phone predicts, lag-compensated shots, parties with WebRTC voice, replays and an admin console. Free MIT-0 source." +url: "/splat/" +summary: "A full-stack shooter: server-refereed 3D matches, client prediction, parties with voice, and a big red PAINTED stamp." +images: ["/developer-guide/img/splat-match-practice.png"] +ShowToc: true +--- + +Splat is a first-person team paintball shooter that takes itself exactly as seriously as a dad in a borrowed tactical vest. Every match opens with a grim mission briefing, every hit lands a big red **PAINTED** stamp, and a player who is out can try to wipe the paint off before the referee notices. Four against four, in a self-storage yard or a suburban cul-de-sac, in Team Paint, Capture the Flag or Hold the Snack Bar. + +Under the jokes it's a real-time shooter of the kind that usually needs an engine, a networking product and a separate server. The server referees every match while each phone predicts its own player, so the marker fires with no wait. A shot from a slow connection still lands where it was aimed, and bots, practice matches and replays all run the same rules. In Splat all of it is ordinary Codename One code in one project: the 3D field, its server, the voice chat and the admin console. + +{{< guide-block >}} + + + +{{< /guide-block >}} + +**[Generate your project](/initializr/)** · **[Read the guide](/developer-guide/splat-implementors-guide/)** · **[Browse the source](https://github.com/codenameone/CodenameOne/tree/master/scripts/splat)** + +In the Initializr, choose the **Splat** paintball template, set your application name and package, and generate the project. The application source is licensed under [MIT-0](https://github.com/codenameone/CodenameOne/blob/master/scripts/splat/LICENSE): you can modify it, use it commercially and keep your changes private without royalties. The players, markers and props are CC0 models from Quaternius, Kenney and Poly Haven, and the type is Rajdhani under the SIL Open Font License, so there is nothing to buy or license. + +## What comes with the application? + +| Part | Included workflows | +| --- | --- | +| The match | A first-person 3D field drawn with `com.codename1.gaming`, animated operators carrying their markers, a slow-motion instant replay from three angles, paint that stays where it lands, a virtual stick, look by drag, fire, jump, grenade and wipe buttons, and keyboard and mouse on desktop | +| Shared rules | One deterministic Java module, with no floating point, that the app predicts with, the server runs as the authority, and the replays and tests re-run | +| Netcode | Server-authoritative snapshots over WebSocket, client prediction and reconciliation, and lag compensation capped so nobody paints around corners | +| Modes | Team Paint, Capture the Flag and Hold the Snack Bar on two fields, each with a mission briefing, against bots or other players | +| Parties | Quick matches with bots filling the empty slots, parties with share codes, peer-to-peer voice chat through WebRTC, text chat, emotes and replays that follow any player | +| Social | Crews, invitations, friends and pushes when a friend gets painted | +| Monetization | Gem packs, marker side-grades and paint jobs through the platform stores with server-side receipt checks, rewarded and interstitial ads | +| Engagement | Daily quests, chests, levels, trophies and a season, with a seeded field of rivals | +| Live operations | Reports, a player query, hourly engagement pipelines, broadcasts and the home-screen spotlight | +| Administration | Live matches, automatic flags for flooded inputs and impossible turns, player lookup and audited actions, as a phone app or a desktop console | + +## What does it look like? + +![The main menu](/developer-guide/img/splat-home.png) +![The operations playlist](/developer-guide/img/splat-operations.png) +![A briefing](/developer-guide/img/splat-briefing.png) +![A match in progress](/developer-guide/img/splat-match-practice.png) +![The scoreboard](/developer-guide/img/splat-scoreboard.png) +![A party lobby with chat](/developer-guide/img/splat-party-chat.png) +![A live party match](/developer-guide/img/splat-party-match.png) +![The admin console](/developer-guide/img/splat-admin-overview.png) + +## From first run to customization + +1. **[Run the project](https://github.com/codenameone/CodenameOne/tree/master/scripts/splat#running-it).** The development profile needs nothing installed: an in-memory database, demo accounts and a simulated store, so you can play against the bots, or against a second simulator, straight away. +2. **[Tour the game](/developer-guide/splat-implementors-guide/).** The guide opens with what the game does, from the first briefing to party matches. +3. **[Make it your own](/developer-guide/splat-implementors-guide/).** The same guide then covers fields, markers, the jokes, re-skinning, store products, ads, push and moderation. + +## What does a live game still need? + +Splat supplies the game and its source. Publishing one also requires your own hosting, store products, ad units, a push key, and a TURN server for voice between players behind strict networks. + +## Looking for another kind of game? + +[Dash](/dash/) is the endless runner and [Skirmish](/skirmish/) the real-time card battler, both on the same architecture. diff --git a/docs/website/layouts/partials/cn1-flagship-samples.html b/docs/website/layouts/partials/cn1-flagship-samples.html index 016cfb62165..560b959ce5e 100644 --- a/docs/website/layouts/partials/cn1-flagship-samples.html +++ b/docs/website/layouts/partials/cn1-flagship-samples.html @@ -44,6 +44,14 @@ "images" (slice "/developer-guide/img/dash-party-chat.png" "/developer-guide/img/dash-city-run.png" "/developer-guide/img/dash-run-chase.png") "guide" "/developer-guide/dash-implementors-guide/" "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/dash") + (dict + "name" "Splat" + "kind" "Team paintball shooter" + "url" "/splat/" + "summary" "A first-person 3D shooter with a Java backend: four on four matches the server referees while the phone predicts, lag-compensated shots, parties with voice chat, replays and an admin console." + "images" (slice "/developer-guide/img/splat-match-practice.png" "/developer-guide/img/splat-briefing.png" "/developer-guide/img/splat-scoreboard.png") + "guide" "/developer-guide/splat-implementors-guide/" + "source" "https://github.com/codenameone/CodenameOne/tree/master/scripts/splat") (dict "name" "Pulse" "kind" "Gym and fitness studio" diff --git a/docs/website/static/splat/splat-mobile-poster.jpg b/docs/website/static/splat/splat-mobile-poster.jpg new file mode 100644 index 00000000000..e3ab260f80b Binary files /dev/null and b/docs/website/static/splat/splat-mobile-poster.jpg differ diff --git a/docs/website/static/splat/splat-mobile.mp4 b/docs/website/static/splat/splat-mobile.mp4 new file mode 100644 index 00000000000..5b519100349 Binary files /dev/null and b/docs/website/static/splat/splat-mobile.mp4 differ diff --git a/docs/website/static/splat/splat-promo-poster.jpg b/docs/website/static/splat/splat-promo-poster.jpg new file mode 100644 index 00000000000..883f4ec1262 Binary files /dev/null and b/docs/website/static/splat/splat-promo-poster.jpg differ diff --git a/docs/website/static/splat/splat-promo.mp4 b/docs/website/static/splat/splat-promo.mp4 new file mode 100644 index 00000000000..a97a5aece2d Binary files /dev/null and b/docs/website/static/splat/splat-promo.mp4 differ diff --git a/maven/backend/src/test/java/com/codename1/backend/ConcurrentReadWriteBlockingModeTest.java b/maven/backend/src/test/java/com/codename1/backend/ConcurrentReadWriteBlockingModeTest.java new file mode 100644 index 00000000000..59921231866 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/ConcurrentReadWriteBlockingModeTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.backend; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/// A websocket reads on its worker while another thread writes to the same +/// descriptor. On the Java SE arm both go through Deadlines, which flips the +/// channel to non-blocking for the duration; the mode has to come back as +/// blocking once both are out, or the next ServerSocket.read takes the +/// non-blocking branch, answers 0 for "nothing yet", and the session reads that +/// as end of stream -- a 1006 with no error, which neither peer asked for. +class ConcurrentReadWriteBlockingModeTest { + + @Test + void readerAndWriterTogetherLeaveTheChannelBlocking() throws Exception { + ServerSocketChannel listen = ServerSocketChannel.open(); + listen.bind(new InetSocketAddress("127.0.0.1", 0)); + final SocketChannel peer = SocketChannel.open(listen.getLocalAddress()); + final SocketChannel served = listen.accept(); + listen.close(); + final int fd = Descriptors.add(served); + ServerSocket.setTimeout(fd, 5000); + ServerSocket.setReceiveTimeout(fd, 5000); + final AtomicReference failure = new AtomicReference(); + Thread drain = new Thread(new Runnable() { + @Override + public void run() { + ByteBuffer sink = ByteBuffer.allocate(65536); + try { + while (true) { + sink.clear(); + if (peer.read(sink) < 0) { + return; + } + } + } catch (Exception ignored) { + // closed at the end of the test + } + } + }); + drain.start(); + try { + for (int round = 0; round < 2000 && failure.get() == null; round++) { + Thread writer = new Thread(new Runnable() { + @Override + public void run() { + try { + byte[] frame = new byte[1024]; + ServerSocket.write(fd, frame, 0, frame.length); + } catch (Throwable err) { + failure.compareAndSet(null, err); + } + } + }); + writer.start(); + peer.write(ByteBuffer.wrap(new byte[] {1})); + byte[] in = new byte[16]; + int n = ServerSocket.read(fd, in, 0, in.length); + writer.join(); + if (n <= 0) { + fail("read answered " + n + " on round " + round + " with the peer open"); + } + if (!served.isBlocking()) { + fail("channel left non-blocking after round " + round); + } + } + if (failure.get() != null) { + throw new AssertionError(failure.get()); + } + assertTrue(served.isBlocking()); + } finally { + ServerSocket.closeFd(fd); + peer.close(); + drain.join(2000); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/gaming/AnimatedModelTest.java b/maven/core-unittests/src/test/java/com/codename1/gaming/AnimatedModelTest.java index 91b85b4a435..890b5652bc5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/gaming/AnimatedModelTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/gaming/AnimatedModelTest.java @@ -229,6 +229,29 @@ void crossFadeBlendsFromThePreviousPose() { assertEquals("Bend", m.getClipName()); } + @Test + void layerMixesAClipIntoItsSubtreeOnly() { + AnimatedModel m = new AnimatedModel(new StubDevice(), GltfLoader.loadScene(armGltf())); + float[] prop = m.getParts()[1].getMesh().getVertices().getData(); + float c = (float) Math.cos(Math.PI / 4); + assertTrue(m.setLayer("Bend", 1f, "root_bone")); + m.update(0.5f); + assertVertex(prop, 1, c, 1 + c, 0); // the layer's own clock, at half the bend + // a subtree that does not hold the animated bone: nothing moves + assertTrue(m.setLayer("Bend", 1f, "prop")); + m.update(0f); + assertVertex(prop, 1, 1, 1, 0); + assertTrue(m.setLayer("Bend", 0.5f, null)); + m.update(0f); + float q = (float) Math.cos(Math.PI / 8); + float sn = (float) Math.sin(Math.PI / 8); + assertVertex(prop, 1, q, 1 + sn, 0); // half weight, half the angle + assertFalse(m.setLayer("Bend", 0f, null)); + m.update(0f); + assertVertex(prop, 1, 1, 1, 0); + assertFalse(m.setLayer("Missing", 1f, null)); + } + @Test void hidingANodeHidesItsSubtree() { AnimatedModel m = new AnimatedModel(new StubDevice(), GltfLoader.loadScene(armGltf())); diff --git a/maven/core-unittests/src/test/java/com/codename1/gpu/EnvironmentLightPostTest.java b/maven/core-unittests/src/test/java/com/codename1/gpu/EnvironmentLightPostTest.java new file mode 100644 index 00000000000..4fcadfd31df --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/gpu/EnvironmentLightPostTest.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.gpu; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The atmosphere, shadow and post processing settings: defaults that change + * nothing, clamping, and the shader key that selects a variant. + */ +class EnvironmentLightPostTest { + + @Test + void defaultEnvironmentIsInert() { + Environment e = new Environment(); + assertFalse(e.isFog()); + assertFalse(e.isHemisphere()); + assertFalse(e.isToneMapping()); + assertFalse(e.isSky()); + assertEquals(1f, e.getExposure()); + assertEquals("fhtk", e.getShaderKey()); + } + + @Test + void shaderKeyAndVersionFollowSettings() { + Environment e = new Environment(); + int v = e.getVersion(); + e.setFog(0xff808080, 0.05f, -1f).setHemisphere(0xff0000ff, 0xff00ff00).setToneMapping(true) + .setSky(0xff0000ff, 0xffffffff, 0xffffff00, 2f); + assertTrue(e.getVersion() > v); + assertEquals("FHTK", e.getShaderKey()); + assertEquals(0f, e.getFogHeightFalloff()); + assertEquals(1f, e.getCloudCover()); + e.setFog(0, 0, 0).clearHemisphere().clearSky(); + assertEquals("fhTk", e.getShaderKey()); + } + + @Test + void lightClampsShadowSettings() { + Light l = new Light().setShadows(true).setShadowMap(99999, -3f).setShadowStyle(4f, -1f); + assertTrue(l.isShadows()); + assertEquals(4096, l.getShadowMapSize()); + assertTrue(l.getShadowExtent() > 0f); + assertEquals(1f, l.getShadowStrength()); + assertEquals(0f, l.getShadowBias()); + l.setShadowCenter(3f, 0f, -4f); + assertEquals(3f, l.getShadowCenterX()); + assertEquals(-4f, l.getShadowCenterZ()); + } + + @Test + void shadowMatrixMapsTheCentreToTheMiddleOfTheMap() { + Light l = new Light().setDirection(-0.3f, -1f, -0.2f).setShadowMap(1024, 10f).setShadowCenter(0f, 0f, 0f); + float[] m = new float[16]; + l.computeShadowMatrix(m); + // column-major: clip x/y of the world origin are the translation column + assertEquals(0f, m[12], 0.05f); + assertEquals(0f, m[13], 0.05f); + assertTrue(m[14] > -1f && m[14] < 1f); + } + + @Test + void postProcessStartsOffAndClamps() { + PostProcess p = new PostProcess(); + assertFalse(p.isBloom()); + assertEquals(0f, p.getVignette()); + p.setBloom(3f, 0.5f).setVignette(2f).setGrain(-1f).setGrade(-1f, 0.5f, 0xff000000, 0xffffffff); + assertTrue(p.isBloom()); + assertEquals(1f, p.getBloomThreshold()); + assertEquals(1f, p.getVignette()); + assertEquals(0f, p.getGrain()); + assertEquals(0f, p.getContrast()); + } + + @Test + void capabilitiesDefaultToNoShadowsOrPost() { + GpuCapabilities c = new GpuCapabilities(2048, 8, false, false, false, "x"); + assertFalse(c.isShadowsSupported()); + assertFalse(c.isPostProcessSupported()); + assertTrue(new GpuCapabilities(2048, 8, false, false, false, "x", true, true).isPostProcessSupported()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/gpu/GlslShaderGeneratorFeaturesTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/gpu/GlslShaderGeneratorFeaturesTest.java new file mode 100644 index 00000000000..45a2823489a --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/gpu/GlslShaderGeneratorFeaturesTest.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.gpu; + +import com.codename1.gpu.Environment; +import com.codename1.gpu.Material; +import com.codename1.gpu.VertexFormat; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The GLSL the shared generator writes for the atmosphere, PBR, the sky and + * shadows: each feature appears only when asked for, so a scene that sets + * none of them gets the shaders it always had. + */ +class GlslShaderGeneratorFeaturesTest { + + private static String frag(Material m, Environment e, boolean shadowed) { + return new GlslShaderGenerator(m, VertexFormat.POSITION_NORMAL_TEXCOORD, e, shadowed).getFragmentSource(); + } + + @Test + void plainEnvironmentAddsNothing() { + Material m = new Material(Material.Type.LAMBERT); + String plain = new GlslShaderGenerator(m, VertexFormat.POSITION_NORMAL_TEXCOORD).getFragmentSource(); + assertEquals(plain, frag(m, new Environment(), false)); + assertFalse(plain.contains("u_fogColor")); + assertFalse(plain.contains("aces")); + } + + @Test + void fogHemisphereAndToneMappingAreEmitted() { + Environment e = new Environment().setFog(0xff808080, 0.05f, 0.1f).setHemisphere(0xff8090a0, 0xff403020) + .setToneMapping(true); + String f = frag(new Material(Material.Type.LAMBERT), e, false); + assertTrue(f.contains("u_fogColor")); + assertTrue(f.contains("u_sky")); + assertTrue(f.contains("aces(")); + } + + @Test + void skyOnlyForSkyboxWithSkyOn() { + Environment e = new Environment().setSky(0xff3060a0, 0xffb0c0d0, 0xfffff0d0, 0.4f); + assertTrue(frag(new Material(Material.Type.SKYBOX), e, false).contains("u_zenith")); + assertFalse(frag(new Material(Material.Type.LAMBERT), e, false).contains("u_zenith")); + assertFalse(frag(new Material(Material.Type.SKYBOX), new Environment(), false).contains("u_zenith")); + } + + @Test + void shadowSamplingOnlyWhenShadowed() { + Material m = new Material(Material.Type.PBR); + assertTrue(frag(m, new Environment(), true).contains("u_shadowMap")); + assertFalse(frag(m, new Environment(), false).contains("u_shadowMap")); + assertNotEquals(GlslShaderGenerator.variantKey(new Environment(), true), + GlslShaderGenerator.variantKey(new Environment(), false)); + } + + @Test + void postAndShadowShadersAreComplete() { + for (String s : new String[]{GlslShaderGenerator.shadowVertex(), GlslShaderGenerator.shadowFragment(), + GlslShaderGenerator.postVertex(), GlslShaderGenerator.brightFragment(), + GlslShaderGenerator.blurFragment(), GlslShaderGenerator.compositeFragment()}) { + assertTrue(s.contains("void main()"), s); + } + assertTrue(GlslShaderGenerator.compositeFragment().contains("u_bloom")); + } +} diff --git a/scripts/check-copyright-headers.sh b/scripts/check-copyright-headers.sh index 35b894e73b2..3aa294a26a4 100755 --- a/scripts/check-copyright-headers.sh +++ b/scripts/check-copyright-headers.sh @@ -38,11 +38,11 @@ is_excluded() { local candidate="$1" # What Initializr puts into a generated project is the developer's, not # Codename One's GPLv2 + Classpath Exception source: these trees are released - # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/dash/LICENSE, scripts/parley/LICENSE, scripts/pulse/LICENSE, scripts/slate/LICENSE, + # under MIT No Attribution (scripts/wayline/LICENSE, scripts/savor/LICENSE, scripts/skirmish/LICENSE, scripts/dash/LICENSE, scripts/parley/LICENSE, scripts/pulse/LICENSE, scripts/slate/LICENSE, scripts/splat/LICENSE, # scripts/bastion/LICENSE, scripts/reel/LICENSE), and carry that header # or none. case "$candidate" in - scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/dash/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|scripts/pulse/*|scripts/slate/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; + scripts/wayline/*|scripts/savor/*|scripts/skirmish/*|scripts/dash/*|scripts/parley/*|scripts/bastion/*|scripts/reel/*|scripts/pulse/*|scripts/slate/*|scripts/splat/*|maven/cn1app-archetype/src/main/resources/archetype-resources/*|scripts/initializr/common/src/main/resources/skill/*) return 0 ;; esac awk -F '\\|' -v candidate="$candidate" ' /^[[:space:]]*(#|$)/ { next } diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index c89865a8fa1..5a2782da161 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -437,3 +437,4 @@ CodenameOne/src/com/codename1/util/BigInteger.java | Bouncy Castle source (MIT); CodenameOne/src/com/codename1/io/CharArrayReader.java | Apache Harmony source retaining its original Apache-2.0 notice CodenameOne/src/com/codename1/io/JSONSanitizer.java | Google json-sanitizer source retaining its original Apache-2.0 notice vm/ByteCodeTranslator/src/cn1_crypto_kernels_amalgamation.h | libsecp256k1 (MIT, notice reproduced in the file) and public-domain ed25519-donna, generated by scripts/crypto-kernels/amalgamate.py +Ports/JavaSE/src/com/codename1/impl/javase/cef/InputStreamSchemeHandler.java | Derived from the Chromium Embedded Framework (java-cef) scheme handler sample, retaining its original BSD-style notice diff --git a/scripts/developer-guide/check-guide-api-names.py b/scripts/developer-guide/check-guide-api-names.py index d5311da1e34..ffe3d11e867 100755 --- a/scripts/developer-guide/check-guide-api-names.py +++ b/scripts/developer-guide/check-guide-api-names.py @@ -75,12 +75,16 @@ # # scripts/dash is the same for the Dash implementor's guide: its Outfit, the # runner's colours, is two letters from Output. +# +# scripts/splat is the same for the Splat implementor's guide: its Mission, +# the player-built field, is a near miss for Session. IDENTIFIER_ROOTS = [os.path.join(ROOT, d) for d in ('CodenameOne', 'Ports', 'vm', 'maven', os.path.join('docs', 'demos'), os.path.join('scripts', 'wayline'), os.path.join('scripts', 'bastion'), - os.path.join('scripts', 'dash'))] + os.path.join('scripts', 'dash'), + os.path.join('scripts', 'splat'))] # '.py' because the Python support ships real Python packages under maven/ # (starlette's TestClient is one edit from RestClient and is a real class). IDENTIFIER_SUFFIXES = ('.java', '.m', '.h', '.mm', '.kt', '.js', '.c', '.cpp', diff --git a/scripts/developer-guide/validate-guide-snippets.py b/scripts/developer-guide/validate-guide-snippets.py index a0949450506..f1bf33c5168 100755 --- a/scripts/developer-guide/validate-guide-snippets.py +++ b/scripts/developer-guide/validate-guide-snippets.py @@ -15,7 +15,7 @@ LISTING_DELIMITER_RE = re.compile(r"^-{4,}\s*$") TAG_BLOCK_RE = re.compile(r"// tag::([^\[]+)\[\]\n(.*?)\n// end::\1\[\]", re.S) JAVA_IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*\.java$") -INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/", "../../scripts/savor/", "../../scripts/skirmish/", "../../scripts/dash/", "../../scripts/parley/", "../../scripts/reel/", "../../scripts/slate/", "../../scripts/hellocodenameone/", "../../maven/cn1-app-kit/") +INCLUDE_ROOTS = ("../demos/", "../../scripts/wayline/", "../../scripts/savor/", "../../scripts/skirmish/", "../../scripts/dash/", "../../scripts/parley/", "../../scripts/reel/", "../../scripts/slate/", "../../scripts/splat/", "../../scripts/hellocodenameone/", "../../maven/cn1-app-kit/") def parse_args(): @@ -177,7 +177,7 @@ def validate_includes(includes): # compiled by its project's CSS goal, and the sample's workflow # builds the sample. relative_css_path = None - for css_dir in ("docs/demos/common/src/main/css", "scripts/skirmish/common/src/main/css", "scripts/dash/common/src/main/css"): + for css_dir in ("docs/demos/common/src/main/css", "scripts/skirmish/common/src/main/css", "scripts/dash/common/src/main/css", "scripts/splat/common/src/main/css"): try: relative_css_path = target_path.relative_to(Path(css_dir).resolve()) break diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index b3117a113c3..6db81bf0ef4 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -467,7 +467,7 @@ private Container createLanguageSelector(Template[] selectedTemplate, Runnable o if (!GeneratorModel.isTemplateOffered(Template.WAYLINE)) { return empty; } - Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.DASH, Template.PARLEY, + Template[] apps = {Template.WAYLINE, Template.SAVOR, Template.SKIRMISH, Template.DASH, Template.SPLAT, Template.PARLEY, Template.BASTION, Template.REEL, Template.PULSE, Template.SLATE}; int offered = 0; @@ -512,6 +512,7 @@ private void addTemplateChoice(Container parent, ButtonGroup group, final Templa static String templateTitle(Template template) { return template == Template.SAVOR ? "Food delivery" : template == Template.SKIRMISH ? "Card battle game" : template == Template.DASH ? "Endless runner" + : template == Template.SPLAT ? "Paintball shooter" : template == Template.PARLEY ? "Messaging" : template == Template.WAYLINE ? "Ride hailing" : template == Template.BASTION ? "Banking" : template == Template.REEL ? "Video streaming" diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 36645d06786..4f160adb1e2 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -85,6 +85,12 @@ public static boolean isMavenLayoutChoiceOffered() { /// template's app does not compile, so it is neither offered nor generated. static final String REEL_SINCE = "7.0.280"; + /// The first release whose core has the 3D engine the shooter template draws + /// its field with: PBR materials, shadow maps, fog and the post-processing + /// chain (`com.codename1.gpu`). Before it the template's app does not + /// compile, so it is neither offered nor generated. + static final String SPLAT_SINCE = "7.0.282"; + /// The first release whose framework has what the banking template's app calls: /// timed clipboard clearing, the P-256 key and signature API it signs requests /// with, and the `BigDecimal` arithmetic its ledger rounds with. Against an @@ -101,7 +107,8 @@ public static boolean isMavenLayoutChoiceOffered() { /// The first release a template can be generated against. static String templateSince(Template template) { return template == Template.BASTION ? BASTION_SINCE - : template == Template.REEL ? REEL_SINCE : JAVA_TIME_ENTITIES_SINCE; + : template == Template.REEL ? REEL_SINCE + : template == Template.SPLAT ? SPLAT_SINCE : JAVA_TIME_ENTITIES_SINCE; } /// The brand colour of the full-stack template as it ships: the `--brand` its @@ -121,6 +128,9 @@ static String templateSince(Template template) { /// The runner template's accent, used for its icon: it has no `--brand`. public static final int DASH_BRAND = 0xff4d5e; + /// The paintball template's accent, used for its icon: it has no `--brand`. + public static final int SPLAT_BRAND = 0xff7a1f; + /// The messaging template's `--brand`, held to its theme.css the same way. public static final int PARLEY_BRAND = 0x0a7d6b; @@ -153,7 +163,8 @@ public static int brandOf(Template template) { } return template == Template.SAVOR ? SAVOR_BRAND : template == Template.SKIRMISH ? SKIRMISH_BRAND - : template == Template.DASH ? DASH_BRAND : FULL_STACK_BRAND; + : template == Template.DASH ? DASH_BRAND + : template == Template.SPLAT ? SPLAT_BRAND : FULL_STACK_BRAND; } /// Whether `template` can be generated at [CN1_PLUGIN_VERSION]: the UI offers @@ -1175,7 +1186,7 @@ private void validateSharedModule(Map entries, String rootPom, S "dependency on the shared module"); requirePomFragment("backend/pom.xml", normalizedPom(entries, "backend/pom.xml"), dependency, "dependency on the shared module"); - if (template == Template.SKIRMISH || template == Template.DASH) { + if (template == Template.SKIRMISH || template == Template.DASH || template == Template.SPLAT) { // The rules pom takes its group and version from the parent, so its // coordinates are the parent's and its artifact id. requirePomFragment("rules/pom.xml", normalizedPom(entries, "rules/pom.xml"), @@ -1661,7 +1672,8 @@ private byte[] applyDataReplacements(String targetPath, byte[] sourceData) throw content = replaceTagValue(content, "cn1.plugin.version", pluginVersion); content = replaceTagValue(content, "cn1.version", pluginVersion); if (template.isFullStack() && !options.isGradle() && !isMavenBackendOnly()) { - content = addSharedModule(content, template == Template.SKIRMISH || template == Template.DASH); + content = addSharedModule(content, template == Template.SKIRMISH || template == Template.DASH + || template == Template.SPLAT); } if (mavenLayoutsEnabled() && !isMavenBackendOnly()) { content = guardModuleProfiles(content, !includesPlatformModules()); @@ -2109,6 +2121,22 @@ private String buildReadmeMarkdown() { .append("On Windows: `run-server.bat`, then `run.bat`. Your IDE's run menu has both: ") .append("\"Run Server\" and the simulator.\n\n") .append("`./mvnw -pl backend -am -Dcodename1.platform=backend test` runs the server's tests.\n\n"); + } else if (template == Template.SPLAT) { + out.append("## The game and its server\n\n") + .append("This project is a working team paintball shooter: first-person 3D matches against bots ") + .append("or other players, a server that referees every match while the phone predicts, parties ") + .append("with voice, crews, replays and an admin console, and the server they talk to.\n\n") + .append("- `rules/` is the game: a deterministic simulation with no dependencies, which the app ") + .append("predicts with, the server runs as the authority and the replay viewer re-runs.\n") + .append("- `shared/` is the contract: the REST interfaces and the objects they carry.\n") + .append("- `backend/` is the server.\n") + .append("- `common/` is the app.\n\n") + .append("Start the server, then, in a second terminal, the app:\n\n```\n") + .append("./run-server.sh\n") + .append("./run.sh\n```\n\n") + .append("On Windows: `run-server.bat`, then `run.bat`. Your IDE's run menu has both: ") + .append("\"Run Server\" and the simulator.\n\n") + .append("`./mvnw -pl backend -am -Dcodename1.platform=backend test` runs the server's tests.\n\n"); } else if (template == Template.SKIRMISH) { out.append("## The game and its server\n\n") .append("This project is a working real-time card battler: a 3D arena, matchmaking, replays, ") diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java index 978733ac53c..1d05f11985a 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/Template.java @@ -81,6 +81,16 @@ public enum Template { "The runner template is an app, a server and the modules they share; " + "an Initializr Gradle project is a single project.", new String[0], "/dash-modules.zip", "/dash-settings.properties"), + // A first-person team paintball shooter: deterministic rules shared by the + // app and the server, server-authoritative matches with client prediction, + // parties, crews and replays. It is the project in scripts/splat; its + // resources come from `scripts/sync-initializr-template.py splat`. Like DASH, + // its look is painted art, so it has no colour scheme to choose. + SPLAT(false, "/splat-src.zip", "/splat-css.zip", "/splat-pom.xml", + "com.codenameone.examples.splat", "Splat", "/splat-preview.jpg", + "The paintball template is an app, a server and the modules they share; " + + "an Initializr Gradle project is a single project.", + new String[0], "/splat-modules.zip", "/splat-settings.properties"), // The same kind of template as WAYLINE: an end-to-end encrypted messaging app // (chats, groups, media, calls) and its server. It is the project in // scripts/parley, and its resources come from @@ -179,7 +189,7 @@ public boolean isFullStack() { /// Whether the developer can choose a colour scheme for the template: false for /// one whose look is art and not a palette its stylesheet declares. public boolean hasColorScheme() { - return this != SKIRMISH && this != DASH; + return this != SKIRMISH && this != DASH && this != SPLAT; } public boolean supportsGradle() { diff --git a/scripts/initializr/common/src/main/resources/skill/references/demos.md b/scripts/initializr/common/src/main/resources/skill/references/demos.md index 0ff3fea2bac..da75f062298 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/demos.md +++ b/scripts/initializr/common/src/main/resources/skill/references/demos.md @@ -46,6 +46,7 @@ README of each lists the demo accounts. | `scripts/pulse` | Gym and studio: member, trainer, front desk, owner | Health and workouts (`com.codename1.health`), a watch companion (`wearable`), rotating QR and NFC door passes (`codescan`, `nfc`), class booking with waitlists and device calendar, households and kids' bookings, an owner dashboard with charts and a desktop master-detail layout, server-side billing with dunning and engagement nudges over push | | `scripts/slate` | Generic appointment booking for salons, tutors, trainers and consultants: client, provider, front desk, owner | Multi-party availability, slot rules (buffers, gaps, travel time), day, week and agenda views, device and Google, Microsoft and ICS calendar sync, simulated deposits | | `scripts/skirmish` | Real-time 3D card battler | Games: `GameView`/3D rendering (`battle/`), a deterministic `rules` module shared by app, server and tests, replays, in-app purchases and rewarded ads (`engage/`), matchmaking over sockets, in-app editors (`forge/`) | +| `scripts/splat` | Team paintball first-person shooter: player, admin console | Games: 3D first-person rendering with `GameView` (`play/`), a deterministic `rules` module the server runs as the authority while the app predicts and reconciles (`play/OnlineDriver`), lag-compensated hits, bots inside the rules, parties with voice, crews and replays over sockets (`net/`, `game/`), in-app purchases and rewarded ads (`engage/`) | | `scripts/dash` | Endless runner with a 2D-to-3D camera swing: player, track editor, admin console | Games: a deterministic `rules` module shared by app, server and tests, with the server re-simulating every submitted run to verify leaderboard scores (`game/`, `progress/`), live races with voice over sockets (`net/`), crews, friends and level packs (`social/`), a track editor (`forge/`), in-app purchases and rewarded ads (`engage/`, `shop/`) | ## Patterns the app demos share (copy these) diff --git a/scripts/initializr/common/src/main/resources/splat-css.zip b/scripts/initializr/common/src/main/resources/splat-css.zip new file mode 100644 index 00000000000..e78e27d8b53 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/splat-css.zip differ diff --git a/scripts/initializr/common/src/main/resources/splat-modules.zip b/scripts/initializr/common/src/main/resources/splat-modules.zip new file mode 100644 index 00000000000..4abea4ea712 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/splat-modules.zip differ diff --git a/scripts/initializr/common/src/main/resources/splat-pom.xml b/scripts/initializr/common/src/main/resources/splat-pom.xml new file mode 100644 index 00000000000..8805f54a6ca --- /dev/null +++ b/scripts/initializr/common/src/main/resources/splat-pom.xml @@ -0,0 +1,426 @@ + + + 4.0.0 + + com.example.myapp + myappname + 1.0-SNAPSHOT + + com.example.myapp + myappname-common + 1.0-SNAPSHOT + jar + + + + + + ${project.groupId} + myappname-shared + ${project.version} + + + + ${project.groupId} + myappname-rules + ${project.version} + + + + com.codenameone + cn1-app-kit-lib + ${cn1.version} + pom + + + com.codenameone + codenameone-core + + + com.codenameone + codenameone-javase + test + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + -no-reflect + -no-jdk + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + simulator + + javase + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + xalan + serializer + 2.7.3 + + + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/splat-settings.properties b/scripts/initializr/common/src/main/resources/splat-settings.properties new file mode 100644 index 00000000000..d4afdb6dcbd --- /dev/null +++ b/scripts/initializr/common/src/main/resources/splat-settings.properties @@ -0,0 +1,21 @@ +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the game talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +# Push notifications: challenges, chest reminders, and flags for an admin. +codename1.arg.ios.includePush=true +# myappname://tour starts the self-driving walkthrough (see Tour.java). +codename1.arg.ios.urlScheme=myappname +codename1.arg.android.xintent_filter= +# Android push is off until the game has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The shop sells through Google Play, and an Android build that uses in-app +# purchase needs the app's licensing key. This is a placeholder that builds +# but verifies nothing: paste the Base64 RSA public key from the Play +# Console's "Monetization setup" before release. +codename1.arg.android.licenseKey=CN1TESTPLACEHOLDERKEYNOTFORPRODUCTIONxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIDAQAB +# Runs are played in portrait. +codename1.arg.android.screenOrientation=portrait +codename1.arg.ios.interface_orientation=UIInterfaceOrientationPortrait diff --git a/scripts/initializr/common/src/main/resources/splat-src.zip b/scripts/initializr/common/src/main/resources/splat-src.zip new file mode 100644 index 00000000000..f574c5d64e1 Binary files /dev/null and b/scripts/initializr/common/src/main/resources/splat-src.zip differ diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index 03d9c6a11b0..1c5e8935e95 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -130,6 +130,7 @@ public boolean runTest() throws Exception { validateMavenRefusals(); validateFullStackTemplate(); validateReelGate(); + validateSplatGate(); validateCommandLineProject(); for (Template template : Template.values()) { for (ProjectOptions.ProjectType type : ProjectOptions.ProjectType.values()) { @@ -410,8 +411,9 @@ private void assertFullStackProject(Template template, Map entri String packageName, String mainClassName, String label) throws IOException { boolean rides = template == Template.WAYLINE; boolean bank = template == Template.BASTION; - boolean game = template == Template.SKIRMISH || template == Template.DASH; + boolean game = template == Template.SKIRMISH || template == Template.DASH || template == Template.SPLAT; boolean runner = template == Template.DASH; + boolean paintball = template == Template.SPLAT; boolean video = template == Template.REEL; boolean studio = template == Template.PULSE; boolean booking = template == Template.SLATE; @@ -429,7 +431,7 @@ private void assertFullStackProject(Template template, Map entri // project once got the ride-hailing accounts and chapters. String readme = getText(entries, "README.md"); boolean chat = template == Template.PARLEY; - assertContains(readme, game ? (runner ? "endless runner" : "card battler") : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : studio ? "fitness studio app" : booking ? "appointment booking app" : video ? "video streaming service" : "food-delivery app", + assertContains(readme, game ? (runner ? "endless runner" : paintball ? "paintball shooter" : "card battler") : rides ? "ride-hailing app" : chat ? "messaging app" : bank ? "working bank" : studio ? "fitness studio app" : booking ? "appointment booking app" : video ? "video streaming service" : "food-delivery app", label + "README names the app"); if (!game) { assertContains(readme, (rides ? "`rider@" : chat ? "`alice@" : bank ? "`customer2@" : studio ? "`member@" : booking ? "`client@" : video ? "`viewer@" : "`customer@") + artifact + ".example`", @@ -641,6 +643,18 @@ private void validateReelGate() throws Exception { GeneratorModel.REEL_SINCE)), Boolean.valueOf(GeneratorModel.isTemplateOffered(Template.REEL)), "The page offers Reel from its gate on"); } + /// The shooter template needs the 3D engine of a newer core: below its own + /// gate it is refused, and the page does not list it. + private void validateSplatGate() throws Exception { + assertFalse(GeneratorModel.isVersionAtLeast(FULL_STACK_VERSION, GeneratorModel.SPLAT_SINCE), + "Splat's gate is past the other complete apps'"); + assertRefused(GeneratorModel.createForPluginVersion(IDE.INTELLIJ, Template.SPLAT, "OldSplat", + "com.acme.oldsplat", ProjectOptions.defaults().forFullStack(), FULL_STACK_VERSION), + GeneratorModel.SPLAT_SINCE, "Splat is not generated before its gate"); + assertEqual(Boolean.valueOf(GeneratorModel.isVersionAtLeast(GeneratorModel.cn1PluginVersion(), + GeneratorModel.SPLAT_SINCE)), Boolean.valueOf(GeneratorModel.isTemplateOffered(Template.SPLAT)), "The page offers Splat from its gate on"); + } + private static ProjectOptions mavenOptions(ProjectOptions.ProjectType type, boolean allModules) { return ProjectOptions.defaults().withBuild(ProjectOptions.BuildTool.MAVEN, type).withPlatformModules(allModules); } diff --git a/scripts/initializr/e2e-shipped.sh b/scripts/initializr/e2e-shipped.sh index c1955caba47..d52d2799d23 100755 --- a/scripts/initializr/e2e-shipped.sh +++ b/scripts/initializr/e2e-shipped.sh @@ -12,7 +12,7 @@ # # e2e-shipped.sh [--require-latest] [--no-ui] [TEMPLATE ...] # -# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, DASH, PARLEY, BASTION, REEL, PULSE or SLATE; all of them +# TEMPLATE is BAREBONES, KOTLIN, WAYLINE, SAVOR, SKIRMISH, DASH, SPLAT, PARLEY, BASTION, REEL, PULSE or SLATE; all of them # by default. Every other check of the templates builds them against this # checkout's 8.0-SNAPSHOT, which no developer has: the generated pom names the # release the Initializr pins, and that is the only thing this script builds @@ -63,7 +63,7 @@ for arg in "$@"; do *) TEMPLATES+=("$arg") ;; esac done -[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH DASH PARLEY BASTION REEL PULSE SLATE) +[ ${#TEMPLATES[@]} -gt 0 ] || TEMPLATES=(BAREBONES KOTLIN WAYLINE SAVOR SKIRMISH DASH SPLAT PARLEY BASTION REEL PULSE SLATE) log() { echo "[e2e-shipped] $*" >&2; } fail() { echo "[e2e-shipped] FAIL: $*" >&2; exit 1; } @@ -78,14 +78,16 @@ since() { FULL_STACK_SINCE="$(since FULL_STACK_SINCE)" BASTION_SINCE="$(since BASTION_SINCE)" REEL_SINCE="$(since REEL_SINCE)" -[ -n "$FULL_STACK_SINCE" ] && [ -n "$BASTION_SINCE" ] && [ -n "$REEL_SINCE" ] || fail "could not read the template floors from $GENERATOR" +SPLAT_SINCE="$(since SPLAT_SINCE)" +[ -n "$FULL_STACK_SINCE" ] && [ -n "$BASTION_SINCE" ] && [ -n "$REEL_SINCE" ] && [ -n "$SPLAT_SINCE" ] || fail "could not read the template floors from $GENERATOR" # The oldest release a template's generated code compiles against: Bastion # calls framework API that first shipped in BASTION_SINCE, Reel the protected -# playback API of REEL_SINCE. +# playback API of REEL_SINCE, Splat the 3D engine of SPLAT_SINCE. template_since() { case "$1" in BASTION) echo "$BASTION_SINCE" ;; REEL) echo "$REEL_SINCE" ;; + SPLAT) echo "$SPLAT_SINCE" ;; *) echo "$FULL_STACK_SINCE" ;; esac } diff --git a/scripts/splat/ART.md b/scripts/splat/ART.md new file mode 100644 index 00000000000..1f2fa8d2bc7 --- /dev/null +++ b/scripts/splat/ART.md @@ -0,0 +1,62 @@ +# Splat art and type + +Everything the game draws in 3D is a CC0 model, written into +`common/src/main/resources/model_*.glb` by `tools/make-operators.py` (the +operators) and `tools/pack-art.py` (everything else). Nothing is +built from primitives at run time except the paintballs, the paint they +leave behind and the sphere the sky is painted on (the sky itself is the +engine's sky shader, set with `Environment.setSky`). + +| What | Pack | Author | Licence | +|---|---|---|---| +| Operator bodies, skins, eyes, brows, hair and clothes (`model_op-*.glb`) | MakeHuman system assets and community asset packs (only assets marked CC0) | MakeHuman community (makehumancommunity.org) | CC0 1.0 | +| Operator animation (`model_op-*.glb`) | Universal Animation Library and Universal Animation Library 2, Standard | Quaternius (quaternius.com) | CC0 1.0 | +| Paintball goggles | modelled by `tools/make-operators.py` | this project | as the sample | +| Cover and yard props | Toon Shooter Game Kit | Quaternius (quaternius.com) | CC0 1.0 | +| Markers (`model_mk-semi`, `-pump`, `-ramp`) | Toon Shooter Game Kit (SMG, Shotgun, AK) | Quaternius (quaternius.com) | CC0 1.0 | +| Scope, hopper and grenade (`model_mk-scope`, `-hopper`, `-grenade`) | Blaster Kit 2.1 | Kenney (kenney.nl) | CC0 1.0 | +| Small props (crates, barrels, tents) | Survival Kit | Kenney (kenney.nl) | CC0 1.0 | +| Trees, bushes, grass and rocks | Ultimate Nature | Quaternius (quaternius.com) | CC0 1.0 | +| Photo-scanned props: covered car, concrete barriers, sofa, barrel, street lamp, cardboard boxes | covered_car, concrete_road_barrier(_02), sofa_02, barrel_03, street_lamp_01, cardboard_box_01 | Poly Haven (polyhaven.com) | CC0 1.0 | +| Ground surfaces (`tex_*.jpg`) | asphalt_02, concrete_floor_02, grass_ground, gravel_road, corrugated_iron | Poly Haven (polyhaven.com) | CC0 1.0 | + +CC0 asks for no credit. We give it anyway. + +The type is **Rajdhani** (Bold and Medium) by the Indian Type Foundry, +under the SIL Open Font License 1.1; the licence is beside the font files +in `common/src/main/css/fonts/OFL.txt`. + +## Re-skinning + +### Operators + +The operators are built in Blender (5.x) with the MPFB add-on, which makes +MakeHuman characters: + +1. `blender --online-mode -c extension install --enable mpfb`, then unzip the + MakeHuman asset packs you want into MPFB's `data` directory. Use only + assets whose `.mhclo`/`.mhmat` says `license CC0`; several community + assets are CC-BY. +2. Download Quaternius' Universal Animation Library 1 and 2 (Standard) into + one directory. +3. Edit `OPERATORS` (body, skin, hair, clothes) and `CLIPS` at the top of + `tools/make-operators.py`, and run + `blender -b --python tools/make-operators.py -- [name ...]`. + +The script retargets each clip from the library's skeleton onto the +MakeHuman game rig (they share Unreal mannequin bone names), applies the +clothing masks, models the goggles, moves the head into its own `_Head` +mesh (hidden in first person) and writes the glTF. If you change the rig, +re-measure the constants in `play/Grip.java` from the `Aim` clip. + +### Everything else + +1. Unzip the packs you want into one directory. +2. Edit the tables at the top of `tools/pack-art.py` (`MARKERS`, `PROPS`, + `NATURE`) to point at the files you want. +3. Run `tools/pack-art.py `. + +An operator can be any skinned glTF with the clip names `play/Models.java` +lists and a `hand_r` bone for the marker. The nature models come as OBJ with a material library; +the packer turns each material into a glTF primitive with its own colour. A prop's collision box lives in the rules +(`rules/.../Catalog.java`); the view scales the model to fit it. diff --git a/scripts/splat/LICENSE b/scripts/splat/LICENSE new file mode 100644 index 00000000000..af5d69aaeb3 --- /dev/null +++ b/scripts/splat/LICENSE @@ -0,0 +1,22 @@ +MIT No Attribution + +Copyright (c) 2026, Codename One and/or its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This applies to the Skirmish sample and to everything Codename One Initializr +writes into a generated project: the application templates under +maven/cn1app-archetype/src/main/resources/archetype-resources, the agent skill +under scripts/initializr/common/src/main/resources/skill, and the Initializr +template archives built from them. diff --git a/scripts/splat/README.adoc b/scripts/splat/README.adoc new file mode 100644 index 00000000000..5b165efa2cb --- /dev/null +++ b/scripts/splat/README.adoc @@ -0,0 +1,113 @@ += Splat + +A first-person team paintball shooter and its server, in one Maven project and one +language. Four against four in a self-storage yard or a suburban cul-de-sac, in Team +Paint, Capture the Flag or Hold the Snack Bar, with a grim mission briefing before +every match, a big red PAINTED stamp for every hit, and a referee who catches most +of the players wiping paint off. Around the match is what a live game needs: +accounts, quick matches with bots filling the empty slots, parties with voice chat, +replays, crews, a shop with in-app purchases and rewarded ads, push notifications, +and a console for running the game. + +The rules are one module, `rules`, that the app, the server and the tests all +compile. It uses no floating point and no clock, so a match plays out identically +on a phone, on the server and in a replay. The server runs every online match as +the authority and sends snapshots; the app predicts its own player with the same +rules and reconciles when a snapshot arrives, so the marker fires with no wait. + +It's a sample of what Codename One does for games, and the project the +Initializr generates when you start from the paintball template. + +== Start here + +One chapter of the developer guide covers this project: + +* https://www.codenameone.com/developer-guide/#splat-implementors-guide[The Splat chapter] + (link:../../docs/developer-guide/Splat-Implementors-Guide.asciidoc[source]) + tours what the game does, with the demo accounts, then explains how it's + built and what to change to make it yours: fields, markers, the jokes, + the look, store products, ads, push and moderation. + +== The modules + +[cols="1,3"] +|=== +| Module | What it holds + +| `rules` | The deterministic simulation: fields, markers, paintballs, the three + modes, lag compensation, the bots and replays. +| `shared` | The data transfer objects and `@RestClient` interfaces. +| `common` | The app: the 3D field and HUD, prediction, the menus, the party lobby + and voice, the replay viewer, the store, the admin console. +| `backend` | The server: accounts, matchmaking, the authoritative match rooms, + results and replays, moderation, progression, receipts, push. +| `ios` | The iOS build, for generating the Xcode project. +|=== + +== Running it + +Start the server on the development profile. It needs nothing installed: the +database is in memory, purchases and push notifications are simulated, and the +demo accounts `player@`, `rival@`, `trouble@` and `admin@splat.example` exist, +each with the password `splat-demo`. + +[source,bash] +---- +CN1_PROFILE=dev backend/server.sh run +---- + +Then run the app in the simulator: + +[source,bash] +---- +mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase +---- + +The app looks for the server at `http://localhost:8080/`; *Settings* changes it. +`backend/server.sh run --port N` serves on another port. + +For the native iOS app, generate the Xcode project on a Mac, open it and run it +from Xcode: + +[source,bash] +---- +mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false +---- + +== Tests + +[source,bash] +---- +mvn -pl rules test # <1> +mvn -pl rules,shared,backend -Dcodename1.platform=backend test # <2> +./run-e2e.sh # <3> +./check-template.sh # <4> +---- +<1> Determinism: the same seed and inputs give the same match, and the golden + replays still end the way they were recorded. +<2> The server's tests: whole matches over its rooms and sockets, replays of + their results, input floods and aimbot flags, receipts, progression and + moderation. +<3> Starts the server on the `test` profile and runs the app's tests against it + in the simulator. The screenshots the developer guide shows land in + `target/e2e-shots`. +<4> Generates a project from the Initializr template, under another name and + package, and builds it. + +`.github/workflows/splat.yml` runs them, and builds the app for Android and +iOS. + +== The Initializr template + +`scripts/sync-initializr-template.py splat --write` copies this project into the +Initializr's resources; without `--write` it fails when they differ, which is what +CI runs. Change the project here, never the copies. + +== Licence + +Splat is released under MIT No Attribution (see `LICENSE`), not under the +GPLv2 with Classpath Exception that covers Codename One itself. Take any of it +into your own application, under any licence, with no notice to keep. + +The game ships no art files. The fields, the players, the markers and the +portraits are all drawn in code, so there is nothing else to license. diff --git a/scripts/splat/backend/application-dev.properties b/scripts/splat/backend/application-dev.properties new file mode 100644 index 00000000000..ab1a283b4f7 --- /dev/null +++ b/scripts/splat/backend/application-dev.properties @@ -0,0 +1,3 @@ +cn1.datasource.url=:memory: +splat.iap.provider=simulated +splat.push.provider=console diff --git a/scripts/splat/backend/application-test.properties b/scripts/splat/backend/application-test.properties new file mode 100644 index 00000000000..cbea9fa42ec --- /dev/null +++ b/scripts/splat/backend/application-test.properties @@ -0,0 +1,5 @@ +cn1.datasource.url=:memory: +splat.signin.per-minute=1000 +splat.iap.provider=simulated +splat.push.provider=recording +splat.race.bot-after-ms=0 diff --git a/scripts/splat/backend/application.properties b/scripts/splat/backend/application.properties new file mode 100644 index 00000000000..b9e4c12b9c9 --- /dev/null +++ b/scripts/splat/backend/application.properties @@ -0,0 +1,3 @@ +cn1.datasource.url=${DATABASE_URL} +cn1.server.port=8080 +cn1.security.schema.enabled=true diff --git a/scripts/splat/backend/pom.xml b/scripts/splat/backend/pom.xml new file mode 100644 index 00000000000..283c59c967f --- /dev/null +++ b/scripts/splat/backend/pom.xml @@ -0,0 +1,175 @@ + + + 4.0.0 + + com.codenameone.examples.splat + splat + 1.0-SNAPSHOT + + com.codenameone.examples.splat + splat-backend + 1.0-SNAPSHOT + jar + + splat-backend + + + + + com.codenameone + codenameone-backend + ${cn1.version} + + + + com.codenameone + cn1-app-kit-backend + ${cn1.version} + + + + ${project.groupId} + splat-shared + ${project.version} + + + + ${project.groupId} + splat-rules + ${project.version} + + + + org.xerial + sqlite-jdbc + 3.46.1.0 + runtime + + + + com.codenameone + codenameone-backend-test + ${cn1.version} + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + cn1-generate-annotation-stubs + generate-sources + + + generate-annotation-stubs + + + + cn1-process-annotations + process-classes + + + process-annotations + + + + cn1-process-test-annotations + process-test-classes + + + process-test-annotations + + + + cn1-compiled-tests + test + + + backend-test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + false + + + + + diff --git a/scripts/splat/backend/server.sh b/scripts/splat/backend/server.sh new file mode 100755 index 00000000000..7dfce0c4f14 --- /dev/null +++ b/scripts/splat/backend/server.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Builds and runs the Splat server. +# +# server.sh build [--jvm|--native] [--web] +# server.sh run [--jvm|--native] [--web] [--prebuilt] [--port N] +# server.sh web +# +# --jvm (the default) runs the server on this JDK, which is how you work on it. +# --native compiles it to a native binary, which is how it ships. +# +# The profile comes from CN1_PROFILE. `test` and `dev` need nothing else: an +# in-memory database, four demo accounts, simulated in-app purchases and +# push notifications that are printed, not sent. Anything else is a +# deployment and reads its settings from the environment -- see +# application.properties. +# +# `web` builds the app for the browser and stages it in target/webapp, which +# takes about a minute; `--web` does that as part of a build or a run. A server +# that finds the app there serves it at /, so http://localhost:8080/ is Splat +# with nothing installed. It stays staged until the next `mvn clean`: build it +# once, and again when the app changed. +# +# The server prints "listening on http port N" when it accepts connections. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP="$(cd "$HERE/.." && pwd)" +# The project's own Maven wrapper where it has one. +if [ -z "${MVN:-}" ]; then + if [ -x "$APP/mvnw" ]; then MVN="$APP/mvnw"; else MVN="mvn"; fi +fi + +log() { echo "[splat-server] $*" >&2; } + +build_jvm() { + log "building the server for the JVM" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl rules,shared,backend \ + -Dmaven.test.skip=true package dependency:copy-dependencies \ + -DincludeScope=runtime -DoutputDirectory="$APP/backend/target/server-lib" >&2 +} + +build_native() { + log "translating the server to a native binary" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl rules,shared,backend \ + -Dmaven.test.skip=true package cn1:backend-package \ + -Dcn1.backend.output="$APP/backend/target/splat-server" >&2 +} + +build_web() { + log "building the app for the browser" + "$MVN" -B -q -f "$APP/pom.xml" -Dcodename1.platform=backend -pl backend \ + cn1:backend-webapp >&2 +} + +# The server looks for the app in `webapp` beside its settings; here it is build +# output, so it is under target and the server is told. An address set by the +# caller is left alone. +serve_web() { + if [ -z "${CN1_WEBAPP_ROOT:-}" ] && [ -f "$APP/backend/target/webapp/index.html" ]; then + export CN1_WEBAPP_ROOT="target/webapp" + log "serving the web app in backend/target/webapp" + fi +} + +command="${1:-run}" +shift || true +mode="jvm" +port="${PORT:-8080}" +prebuilt=0 +web=0 +while [ $# -gt 0 ]; do + case "$1" in + --jvm) mode="jvm" ;; + --native) mode="native" ;; + --prebuilt) prebuilt=1 ;; + --web) web=1 ;; + --port) port="$2"; shift ;; + *) log "unknown argument $1"; exit 2 ;; + esac + shift +done + +case "$command" in + build) + if [ "$mode" = "native" ]; then build_native; else build_jvm; fi + [ "$web" = 0 ] || build_web + ;; + web) + build_web + ;; + run) + # application.properties is read from the working directory. + cd "$APP/backend" + if [ "$mode" = "native" ]; then + [ "$prebuilt" = 1 ] || build_native + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + PORT="$port" exec "$APP/backend/target/splat-server" + fi + [ "$prebuilt" = 1 ] || build_jvm + [ "$web" = 0 ] || [ "$prebuilt" = 1 ] || build_web + serve_web + main="$(tr -d '\r\n' < "$APP/backend/target/classes/META-INF/cn1-backend-main")" + log "running $main on port $port" + PORT="$port" exec java -cp "$APP/backend/target/classes:$APP/backend/target/server-lib/*" "$main" + ;; + *) + log "unknown command $command (build, run, web)" + exit 2 + ;; +esac diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Clock.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Clock.java new file mode 100644 index 00000000000..772780f9990 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Clock.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +/// Wall-clock time, in one place so day boundaries are computed the same way +/// everywhere (UTC days). +public final class Clock { + public static final long DAY = 24L * 60 * 60 * 1000; + + private Clock() { + } + + public static long now() { + return System.currentTimeMillis(); + } + + /// Midnight UTC at the start of the day `millis` falls in. + public static long dayOf(long millis) { + return millis - millis % DAY; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Ids.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Ids.java new file mode 100644 index 00000000000..70f927ec479 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Ids.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.Crypto; + +import java.io.IOException; + +/// Identifiers and secrets made of random bytes. +public final class Ids { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private Ids() { + } + + /// A new identifier: 32 hex digits from 16 random bytes. Random and not a + /// counter, so the id of one delivery says nothing about the id of another, and + /// so no table needs a generated key, which every database spells its own way. + public static String next() throws IOException { + return hex(Crypto.randomBytes(16)); + } + + public static String hex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int iter = 0; iter < bytes.length; iter++) { + out[iter * 2] = HEX[(bytes[iter] >> 4) & 0xf]; + out[iter * 2 + 1] = HEX[bytes[iter] & 0xf]; + } + return new String(out); + } + + /// The SHA-256 of `text`, in hex. What is stored in place of a secret that + /// only ever needs to be compared. + public static String sha256(String text) throws IOException { + return hex(Crypto.sha256(text.getBytes("UTF-8"))); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Roles.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Roles.java new file mode 100644 index 00000000000..cfc1f710842 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Roles.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +/// The two roles. Every account is a PLAYER; an ADMIN also gets god mode. +public final class Roles { + public static final String PLAYER = "PLAYER"; + public static final String ADMIN = "ADMIN"; + + private Roles() { + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/SecurityConfig.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/SecurityConfig.java new file mode 100644 index 00000000000..5539561bd10 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/SecurityConfig.java @@ -0,0 +1,388 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.Config; +import com.codename1.backend.DataSource; +import com.codename1.backend.HttpServer; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Order; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.security.AntPathRequestMatcher; +import com.codename1.backend.security.DisabledException; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.HttpSecurity; +import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler; +import com.codename1.backend.security.LogoutFilter; +import com.codename1.backend.security.SecurityExchange; +import com.codename1.backend.security.SecurityFilter; +import com.codename1.backend.security.SecurityFilterChain; +import com.codename1.backend.security.SessionCreationPolicy; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.JwkSource; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codename1.backend.security.crypto.PasswordEncoderFactories; +import com.codename1.backend.security.oauth2.core.AuthorizationGrantType; +import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod; +import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder; +import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator; +import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator; +import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator; +import com.codename1.backend.security.oauth2.jwt.JwtValidators; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys; +import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings; +import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient; +import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository; +import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter; +import com.codename1.backend.security.ratelimit.InMemoryRateLimiter; +import com.codename1.backend.security.ratelimit.RateLimitKeys; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; + +/// Who may call what. +/// +/// This server is two things at once, and has a filter chain for each: +/// +/// - **An authorization server.** The app signs a user in here -- e-mail and +/// password, then the authorization-code flow with PKCE -- and gets a +/// short-lived access token and a refresh token. Nothing but this server ever +/// sees the password. +/// - **An API.** Everything under `/api` takes that access token as a bearer +/// token, keeps no session, and decides from the account's roles what the +/// caller may do. +/// +/// The roles are PLAYER and ADMIN ([Roles]). `/api/admin` is god mode and +/// for admins only; the rest is for anyone signed in and acts on the caller's +/// own account, runner and wallet. +@Configuration +public class SecurityConfig { + /// The id the app signs in as. A public client: it holds no secret, since + /// anything shipped inside an app can be read out of it, and proves itself + /// with PKCE instead. + public static final String CLIENT_ID = "splat-app"; + /// Where the authorization code is sent back to. The app makes the requests + /// of the flow itself and reads the code out of the redirect, so this is + /// never opened; it is the loopback address because that is the one address + /// nobody else can register a listener on. An app that signs in through the + /// system browser adds its own address scheme here with `redirectUri`. + public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback"; + /// Where the code is sent back to when the app runs in a browser: a path on + /// this server, which answers with the code it was given. See + /// [#withWebRedirect]. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + /// Whom the access tokens are for. The API accepts no token issued for + /// anything else. + public static final String AUDIENCE = "urn:splat:api"; + public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer"; + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + /// The accounts, in the tables `cn1.security.schema.enabled` creates. + @Bean + public JdbcUserDetailsManager users(DataSource db) { + return new JdbcUserDetailsManager(db); + } + + @Bean + public RegisteredClientRepository clients(DataSource db, + @Value("${" + ISSUER_KEY + ":}") final String issuer) throws IOException { + final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db); + if (clients.findByClientId(CLIENT_ID) == null) { + clients.save(RegisteredClient.withId("splat").clientId(CLIENT_ID) + .clientName("Splat") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .redirectUri(LOOPBACK_REDIRECT) + .scope("openid").scope("profile") + .build()); + } + // The app in a browser is the same client with one more address: this + // server's own. It is worked out and not stored, because on a + // development profile the server has no one address to store -- and a + // row written by an earlier version would not have it either. + return new RegisteredClientRepository() { + @Override + public void save(RegisteredClient client) { + clients.save(client); + } + + @Override + public RegisteredClient findByClientId(String clientId) { + return withWebRedirect(clients.findByClientId(clientId), issuer); + } + + @Override + public RegisteredClient findById(String id) { + return withWebRedirect(clients.findById(id), issuer); + } + }; + } + + /// The app's registration with [#WEB_REDIRECT_PATH] on this server added + /// to the addresses a code may be sent back to. + /// + /// A browser will not show a page the answer to a request that was + /// redirected to another origin, so the app running in one cannot read its + /// code off a redirect to the loopback address the way the installed app + /// does. It asks for the code to be sent to this server instead, which + /// hands it straight back ([SignInCodePage]). + /// + /// "This server" is the configured issuer where there is one, and is then + /// the only web address accepted. With none -- a development profile -- it + /// is the address the request arrived at, the same rule + /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back + /// to the server the browser was already talking to. + static RegisteredClient withWebRedirect(RegisteredClient client, String issuer) { + if (client == null || !CLIENT_ID.equals(client.getClientId())) { + return client; + } + String here = here(issuer); + if (here == null) { + return client; + } + RegisteredClient.Builder copy = RegisteredClient.withId(client.getId()) + .clientId(client.getClientId()) + .clientName(client.getClientName()) + .clientSettings(client.getClientSettings()) + .tokenSettings(client.getTokenSettings()) + .redirectUri(here + WEB_REDIRECT_PATH); + for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) { + copy.clientAuthenticationMethod(method); + } + for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) { + copy.authorizationGrantType(grant); + } + for (String uri : client.getRedirectUris()) { + copy.redirectUri(uri); + } + for (String scope : client.getScopes()) { + copy.scope(scope); + } + for (String resource : client.getResources()) { + copy.resource(resource); + } + return copy.build(); + } + + /// Refuses a request that changes something and was sent by a page of + /// another site. + /// + /// A browser names the site a request came from in `Origin` on every + /// request that is not a plain read, and a page cannot change or drop it. + /// The installed app is not a browser and sends none, so a request without + /// one is let through: there is no cookie jar of somebody else's for it to + /// be riding on. + static SecurityFilter sameOrigin(final String issuer) { + return (request, chain) -> { + String method = request.getMethod(); + if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) { + return chain.doFilter(request); + } + String origin = request.getHeader("Origin"); + if (origin == null || origin.length() == 0) { + return chain.doFilter(request); + } + String here = here(issuer); + if (here != null && sameOrigin(origin, here)) { + return chain.doFilter(request); + } + return new HttpServer.Response(403, "application/json", + "{\"error\":\"cross_origin\"}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"); + }; + } + + /// This server's address: the configured issuer, and with none -- a + /// development profile -- the address the request in hand arrived at. Null + /// outside a request that says. + static String here(String issuer) { + if (issuer != null && issuer.length() > 0) { + return issuer; + } + SecurityExchange exchange = SecurityExchange.current(); + String host = exchange == null ? null : exchange.getRequest().getHeader("Host"); + if (host == null || host.length() == 0) { + return null; + } + return (exchange.isSecure() ? "https://" : "http://") + host; + } + + /// Whether `origin` is the origin of `address`: the same scheme, host and + /// port, whatever path the address goes on to name. Scheme and host are + /// compared without regard to case, and without folding it -- that would + /// depend on the language the server runs in. + static boolean sameOrigin(String origin, String address) { + int path = address.indexOf('/', address.indexOf("://") + 3); + String root = path < 0 ? address : address.substring(0, path); + return origin.equalsIgnoreCase(root); + } + + /// Kept in the database, so a refresh token outlives a restart and is good + /// on every instance of the server. + @Bean + public OAuth2AuthorizationService authorizations(DataSource db) { + return new JdbcOAuth2AuthorizationService(db); + } + + /// The keys the tokens are signed with: the files named by + /// `cn1.security.authorizationserver.jwk.keys`. On a development profile + /// with none configured, a key made at start-up -- which is why signing in + /// again is needed after restarting a development server. + @Bean + public JwkSource signingKeys(Config config) throws IOException { + return AuthorizationServerKeys.load(config); + } + + /// Signing in. A session exists here and only here, for the few requests + /// between the password being accepted and the code being handed over; + /// the app ends it with `POST /logout` once it has its tokens, and one it + /// never ends runs out after `cn1.session.timeout` seconds without a + /// request, half an hour unless set. + @Bean + @Order(1) + public SecurityFilterChain signIn(HttpSecurity http, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + @Value("${splat.signin.per-minute:10}") int signInsPerMinute) throws IOException { + http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**", + "/logout") + .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) + // No CSRF token: the app posts the sign-in form itself, from its own + // screen, and would have to fetch a token first to send one. What a + // token is for still has to be done, because the app also runs in a + // browser, where the session is a cookie the browser keeps and would + // send along with a form another site submitted -- signing its + // visitor in as somebody else. Two things stand in for it. The + // session cookie is `SameSite=Lax`, the server's default, so it does + // not travel with another site's request; and [#sameOrigin] below + // refuses any request that changes something and says it came from + // another site, which is the one a forged sign-in is. A server that + // signs people in through a page it serves keeps CSRF on. + .csrf(csrf -> csrf.disable()) + .addFilterBefore(sameOrigin(issuer), LogoutFilter.class) + // Signing out is a POST and nothing else -- with CSRF off the + // default takes a GET too, which an image tag on any page can send + // -- and is answered with a status, for the reason signing in is. + .logout(logout -> logout + .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204))) + // And it is answered with a status, not a redirect to a page: + // the app in a browser cannot see a redirect or the cookie, only + // how the request ended, so that has to be the answer. The session + // cookie is set either way; the installed app reads it, a browser + // keeps it. + .formLogin(form -> form + .successHandler((request, authentication) -> new HttpServer.Response(200, + "application/json", "{\"signedIn\":true}".getBytes("UTF-8")) + .header("Cache-Control", "no-store")) + .failureHandler((request, refused) -> new HttpServer.Response(401, + "application/json", "{\"signedIn\":false}".getBytes("UTF-8")) + .header("Cache-Control", "no-store"))) + // Guessing passwords is the attack on this chain. Ten tries a minute + // from one address, unless `splat.signin.per-minute` says otherwise: + // the end-to-end tests are many people signing in from one address, + // and their profile raises it. + .rateLimit("/login", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(signInsPerMinute, 60)) + .authorizationServer(server -> server + .settings(AuthorizationServerSettings.builder() + .defaultAudience(AUDIENCE).build())); + return http.build(); + } + + /// The API. Stateless: every request stands on its own bearer token. + @Bean + @Order(2) + public SecurityFilterChain api(HttpSecurity http, JwkSource signingKeys, + @Value("${" + ISSUER_KEY + ":}") final String issuer, + JdbcUserDetailsManager users) throws IOException { + DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtAudienceValidator(AUDIENCE), + issuer.length() > 0 ? new JwtIssuerValidator(issuer) + : new JwtClaimValidator("iss", SecurityConfig::issuedAtThisAddress))); + + // The token says who the caller is and nothing about what they may do. + // The roles are read from the account on every request instead of being + // copied into the token, for one reason: an admin who withdraws a role + // or suspends an account means now, not when the token runs out. It + // costs a lookup by primary key per request. + JwtAuthenticationConverter roles = new JwtAuthenticationConverter(); + roles.setJwtGrantedAuthoritiesConverter(jwt -> { + String name = jwt.getSubject(); + if (name == null || !users.userExists(name)) { + throw new DisabledException("No such account"); + } + UserDetails account = users.loadUserByUsername(name); + if (!account.isEnabled() || !account.isAccountNonLocked()) { + throw new DisabledException("This account is suspended"); + } + Collection held = new ArrayList(); + held.addAll(account.getAuthorities()); + return held; + }); + + http.securityMatcher("/api/**") + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Opening accounts is the one thing here that costs something + // each time and that a stranger can ask for. + .rateLimit("/api/account/register", RateLimitKeys.clientAddress(), + new InMemoryRateLimiter(20, 3600)) + // Receipts are checked against the store, in the game's name. + .rateLimit("/api/shop/receipt", + RateLimitKeys.firstOf(RateLimitKeys.principal(), RateLimitKeys.clientAddress()), + new InMemoryRateLimiter(60, 3600)) + // tag::splat-api-rules[] + .authorizeHttpRequests(auth -> auth + .requestMatchers(AntPathRequestMatcher.antMatcher("POST", + "/api/account/register")).permitAll() + .requestMatchers("/api/admin/**").hasRole(Roles.ADMIN) + .anyRequest().authenticated()) + // end::splat-api-rules[] + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(decoder) + .jwtAuthenticationConverter(roles))); + return http.build(); + } + + /// Whether a token's issuer is the address this request came to. + /// + /// Used only when no issuer is configured, which the server allows only on + /// a development profile: it then issues tokens in the name of whatever + /// address each request arrived at, so that is the name to hold a token to. + static boolean issuedAtThisAddress(Object issuer) { + SecurityExchange exchange = SecurityExchange.current(); + if (exchange == null || !(issuer instanceof String)) { + return false; + } + String host = exchange.getRequest().getHeader("Host"); + return host != null && ((exchange.isSecure() ? "https://" : "http://") + host) + .equals(issuer); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Services.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Services.java new file mode 100644 index 00000000000..41e638cecd7 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/Services.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Bean; +import com.codename1.backend.annotations.Configuration; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.push.CloudPush; +import com.codename1.appkit.server.push.CloudPushSender; +import com.codename1.appkit.server.push.ConsolePushSender; +import com.codename1.appkit.server.push.MisconfiguredPushSender; +import com.codename1.appkit.server.push.PushSender; +import com.codename1.appkit.server.push.RecordingPushSender; +import com.codenameone.examples.splat.shop.MisconfiguredReceipts; +import com.codenameone.examples.splat.shop.ReceiptValidator; +import com.codenameone.examples.splat.shop.SimulatedReceipts; +import com.codenameone.examples.splat.shop.StoreReceipts; + +/// The services this server reaches outside itself for, each behind an +/// interface and each chosen here from the settings. A development profile +/// (dev, test, local) gets the stand-in that needs no account; any other +/// profile gets the real one, and when its settings are missing the server +/// still starts and fails every call with a message naming them. It never +/// quietly falls back to a stand-in. +/// +/// Every setting is also read from the environment, in upper case with `_` +/// for each `.` (`SPLAT_IAP_APPLE_SECRET`), which is where secrets belong. +@Configuration +public class Services { + // tag::splat-receipt-choice[] + /// `splat.iap.provider` is one of: + /// + /// - `store`: receipts are checked with Apple (`splat.iap.apple.secret`) + /// and Google Play (`splat.iap.google.package` and + /// `splat.iap.google.token`). A store with no settings refuses its + /// receipts; the other keeps working. + /// - `simulated`: every receipt is accepted, on a development profile only. + /// + /// Unset, it is `simulated` on a development profile and `store` otherwise. + @Bean + public ReceiptValidator receipts(Config config, + @Value("${splat.iap.provider:}") String chosen, + @Value("${splat.iap.apple.secret:}") String appleSecret, + @Value("${splat.iap.apple.bundle:}") String appleBundle, + @Value("${splat.iap.google.package:}") String googlePackage, + @Value("${splat.iap.google.token:}") String googleToken) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : config.isDevelopmentProfile() ? "simulated" : "store"; + if ("simulated".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredReceipts("splat.iap.provider=simulated grants every " + + "purchase for free and is refused on the '" + config.getProfile() + + "' profile; set it to store"); + } + return new SimulatedReceipts(); + } + if (!"store".equalsIgnoreCase(kind)) { + return new MisconfiguredReceipts("splat.iap.provider is '" + kind + + "', which is neither store nor simulated"); + } + if (appleSecret.trim().length() == 0 && (googlePackage.trim().length() == 0 + || googleToken.trim().length() == 0)) { + return new MisconfiguredReceipts("in-app purchases are not configured; set " + + "splat.iap.apple.secret, or splat.iap.google.package and " + + "splat.iap.google.token"); + } + if (appleSecret.trim().length() > 0 && appleBundle.trim().length() == 0) { + // Without it, a genuine receipt from any other app could buy gems. + return new MisconfiguredReceipts("splat.iap.apple.secret is set without " + + "splat.iap.apple.bundle, the bundle id receipts must belong to"); + } + return new StoreReceipts(appleSecret.trim(), appleBundle.trim(), googlePackage.trim(), googleToken.trim()); + } + // end::splat-receipt-choice[] + + // tag::splat-push-choice[] + /// `splat.push.provider` is one of: + /// + /// - `cloud`: the Codename One push service. It needs `splat.push.app`, + /// the app's push id, and `splat.push.key`, a server key with the push + /// scope; both are in the Push section of the Codename One console. + /// - `console`: prints each notification, on a development profile only. + /// - `recording`: keeps each notification for the tests to read, on a + /// development profile only. + /// + /// Unset, it is `recording` on the `test` profile, `console` on the other + /// development profiles and `cloud` otherwise. A server whose push settings + /// are missing still starts and still serves deliveries: it logs a configuration + /// error at start-up and again for every notification it could not send. + @Bean + public PushSender pushSender(Config config, + @Value("${splat.push.provider:}") String chosen, + @Value("${splat.push.app:}") String app, + @Value("${splat.push.key:}") String key, + @Value("${splat.push.url:}") String url) { + String kind = chosen.trim().length() > 0 ? chosen.trim() + : "test".equals(config.getProfile()) ? "recording" + : config.isDevelopmentProfile() ? "console" : "cloud"; + if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) { + if (!config.isDevelopmentProfile()) { + return new MisconfiguredPushSender("splat.push.provider=" + kind + + " sends nothing and is refused on the '" + config.getProfile() + + "' profile; set it to cloud"); + } + return "console".equalsIgnoreCase(kind) ? new ConsolePushSender() + : new RecordingPushSender(); + } + if (!"cloud".equalsIgnoreCase(kind)) { + return new MisconfiguredPushSender("splat.push.provider is '" + kind + + "', which is none of cloud, console or recording"); + } + String missing = missing("splat.push.", new String[] {"app", app, "key", key}); + if (missing.length() > 0) { + return new MisconfiguredPushSender(unset("push", "cloud", missing)); + } + try { + CloudPush push = new CloudPush(app.trim(), key.trim()); + return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim()) + : push); + } catch (IllegalArgumentException err) { + return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: " + + err.getMessage()); + } + } + // end::splat-push-choice[] + + /// The names, after `prefix`, of the settings in `namesAndValues` whose + /// value is empty, separated by commas. + private static String missing(String prefix, String[] namesAndValues) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter + 1 < namesAndValues.length; iter += 2) { + if (namesAndValues[iter + 1] == null || namesAndValues[iter + 1].trim().length() == 0) { + if (out.length() > 0) { + out.append(", "); + } + out.append(prefix).append(namesAndValues[iter]); + } + } + return out.toString(); + } + + private static String unset(String service, String provider, String missing) { + return service + " provider '" + provider + "' is not configured; set " + missing; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/SignInCodePage.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/SignInCodePage.java new file mode 100644 index 00000000000..d99a2d4562c --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/SignInCodePage.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.HttpServer; +import com.codename1.backend.Json; +import com.codename1.backend.annotations.GetMapping; +import com.codename1.backend.annotations.RequestParam; +import com.codename1.backend.annotations.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/// Where a sign-in from a browser ends: the authorization code, handed back. +/// +/// The installed app asks for its code to be sent to a loopback address and +/// reads it off the redirect without following it. A page in a browser cannot +/// do that -- the browser follows the redirect itself and shows the page only +/// what it found at the end -- so the app running in one asks for the code to +/// be sent here, and what is at the end is the code. +/// +/// Nothing is decided here and nothing is trusted: the code arrived in the +/// address, so repeating it tells the caller nothing it did not have, and it +/// is worth nothing without the PKCE verifier only the app that started the +/// sign-in holds. That is why this needs no sign-in of its own. +@RestController +public class SignInCodePage { + @GetMapping(SecurityConfig.WEB_REDIRECT_PATH) + public HttpServer.Response code( + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "error", required = false) String error) throws Exception { + Map answer = new LinkedHashMap(); + if (code != null) { + answer.put("code", code); + } + if (state != null) { + answer.put("state", state); + } + if (error != null) { + answer.put("error", error); + } + return new HttpServer.Response(code == null ? 400 : 200, "application/json", + Json.write(answer).getBytes("UTF-8")) + // A code is good once, for a few minutes; nothing should keep it. + .header("Cache-Control", "no-store") + // And nothing should read it as anything but data: the values + // come from the address, which anyone can write a link to. + .header("X-Content-Type-Options", "nosniff"); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/AccountEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/AccountEndpoint.java new file mode 100644 index 00000000000..d26e6944d62 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/AccountEndpoint.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.appkit.server.Caller; +import com.codenameone.examples.splat.api.AccountApiServer; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.ProfileDto; +import com.codenameone.examples.splat.api.PushDeviceDto; +import com.codenameone.examples.splat.api.RegisterDto; +import com.codenameone.examples.splat.api.TicketDto; +import com.codenameone.examples.splat.game.GameTickets; +import com.codenameone.examples.splat.push.PushDevices; + +@Component +public class AccountEndpoint implements AccountApiServer { + private final Accounts accounts; + private final GameTickets tickets; + private final PushDevices devices; + + public AccountEndpoint(Accounts accounts, GameTickets tickets, PushDevices devices) { + this.accounts = accounts; + this.tickets = tickets; + this.devices = devices; + } + + @Override + public PlayerDto register(RegisterDto request) throws Exception { + return accounts.register(request); + } + + @Override + public PlayerDto me() throws Exception { + return accounts.describe(Caller.name()); + } + + @Override + public PlayerDto saveProfile(ProfileDto profile) throws Exception { + return accounts.rename(Caller.name(), profile == null ? null : profile.displayName()); + } + + @Override + public TicketDto gameTicket() throws Exception { + return tickets.issue(Caller.name()); + } + + @Override + public PushDeviceDto registerPush(PushDeviceDto device) throws Exception { + return devices.register(Caller.name(), device); + } + + @Override + public PushDeviceDto unregisterPush(PushDeviceDto device) throws Exception { + return devices.unregister(Caller.name(), device); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/Accounts.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/Accounts.java new file mode 100644 index 00000000000..b07b3659902 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/Accounts.java @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.account; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.security.GrantedAuthority; +import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager; +import com.codename1.backend.security.core.userdetails.User; +import com.codename1.backend.security.core.userdetails.UserDetails; +import com.codename1.backend.security.crypto.PasswordEncoder; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Roles; +import com.codename1.appkit.server.Text; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.RegisterDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.shop.Catalog; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/// Accounts: the sign-in record (users and authorities, owned by the security +/// layer) plus the [Profile] row that holds everything the game knows. +@Component +public class Accounts { + public static final int START_COINS = 100; + public static final int START_GEMS = 20; + + private final ProfileRepository profiles; + private final JdbcUserDetailsManager users; + private final PasswordEncoder encoder; + + public Accounts(ProfileRepository profiles, JdbcUserDetailsManager users, PasswordEncoder encoder) { + this.profiles = profiles; + this.users = users; + this.encoder = encoder; + } + + public PlayerDto register(RegisterDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Nothing to register"); + } + String username = email(request.email()); + password(request.password()); + String name = displayName(request.displayName()); + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + try { + create(username, request.password(), name, false); + } catch (IOException | RuntimeException failed) { + if (users.userExists(username)) { + throw new ResponseStatusException(409, "That e-mail already has an account"); + } + throw failed; + } + return describe(username); + } + + @Transactional(rollbackFor = IOException.class) + public void create(String username, String password, String name, boolean admin) throws IOException { + users.createUser(User.withUsername(username).password(encoder.encode(password)) + .roles(admin ? new String[] {Roles.PLAYER, Roles.ADMIN} : new String[] {Roles.PLAYER}) + .build()); + Profile p = new Profile(); + p.setUsername(username); + p.setDisplayName(name); + p.setCoins(START_COINS); + p.setGems(START_GEMS); + p.setRunner(Catalog.FREE_RUNNER); + p.setOwned(Catalog.DEFAULT_OUTFIT + "," + Catalog.DEFAULT_EMOTES); + p.setOutfit(Catalog.DEFAULT_OUTFIT); + p.setCreatedAt(Instant.now()); + p.setLastSeen(Instant.now()); + profiles.insert(p); + } + + public boolean exists(String username) { + return users.userExists(username); + } + + @Transactional(readOnly = true) + public PlayerDto describe(String username) throws IOException { + Profile p = profiles.findById(username); + if (p == null || !users.userExists(username)) { + throw new ResponseStatusException(404, "No such account"); + } + return toDto(p); + } + + public PlayerDto toDto(Profile p) { + UserDetails signIn = users.loadUserByUsername(p.getUsername()); + return new PlayerDto(p.getUsername(), p.getDisplayName(), p.getTrophies(), p.getWins(), + p.getLosses(), p.getDraws(), p.getCoins(), p.getGems(), isAdmin(signIn), + !signIn.isEnabled(), p.isMuted(), p.isFlagged(), p.getRunner(), + Lists.split(p.getOwned()), p.getOutfit()); + } + + @Transactional(rollbackFor = IOException.class) + public PlayerDto rename(String username, String displayName) throws IOException { + Profile p = profile(username); + p.setDisplayName(displayName(displayName)); + profiles.save(p); + return toDto(p); + } + + /// Enables or disables sign-in. A disabled account's tokens stop working on + /// its next request, and the game socket is closed by the caller. + public void setEnabled(String username, boolean enabled) { + UserDetails signIn = users.loadUserByUsername(username); + if (signIn.isEnabled() != enabled) { + users.updateUser(User.withUserDetails(signIn).disabled(!enabled).build()); + } + } + + public boolean isEnabled(String username) { + return users.userExists(username) && users.loadUserByUsername(username).isEnabled(); + } + + public boolean isAdmin(String username) { + return users.userExists(username) && isAdmin(users.loadUserByUsername(username)); + } + + private static boolean isAdmin(UserDetails signIn) { + Iterator it = signIn.getAuthorities().iterator(); + while (it.hasNext()) { + if (("ROLE_" + Roles.ADMIN).equals(it.next().getAuthority())) { + return true; + } + } + return false; + } + + @Transactional(readOnly = true) + public Profile profile(String username) { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return p; + } + + public static String email(String value) { + String text = value == null ? "" : value.trim(); + int at = text.indexOf('@'); + if (at < 1 || at != text.lastIndexOf('@') || at > text.length() - 4 + || text.indexOf('.', at) < 0 || text.length() > 190) { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + char[] chars = text.toCharArray(); + for (int i = 0; i < chars.length; i++) { + char c = chars[i]; + if (c <= ' ' || c > '~') { + throw new ResponseStatusException(400, "That is not an e-mail address"); + } + if (c >= 'A' && c <= 'Z') { + chars[i] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + static void password(String value) { + if (value == null || value.length() < 8) { + throw new ResponseStatusException(400, "Choose a password of at least 8 characters"); + } + if (value.length() > 200) { + throw new ResponseStatusException(400, "That password is too long"); + } + } + + /// A player name: 3 to 20 characters, shown to other runners, so control + /// characters and leading or trailing spaces are refused. + static String displayName(String value) { + String name = Text.required(value, 20, "your player name").trim(); + if (name.length() < 3) { + throw new ResponseStatusException(400, "A player name needs at least 3 characters"); + } + for (int i = 0; i < name.length(); i++) { + if (name.charAt(i) < ' ') { + throw new ResponseStatusException(400, "That name has characters we cannot show"); + } + } + return name; + } + + static List names(List list) { + List out = new ArrayList(); + for (Profile p : list) { + out.add(p.getUsername()); + } + return out; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/DemoAccounts.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/DemoAccounts.java new file mode 100644 index 00000000000..14d67ef5755 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/DemoAccounts.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.account; + +import com.codename1.backend.Config; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.PostConstruct; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.splat.admin.Flags; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.api.PipelineDto; +import com.codenameone.examples.splat.api.SpotlightDto; +import com.codenameone.examples.splat.progress.Progression; +import com.codenameone.examples.splat.social.Pipelines; +import com.codenameone.examples.splat.social.Promotion; +import java.io.IOException; +import java.util.Arrays; + +/// The accounts a development server starts with: two players to challenge +/// each other, one flagged player for the console to find, and an admin. +/// Anywhere else only the admin is made, and only when +/// `splat.admin.email` and `splat.admin.password` say who. +@Component +public class DemoAccounts { + public static final String PLAYER = "player@splat.example"; + public static final String RIVAL = "rival@splat.example"; + public static final String TROUBLE = "trouble@splat.example"; + public static final String ADMIN = "admin@splat.example"; + public static final String PASSWORD = "splat-demo"; + + private final Accounts accounts; + private final Flags flags; + private final Progression progression; + private final Config config; + private final Promotion promotion; + private final Pipelines pipelines; + private final DemoHistory history; + private final String adminEmail; + private final String adminPassword; + + public DemoAccounts(Accounts accounts, Flags flags, Progression progression, Config config, + Promotion promotion, Pipelines pipelines, DemoHistory history, + @Value("${splat.admin.email:}") String adminEmail, + @Value("${splat.admin.password:}") String adminPassword) { + this.accounts = accounts; + this.flags = flags; + this.progression = progression; + this.config = config; + this.promotion = promotion; + this.pipelines = pipelines; + this.history = history; + this.adminEmail = adminEmail; + this.adminPassword = adminPassword; + } + + @PostConstruct + public void seed() throws IOException { + if (!config.isDevelopmentProfile()) { + if (adminEmail.length() > 0 && adminPassword.length() >= 8 + && !accounts.exists(Accounts.email(adminEmail))) { + accounts.create(Accounts.email(adminEmail), adminPassword, "Administrator", true); + } + return; + } + add(PLAYER, "Riley", false); + add(RIVAL, "Morgan", false); + add(ADMIN, "Game Master", true); + field(); + if (!accounts.exists(TROUBLE)) { + add(TROUBLE, "Sore Loser", false); + flags.auto(TROUBLE, "Wiped paint off three times in one match", null); + } + // A banner on the home screen from the start, so the spotlight the + // console's Promote tab sets is there to be seen and changed. + String banner = promotion.spotlight().text(); + if (banner == null || banner.length() == 0) { + promotion.spotlight(ADMIN, new SpotlightDto("New field: Cul-de-Sac Siege. The HOA has approved paint.", + "", "play", 0L)); + } + // Two pipelines to start from: one brings lapsed players back, the + // other (off until someone turns it on) rewards a keen player who has + // never bought anything. + if (pipelines.list().isEmpty()) { + pipelines.save(ADMIN, new PipelineDto(null, "Win back", true, + Arrays.asList(new ConditionDto("daysAway", ">=", 7), new ConditionDto("races", ">=", 1)), + Pipelines.PUSH, "Your marker misses you", "The other team says you are scared. Prove them wrong.", + "play", 0, 14, 0, 0L)); + pipelines.save(ADMIN, new PipelineDto(null, "Keen, never paid", false, + Arrays.asList(new ConditionDto("races", ">=", 10), new ConditionDto("purchases", "=", 0)), + Pipelines.GEMS, "A gift for you", "20 gems for fresh camo. Thanks for playing!", + "shop", 20, 30, 0, 0L)); + } + } + + /// The field a development server's leaderboard starts with: named bots + /// of fixed skill whose month of matches [DemoHistory] plays for real. + static final String[][] FIELD = { + {"nova@splat.example", "Nova", "95"}, + {"kai@splat.example", "Kai", "80"}, + {"juno@splat.example", "Juno", "70"}, + {"ezra@splat.example", "Ezra", "60"}, + {"sasha@splat.example", "Sasha", "50"}, + {"dev@splat.example", "Dev", "40"}, + {"ivy@splat.example", "Ivy", "30"}, + {"theo@splat.example", "Theo", "20"}, + }; + + private void field() throws IOException { + for (int i = 0; i < FIELD.length; i++) { + add(FIELD[i][0], FIELD[i][1], false); + } + history.seed(FIELD); + } + + private void add(String username, String name, boolean admin) throws IOException { + if (!accounts.exists(username)) { + accounts.create(username, PASSWORD, name, admin); + progression.seedDemo(username); + } + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/DemoHistory.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/DemoHistory.java new file mode 100644 index 00000000000..fd17677e685 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/DemoHistory.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.account; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.RaceRecord; +import com.codenameone.examples.splat.domain.StorePurchase; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.RaceRecordRepository; +import com.codenameone.examples.splat.repository.StorePurchaseRepository; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Replay; +import com.codenameone.examples.splat.rules.RulesVersion; +import com.codenameone.examples.splat.rules.Slot; +import com.codenameone.examples.splat.game.Arenas; +import java.util.ArrayList; +import java.util.List; +import java.io.IOException; +import java.time.Instant; + +/// A development server's past: what [DemoAccounts] writes behind its field +/// of runners so the console has a month to chart. +@Component +public class DemoHistory { + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final StorePurchaseRepository purchases; + + public DemoHistory(ProfileRepository profiles, RaceRecordRepository races, StorePurchaseRepository purchases) { + this.profiles = profiles; + this.races = races; + this.purchases = purchases; + } + + /// Four weeks behind the field, so the console's charts have a shape on + /// a server started this morning: the runners joined on days spread over + /// the month, raced each other a few times a day -- real races of the + /// rules' bots, replays included -- and now and then bought gems. + @Transactional + public void seed(String[][] field) throws IOException { + // Once per database: a first start that died half way through seeds + // the field again on the next one, but never a second month. + if (races.count() > 0) { + return; + } + long today = Clock.dayOf(Clock.now()); + int days = 28; + for (int i = 0; i < field.length; i++) { + Profile p = profiles.findById(field[i][0]); + if (p != null) { + p.setCreatedAt(Instant.ofEpochMilli(today - (long) (days - 2 - i * 3) * Clock.DAY)); + profiles.save(p); + } + } + for (int d = days - 1; d >= 1; d--) { + long day = today - (long) d * Clock.DAY; + // Busier as the month goes on and more of the field has joined. + int joined = 0; + for (int i = 0; i < field.length; i++) { + if (days - 2 - i * 3 >= d) { + joined++; + } + } + int count = joined < 2 ? 0 : 1 + (days - d) / 6 + (d % 3); + for (int k = 0; k < count; k++) { + // Two a side from whoever has joined, on a field and mode that rotate. + int[] who = new int[Math.min(4, joined - joined % 2)]; + for (int w = 0; w < who.length; w++) { + who[w] = (d * 7 + k * 3 + w) % joined; + } + String ref = MATCHES[(d + k) % MATCHES.length]; + match(field, ref, who, day + (9 + k * 2L) * 3600000L, d * 131L + k); + } + if (d % 4 == 1 && joined > 0) { + StorePurchase s = new StorePurchase(); + s.setId(Ids.next()); + s.setUsername(field[d % joined][0]); + s.setStore("demo"); + boolean big = d % 8 == 1; + s.setSku(big ? "splat.gems_500" : "splat.gems_80"); + s.setGems(big ? 500 : 80); + s.setCreatedAt(Instant.ofEpochMilli(day + 15 * 3600000L)); + purchases.insert(s); + } + } + } + + private static final String[] MATCHES = { + "lot7:TEAM_PAINT", "cul-de-sac:SNACK_BAR", "lot7:CAPTURE_THE_FLAG", "cul-de-sac:TEAM_PAINT", + }; + + private void match(String[][] field, String ref, int[] who, long endedAt, long seed) throws IOException { + Mode mode = Arenas.mode(ref); + List slots = new ArrayList(); + for (int i = 0; i < who.length; i++) { + slots.add(new Slot(field[who[i]][1], i % 2, "semi", Integer.parseInt(field[who[i]][2]))); + } + Replay replay = new Replay(seed, Arenas.arena(ref), mode, slots); + Match match = replay.newMatch(); + Input[] idle = new Input[who.length]; + while (!match.over()) { + match.step(idle); + } + int winner = match.winner(); + StringBuilder players = new StringBuilder(); + StringBuilder names = new StringBuilder(); + StringBuilder places = new StringBuilder(); + StringBuilder points = new StringBuilder(); + StringBuilder trophies = new StringBuilder(); + for (int i = 0; i < who.length; i++) { + boolean won = winner >= 0 && i % 2 == winner; + int place = winner < 0 || won ? 0 : 1; + String sep = i == 0 ? "" : ","; + players.append(sep).append(field[who[i]][0]); + names.append(sep).append(field[who[i]][1]); + places.append(sep).append(place); + points.append(sep).append(match.player(i).paints()); + int t = winner < 0 ? 0 : won ? 20 : -15; + trophies.append(sep).append(t); + Profile p = profiles.findById(field[who[i]][0]); + if (p != null) { + p.setTrophies(Math.max(0, p.getTrophies() + t)); + if (won) { + p.setWins(p.getWins() + 1); + } else if (winner >= 0) { + p.setLosses(p.getLosses() + 1); + } + p.setLastSeen(Instant.ofEpochMilli(endedAt)); + profiles.save(p); + } + } + RaceRecord m = new RaceRecord(); + m.setId(Ids.next()); + m.setLevel(ref); + m.setPlayers(players.toString()); + m.setNames(names.toString()); + m.setPlaces(places.toString()); + m.setScores(points.toString()); + m.setTrophies(trophies.toString()); + m.setRunners(who.length); + m.setHumans(who.length); + m.setTicks(match.tick()); + m.setEndedAt(Instant.ofEpochMilli(endedAt)); + m.setRulesVersion(String.valueOf(RulesVersion.ID)); + m.setReplay(replay.encode()); + races.insert(m); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/Lists.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/Lists.java new file mode 100644 index 00000000000..e6e7be3bd2f --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/account/Lists.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.account; + +import java.util.ArrayList; +import java.util.List; + +/// Comma-separated id lists, the way a profile stores its owned items. +public final class Lists { + private Lists() { + } + + public static String join(String[] items) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < items.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(items[i]); + } + return sb.toString(); + } + + public static String join(List items) { + return join(items.toArray(new String[items.size()])); + } + + public static List split(String csv) { + List out = new ArrayList(); + if (csv == null || csv.length() == 0) { + return out; + } + int start = 0; + for (int i = 0; i <= csv.length(); i++) { + if (i == csv.length() || csv.charAt(i) == ',') { + if (i > start) { + out.add(csv.substring(start, i)); + } + start = i + 1; + } + } + return out; + } + + /// Like [#split], but keeps empty fields, so positions line up: a race's + /// player list has an empty entry for each bot. + public static List fields(String csv) { + List out = new ArrayList(); + if (csv == null || csv.length() == 0) { + return out; + } + int start = 0; + for (int i = 0; i <= csv.length(); i++) { + if (i == csv.length() || csv.charAt(i) == ',') { + out.add(csv.substring(start, i)); + start = i + 1; + } + } + return out; + } + + public static boolean contains(String csv, String id) { + return split(csv).contains(id); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/AdminChannel.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/AdminChannel.java new file mode 100644 index 00000000000..fe42f308d42 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/AdminChannel.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The admins connected to the game socket right now, so a new flag reaches the +/// console the moment it is raised. +@Component +public class AdminChannel { + private final Map admins = new HashMap(); + + public synchronized void add(String username, WebSocketSession session) { + admins.put(username, session); + } + + public synchronized void remove(String username, WebSocketSession session) { + if (admins.get(username) == session) { + admins.remove(username); + } + } + + public void flagged(String username, String reason) { + List to; + synchronized (this) { + to = new ArrayList(admins.values()); + } + String msg = "{\"type\":\"flag\",\"username\":" + Json.write(username) + ",\"reason\":" + + Json.write(reason) + "}"; + for (WebSocketSession s : to) { + try { + if (s.isOpen()) { + s.sendText(msg); + } + } catch (IOException ignored) { + // Closing; its close callback removes it. + } + } + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/AdminEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/AdminEndpoint.java new file mode 100644 index 00000000000..0485e9dd417 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/AdminEndpoint.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codename1.appkit.server.Caller; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.api.AdminActionDto; +import com.codenameone.examples.splat.api.AdminApiServer; +import com.codenameone.examples.splat.api.AdminStatsDto; +import com.codenameone.examples.splat.api.AuditDto; +import com.codenameone.examples.splat.api.BroadcastDto; +import com.codenameone.examples.splat.api.FlagDto; +import com.codenameone.examples.splat.api.LiveRaceDto; +import com.codenameone.examples.splat.api.PlayerDetailDto; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.PipelineDto; +import com.codenameone.examples.splat.api.PromoteDto; +import com.codenameone.examples.splat.api.QueryDto; +import com.codenameone.examples.splat.api.QueryResultDto; +import com.codenameone.examples.splat.api.InsightsDto; +import com.codenameone.examples.splat.api.SpotlightDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.StorePurchase; +import com.codenameone.examples.splat.game.GameEndpoint; +import com.codenameone.examples.splat.game.Rooms; +import com.codenameone.examples.splat.repository.RaceEntryRepository; +import com.codenameone.examples.splat.repository.RaceRecordRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.StorePurchaseRepository; +import com.codenameone.examples.splat.shop.Shop; +import com.codenameone.examples.splat.social.Insights; +import com.codenameone.examples.splat.social.Pipelines; +import com.codenameone.examples.splat.social.Promotion; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// God mode. The security chain already requires ADMIN for `/api/admin/**`. +// tag::splat-admin[] +@Component +public class AdminEndpoint implements AdminApiServer { + private final Accounts accounts; + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final RaceEntryRepository entries; + private final StorePurchaseRepository purchases; + private final Flags flags; + private final Moderation moderation; + private final Rooms rooms; + private final Shop shop; + + private final Promotion promotion; + private final Insights insights; + private final Pipelines pipelines; + + public AdminEndpoint(Accounts accounts, ProfileRepository profiles, RaceRecordRepository races, RaceEntryRepository entries, + StorePurchaseRepository purchases, Flags flags, Moderation moderation, Rooms rooms, Shop shop, + Promotion promotion, Insights insights, Pipelines pipelines) { + this.insights = insights; + this.pipelines = pipelines; + this.promotion = promotion; + this.accounts = accounts; + this.profiles = profiles; + this.races = races; + this.entries = entries; + this.purchases = purchases; + this.flags = flags; + this.moderation = moderation; + this.rooms = rooms; + this.shop = shop; + } + + @Override + @Transactional(readOnly = true) + public AdminStatsDto stats() throws Exception { + Instant today = Instant.ofEpochMilli(Clock.dayOf(Clock.now())); + int gems = 0; + List sold = purchases.since(today); + for (StorePurchase s : sold) { + gems += s.getGems(); + } + return new AdminStatsDto((int) profiles.count(), rooms.online(), rooms.live().size(), + (int) races.countByEndedAtGreaterThan(today), (int) flags.pendingCount(), + (int) count(profiles.suspended(Instant.now())), sold.size(), gems); + } + + @Override + @Transactional(readOnly = true) + public List players(String query) throws Exception { + String q = query == null ? "" : query.trim(); + List out = new ArrayList(); + for (Profile p : profiles.search("%" + q + "%", new PageRequest(0, 100))) { + out.add(accounts.toDto(p)); + } + return out; + } + + @Override + @Transactional(readOnly = true) + public PlayerDetailDto player(String username) throws Exception { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return new PlayerDetailDto(accounts.toDto(p), + GameEndpoint.summaries(entries.history(username, new PageRequest(0, 30))), + flags.of(username), moderation.of(username), shop.purchasesOf(username)); + } + + @Override + public PlayerDto act(String username, AdminActionDto action) throws Exception { + return moderation.act(Caller.name(), username, action); + } + + @Override + public List flags(boolean open) throws Exception { + return flags.list(open); + } + + @Override + public FlagDto resolve(String id) throws Exception { + return flags.resolve(Caller.name(), id); + } + + @Override + public List liveRaces() throws Exception { + return rooms.live(); + } + + private static long count(Long n) { + return n == null ? 0 : n.longValue(); + } + + @Override + public List audit() throws Exception { + return moderation.newest(); + } + + + + + + @Override + public PromoteDto promote() throws Exception { + return promotion.overview(); + } + + @Override + public BroadcastDto audience(BroadcastDto body) throws Exception { + return promotion.audience(body); + } + + @Override + public BroadcastDto broadcast(BroadcastDto body) throws Exception { + return promotion.broadcast(Caller.name(), body); + } + + @Override + public SpotlightDto spotlight(SpotlightDto body) throws Exception { + return promotion.spotlight(Caller.name(), body); + } + + @Override + public InsightsDto reports(int days) throws Exception { + return insights.report(days); + } + + @Override + public QueryResultDto query(QueryDto body) throws Exception { + return insights.query(body); + } + + @Override + public List pipelines() throws Exception { + return pipelines.list(); + } + + @Override + public PipelineDto savePipeline(PipelineDto body) throws Exception { + return pipelines.save(Caller.name(), body); + } + + @Override + public PipelineDto deletePipeline(String id) throws Exception { + return pipelines.delete(Caller.name(), id); + } + + @Override + public PipelineDto runPipeline(String id) throws Exception { + return pipelines.run(id); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Flags.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Flags.java new file mode 100644 index 00000000000..7cd5ca82676 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Flags.java @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codename1.appkit.server.Text; +import com.codenameone.examples.splat.account.Lists; +import com.codenameone.examples.splat.api.FlagDto; +import com.codenameone.examples.splat.domain.Flag; +import com.codenameone.examples.splat.domain.RaceRecord; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.push.GamePushes; +import com.codenameone.examples.splat.repository.FlagRepository; +import com.codenameone.examples.splat.repository.RaceRecordRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Flags: the queue the admin console works through. +/// +/// Two sources fill it. Players report another runner after a race (one report +/// per player per race), and the server raises its own AUTO flags when it sees +/// what an honest client never does: plays it had to refuse, a flood of plays, +/// a state that diverged from the server's, or leaving race after race. +// tag::splat-flags[] +@Component +public class Flags { + public static final String REPORT = "REPORT"; + public static final String AUTO = "AUTO"; + + private final FlagRepository flags; + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final GamePushes pushes; + private final AdminChannel channel; + private final List adminNames; + + public Flags(FlagRepository flags, ProfileRepository profiles, RaceRecordRepository races, + GamePushes pushes, AdminChannel channel, @Value("${splat.admin.notify:}") String adminNames) { + this.flags = flags; + this.profiles = profiles; + this.races = races; + this.pushes = pushes; + this.channel = channel; + this.adminNames = com.codenameone.examples.splat.account.Lists.split(adminNames); + } + + /// A player reporting another runner of a race they ran in. `accused` is + /// the seat number, as the race's lists number them. + @Transactional(rollbackFor = IOException.class) + public FlagDto report(String reporter, String raceId, String accused, String reason) throws IOException { + RaceRecord m = raceId == null ? null : races.findById(raceId); + List players = m == null ? new ArrayList() : Lists.fields(m.getPlayers()); + if (!players.contains(reporter)) { + throw new ResponseStatusException(404, "No such race"); + } + int seat = accused == null ? -1 : players.indexOf(accused); + if (seat < 0) { + int n = accused != null && accused.length() == 1 ? accused.charAt(0) - '0' : -1; + seat = n >= 0 && n < players.size() ? n : -1; + } + String who = seat < 0 ? "" : players.get(seat); + if (who.length() == 0 || who.equals(reporter)) { + throw new ResponseStatusException(400, "Pick a runner from that race to report; bots cannot be"); + } + if (flags.countByReporterAndRaceId(reporter, raceId) > 0) { + throw new ResponseStatusException(409, "You already reported this race"); + } + String why = Text.required(reason, 255, "what happened"); + return raise(who, REPORT, why, raceId, reporter); + } + + /// Something the server noticed itself. Transactional on its own because + /// the demo seeding calls it with no transaction open; inside one (a race + /// being recorded) it joins that one. + @Transactional(rollbackFor = IOException.class) + public void auto(String username, String reason, String raceId) { + try { + raise(username, AUTO, reason, raceId == null ? "" : raceId, ""); + } catch (IOException | RuntimeException failed) { + System.err.println("[flags] could not flag " + username + ": " + failed); + } + } + + FlagDto raise(String username, String kind, String reason, String raceId, String reporter) + throws IOException { + Flag f = store(username, kind, reason, raceId, reporter); + channel.flagged(username, reason); + pushes.flagRaised(adminNames, username, reason); + return toDto(f, profiles.findById(username)); + } + + @Transactional(rollbackFor = IOException.class) + Flag store(String username, String kind, String reason, String raceId, String reporter) + throws IOException { + Flag f = new Flag(); + f.setId(Ids.next()); + f.setUsername(username); + f.setKind(kind); + f.setReason(reason); + f.setRaceId(raceId); + f.setReporter(reporter); + f.setCreatedAt(Instant.now()); + f.setPending(true); + flags.insert(f); + Profile p = profiles.findById(username); + if (p != null && !p.isFlagged()) { + p.setFlagged(true); + profiles.save(p); + } + return f; + } + + @Transactional(rollbackFor = IOException.class) + public FlagDto resolve(String admin, String id) { + Flag f = flags.findById(id); + if (f == null) { + throw new ResponseStatusException(404, "No such flag"); + } + f.setPending(false); + f.setResolvedBy(admin); + flags.save(f); + Profile p = profiles.findById(f.getUsername()); + if (p != null && flags.countByUsernameAndPendingTrue(f.getUsername()) == 0) { + p.setFlagged(false); + profiles.save(p); + } + return toDto(f, p); + } + + @Transactional(readOnly = true) + public List list(boolean pending) { + List out = new ArrayList(); + for (Flag f : flags.inState(pending, new PageRequest(0, 200))) { + out.add(toDto(f, profiles.findById(f.getUsername()))); + } + return out; + } + + @Transactional(readOnly = true) + public List of(String username) { + List out = new ArrayList(); + Profile p = profiles.findById(username); + for (Flag f : flags.of(username, new PageRequest(0, 50))) { + out.add(toDto(f, p)); + } + return out; + } + + public long pendingCount() { + return flags.countByPendingTrue(); + } + + static FlagDto toDto(Flag f, Profile p) { + return new FlagDto(f.getId(), f.getUsername(), p == null ? f.getUsername() : p.getDisplayName(), + f.getKind(), f.getReason(), f.getRaceId(), f.getReporter(), f.getCreatedAt().toEpochMilli(), f.isPending()); + } +} +// end::splat-flags[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Moderation.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Moderation.java new file mode 100644 index 00000000000..179904de798 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/admin/Moderation.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codename1.appkit.server.Text; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.api.AdminActionDto; +import com.codenameone.examples.splat.api.AuditDto; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.domain.AdminAction; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.game.Rooms; +import com.codenameone.examples.splat.repository.AdminActionRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.shop.Catalog; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// What an admin can do to a player, and the audit log that records it. +/// +/// Every action is written to `admin_action` in the same transaction as the +/// change itself, so the log cannot miss one. +// tag::splat-moderation[] +@Component +public class Moderation { + public static final String WARN = "WARN"; + public static final String MUTE = "MUTE"; + public static final String UNMUTE = "UNMUTE"; + public static final String SUSPEND = "SUSPEND"; + public static final String BAN = "BAN"; + public static final String UNBAN = "UNBAN"; + public static final String RESET_TROPHIES = "RESET_TROPHIES"; + public static final String REVOKE_ITEMS = "REVOKE_ITEMS"; + static final String[] ACTIONS = {WARN, MUTE, UNMUTE, SUSPEND, BAN, UNBAN, RESET_TROPHIES, REVOKE_ITEMS}; + + private final Accounts accounts; + private final ProfileRepository profiles; + private final AdminActionRepository audit; + private final Rooms rooms; + + public Moderation(Accounts accounts, ProfileRepository profiles, AdminActionRepository audit, Rooms rooms) { + this.accounts = accounts; + this.profiles = profiles; + this.audit = audit; + this.rooms = rooms; + } + + public PlayerDto act(String admin, String username, AdminActionDto request) throws IOException { + if (request == null) { + throw new ResponseStatusException(400, "Choose an action"); + } + String action = Text.oneOf(request.action(), ACTIONS, "", "the action"); + if (action.length() == 0) { + throw new ResponseStatusException(400, "Choose an action"); + } + if (admin.equals(username) && !WARN.equals(action)) { + throw new ResponseStatusException(400, "Not on your own account"); + } + String reason = Text.required(request.reason(), 255, "the reason"); + boolean closes = apply(admin, username, action, reason, request.days()); + if (closes) { + rooms.kick(username); + } + return accounts.describe(username); + } + + @Transactional(rollbackFor = IOException.class) + boolean apply(String admin, String username, String action, String reason, int days) throws IOException { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + Profile p = profiles.findById(username); + boolean closes = false; + if (MUTE.equals(action) || UNMUTE.equals(action)) { + p.setMuted(MUTE.equals(action)); + } else if (SUSPEND.equals(action)) { + if (days < 1 || days > 365) { + throw new ResponseStatusException(400, "Suspend for 1 to 365 days"); + } + p.setSuspendedUntil(Instant.now().plusMillis(days * Clock.DAY)); + accounts.setEnabled(username, false); + closes = true; + } else if (BAN.equals(action)) { + p.setBanned(true); + accounts.setEnabled(username, false); + closes = true; + } else if (UNBAN.equals(action)) { + p.setBanned(false); + p.setSuspendedUntil(Instant.EPOCH); + accounts.setEnabled(username, true); + } else if (RESET_TROPHIES.equals(action)) { + p.setTrophies(0); + } else if (REVOKE_ITEMS.equals(action)) { + p.setOwned(Catalog.DEFAULT_OUTFIT + "," + Catalog.DEFAULT_EMOTES); + p.setOutfit(Catalog.DEFAULT_OUTFIT); + } + profiles.save(p); + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(admin); + a.setUsername(username); + a.setAction(SUSPEND.equals(action) ? SUSPEND + " " + days + "d" : action); + a.setReason(reason); + a.setCreatedAt(Instant.now()); + audit.insert(a); + return closes; + } + + /// Lifts suspensions that have run their course. + @Scheduled(fixedDelay = 60000, initialDelay = 60000, lock = "splat-suspensions") + public void liftExpired() { + lift(Clock.now()); + } + + @Transactional + public int lift(long now) { + int lifted = 0; + for (Profile p : profiles.findAll()) { + if (!p.isBanned() && p.getSuspendedUntil().isAfter(Instant.EPOCH) + && p.getSuspendedUntil().toEpochMilli() <= now) { + p.setSuspendedUntil(Instant.EPOCH); + profiles.save(p); + accounts.setEnabled(p.getUsername(), true); + lifted++; + } + } + return lifted; + } + + @Transactional(readOnly = true) + public List newest() { + return toDtos(audit.newest(new PageRequest(0, 200))); + } + + @Transactional(readOnly = true) + public List of(String username) { + return toDtos(audit.of(username, new PageRequest(0, 50))); + } + + private static List toDtos(List rows) { + List out = new ArrayList(); + for (AdminAction a : rows) { + out.add(new AuditDto(a.getId(), a.getAdmin(), a.getUsername(), a.getAction(), a.getReason(), + a.getCreatedAt().toEpochMilli())); + } + return out; + } +} +// end::splat-moderation[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/AdminAction.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/AdminAction.java new file mode 100644 index 00000000000..428e32fbb85 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/AdminAction.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One entry of the admin audit log. Rows are only ever inserted. +@Entity +public class AdminAction { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String admin = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(24)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public AdminAction() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getAdmin() { + return admin; + } + + public void setAdmin(String admin) { + this.admin = admin; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Broadcast.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Broadcast.java new file mode 100644 index 00000000000..b6eb23d0fd5 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Broadcast.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A push an operator sent to a segment of players, kept as the record of it. +@Entity +public class Broadcast { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(10)") + private String segment = ""; + + @Column(nullable = false) + private int days; + + @Column(nullable = false, type = "VARCHAR(60)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String body = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String screen = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String campaign = ""; + + @Column(nullable = false) + private int targets; + + @Column(nullable = false, type = "VARCHAR(190)") + private String sentBy = ""; + + @Column(nullable = false) + private Instant sentAt = Instant.EPOCH; + + public Broadcast() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getSegment() { + return segment; + } + + public void setSegment(String segment) { + this.segment = segment; + } + + public int getDays() { + return days; + } + + public void setDays(int days) { + this.days = days; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getScreen() { + return screen; + } + + public void setScreen(String screen) { + this.screen = screen; + } + + public String getCampaign() { + return campaign; + } + + public void setCampaign(String campaign) { + this.campaign = campaign; + } + + public int getTargets() { + return targets; + } + + public void setTargets(int targets) { + this.targets = targets; + } + + public String getSentBy() { + return sentBy; + } + + public void setSentBy(String sentBy) { + this.sentBy = sentBy; + } + + public Instant getSentAt() { + return sentAt; + } + + public void setSentAt(Instant sentAt) { + this.sentAt = sentAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Crew.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Crew.java new file mode 100644 index 00000000000..56153854c02 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Crew.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A crew: friends who race together. Joined by its code, which is also how it +/// is shared. +@Entity +public class Crew { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false) + private int members; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public Crew() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public int getMembers() { + return members; + } + + public void setMembers(int members) { + this.members = members; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/CrewMember.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/CrewMember.java new file mode 100644 index 00000000000..db9474678c4 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/CrewMember.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player in a [Crew]; the id is `code|username`. +@Entity +public class CrewMember { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(210)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant joinedAt = Instant.EPOCH; + + public CrewMember() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(Instant joinedAt) { + this.joinedAt = joinedAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Flag.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Flag.java new file mode 100644 index 00000000000..134f49c9f0b --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Flag.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A reason for an admin to look at a player: a fellow runner's report or something the server detected. +@Entity +public class Flag { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String kind = ""; + + @Column(nullable = false, type = "VARCHAR(255)") + private String reason = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String raceId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String reporter = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private boolean pending; + + @Column(nullable = false, type = "VARCHAR(190)") + private String resolvedBy = ""; + + public Flag() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getRaceId() { + return raceId; + } + + public void setRaceId(String raceId) { + this.raceId = raceId; + } + + public String getReporter() { + return reporter; + } + + public void setReporter(String reporter) { + this.reporter = reporter; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public boolean isPending() { + return pending; + } + + public void setPending(boolean pending) { + this.pending = pending; + } + + public String getResolvedBy() { + return resolvedBy; + } + + public void setResolvedBy(String resolvedBy) { + this.resolvedBy = resolvedBy; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/GameTicket.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/GameTicket.java new file mode 100644 index 00000000000..1753c95ba7c --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/GameTicket.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A ticket for one connection to the live channel, as its hash. +@Entity +public class GameTicket { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String ticketHash = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant expiresAt = Instant.EPOCH; + + public GameTicket() { + } + + public String getTicketHash() { + return ticketHash; + } + + public void setTicketHash(String ticketHash) { + this.ticketHash = ticketHash; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/MissionRecord.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/MissionRecord.java new file mode 100644 index 00000000000..4d26e577a7c --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/MissionRecord.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A mission a player built and published. `body` is the mission's JSON with +/// `code` already set to this row's code; `name` and `mode` are copied out of +/// it so a listing need not parse every body. +@Entity +public class MissionRecord { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String owner = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String name = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String mode = ""; + + @Column(nullable = false) + private String body = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private int plays; + + public MissionRecord() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public int getPlays() { + return plays; + } + + public void setPlays(int plays) { + this.plays = plays; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Pipeline.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Pipeline.java new file mode 100644 index 00000000000..d064029c06e --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Pipeline.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// An engagement pipeline: a query over the players and what to do for each +/// one who matches. See `social.Pipelines`. +@Entity +public class Pipeline { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String name = ""; + + @Column(nullable = false) + private boolean enabled; + + @Column(nullable = false, type = "VARCHAR(500)") + private String conditions = ""; + + @Column(nullable = false, type = "VARCHAR(10)") + private String action = ""; + + @Column(nullable = false, type = "VARCHAR(60)") + private String title = ""; + + @Column(nullable = false, type = "VARCHAR(200)") + private String body = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String screen = ""; + + @Column(nullable = false) + private int amount; + + @Column(nullable = false) + private int cooldownDays; + + @Column(nullable = false) + private int reached; + + @Column(nullable = false) + private Instant lastRun = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(190)") + private String createdBy = ""; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getConditions() { + return conditions; + } + + public void setConditions(String conditions) { + this.conditions = conditions; + } + + public String getAction() { + return action; + } + + public void setAction(String action) { + this.action = action; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getScreen() { + return screen; + } + + public void setScreen(String screen) { + this.screen = screen; + } + + public int getAmount() { + return amount; + } + + public void setAmount(int amount) { + this.amount = amount; + } + + public int getCooldownDays() { + return cooldownDays; + } + + public void setCooldownDays(int cooldownDays) { + this.cooldownDays = cooldownDays; + } + + public int getReached() { + return reached; + } + + public void setReached(int reached) { + this.reached = reached; + } + + public Instant getLastRun() { + return lastRun; + } + + public void setLastRun(Instant lastRun) { + this.lastRun = lastRun; + } + + public String getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(String createdBy) { + this.createdBy = createdBy; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/PipelineHit.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/PipelineHit.java new file mode 100644 index 00000000000..f5ea0dfcde3 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/PipelineHit.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// When a pipeline last acted on a player, so it waits its cool-down before +/// acting on them again. The id is the pipeline id, a colon and the username. +@Entity +public class PipelineHit { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(230)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String pipeline = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant hitAt = Instant.EPOCH; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPipeline() { + return pipeline; + } + + public void setPipeline(String pipeline) { + this.pipeline = pipeline; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getHitAt() { + return hitAt; + } + + public void setHitAt(Instant hitAt) { + this.hitAt = hitAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Profile.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Profile.java new file mode 100644 index 00000000000..1a7a095c91a --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Profile.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player's account data beyond the sign-in record: rating, currencies, runner, cosmetics and moderation state. +@Entity +public class Profile { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String displayName = ""; + + @Column(nullable = false) + private int trophies; + + @Column(nullable = false) + private int wins; + + @Column(nullable = false) + private int losses; + + @Column(nullable = false) + private int draws; + + @Column(nullable = false) + private int coins; + + @Column(nullable = false) + private int gems; + + @Column(nullable = false, type = "VARCHAR(40)") + private String runner = "semi"; + + @Column(nullable = false, type = "VARCHAR(1000)") + private String owned = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String outfit = ""; + + @Column(nullable = false) + private boolean flagged; + + @Column(nullable = false) + private boolean muted; + + @Column(nullable = false) + private Instant suspendedUntil = Instant.EPOCH; + + @Column(nullable = false) + private boolean banned; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant lastSeen = Instant.EPOCH; + + @Column(nullable = false) + private long adDay; + + @Column(nullable = false) + private int adCount; + + @Column(nullable = false) + private int leftStreak; + + public Profile() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public int getTrophies() { + return trophies; + } + + public void setTrophies(int trophies) { + this.trophies = trophies; + } + + public int getWins() { + return wins; + } + + public void setWins(int wins) { + this.wins = wins; + } + + public int getLosses() { + return losses; + } + + public void setLosses(int losses) { + this.losses = losses; + } + + public int getDraws() { + return draws; + } + + public void setDraws(int draws) { + this.draws = draws; + } + + public int getCoins() { + return coins; + } + + public void setCoins(int coins) { + this.coins = coins; + } + + public int getGems() { + return gems; + } + + public void setGems(int gems) { + this.gems = gems; + } + + public String getRunner() { + return runner; + } + + public void setRunner(String runner) { + this.runner = runner; + } + + public String getOwned() { + return owned; + } + + public void setOwned(String owned) { + this.owned = owned; + } + + public String getOutfit() { + return outfit; + } + + public void setOutfit(String outfit) { + this.outfit = outfit; + } + + public boolean isFlagged() { + return flagged; + } + + public void setFlagged(boolean flagged) { + this.flagged = flagged; + } + + public boolean isMuted() { + return muted; + } + + public void setMuted(boolean muted) { + this.muted = muted; + } + + public Instant getSuspendedUntil() { + return suspendedUntil; + } + + public void setSuspendedUntil(Instant suspendedUntil) { + this.suspendedUntil = suspendedUntil; + } + + public boolean isBanned() { + return banned; + } + + public void setBanned(boolean banned) { + this.banned = banned; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getLastSeen() { + return lastSeen; + } + + public void setLastSeen(Instant lastSeen) { + this.lastSeen = lastSeen; + } + + public long getAdDay() { + return adDay; + } + + public void setAdDay(long adDay) { + this.adDay = adDay; + } + + public int getAdCount() { + return adCount; + } + + public void setAdCount(int adCount) { + this.adCount = adCount; + } + + public int getLeftStreak() { + return leftStreak; + } + + public void setLeftStreak(int leftStreak) { + this.leftStreak = leftStreak; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Progress.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Progress.java new file mode 100644 index 00000000000..f53c5d509b1 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Progress.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/// A player's progress: experience, chests, lifetime counters behind the +/// achievements, and today's quest counters. One row per account, created the +/// first time anything asks for it. +@Entity +public class Progress { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private int xp; + + @Column(nullable = false) + private boolean tutorialDone; + + @Column(nullable = false) + private int chests; + + @Column(nullable = false) + private int chestsOpened; + + @Column(nullable = false) + private int races; + + @Column(nullable = false) + private int wins; + + @Column(nullable = false) + private int metres; + + @Column(nullable = false) + private int pickups; + + @Column(nullable = false) + private int podiums; + + @Column(nullable = false) + private int streak; + + @Column(nullable = false) + private int bestStreak; + + @Column(nullable = false, type = "VARCHAR(500)") + private String achievements = ""; + + @Column(nullable = false) + private long questDay; + + @Column(nullable = false) + private int quest0; + + @Column(nullable = false) + private int quest1; + + @Column(nullable = false) + private int quest2; + + @Column(nullable = false) + private int claimed; + + public Progress() { + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getXp() { + return xp; + } + + public void setXp(int xp) { + this.xp = xp; + } + + public boolean isTutorialDone() { + return tutorialDone; + } + + public void setTutorialDone(boolean tutorialDone) { + this.tutorialDone = tutorialDone; + } + + public int getChests() { + return chests; + } + + public void setChests(int chests) { + this.chests = chests; + } + + public int getChestsOpened() { + return chestsOpened; + } + + public void setChestsOpened(int chestsOpened) { + this.chestsOpened = chestsOpened; + } + + public int getRaces() { + return races; + } + + public void setRaces(int races) { + this.races = races; + } + + public int getWins() { + return wins; + } + + public void setWins(int wins) { + this.wins = wins; + } + + public int getMetres() { + return metres; + } + + public void setMetres(int metres) { + this.metres = metres; + } + + public int getPickups() { + return pickups; + } + + public void setPickups(int pickups) { + this.pickups = pickups; + } + + public int getPodiums() { + return podiums; + } + + public void setPodiums(int podiums) { + this.podiums = podiums; + } + + public int getStreak() { + return streak; + } + + public void setStreak(int streak) { + this.streak = streak; + } + + public int getBestStreak() { + return bestStreak; + } + + public void setBestStreak(int bestStreak) { + this.bestStreak = bestStreak; + } + + public String getAchievements() { + return achievements; + } + + public void setAchievements(String achievements) { + this.achievements = achievements; + } + + public long getQuestDay() { + return questDay; + } + + public void setQuestDay(long questDay) { + this.questDay = questDay; + } + + public int getQuest0() { + return quest0; + } + + public void setQuest0(int quest0) { + this.quest0 = quest0; + } + + public int getQuest1() { + return quest1; + } + + public void setQuest1(int quest1) { + this.quest1 = quest1; + } + + public int getQuest2() { + return quest2; + } + + public void setQuest2(int quest2) { + this.quest2 = quest2; + } + + public int getClaimed() { + return claimed; + } + + public void setClaimed(int claimed) { + this.claimed = claimed; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/PushDevice.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/PushDevice.java new file mode 100644 index 00000000000..331e6345cd3 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/PushDevice.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A device an account is sent push notifications on. +/// +/// Keyed by the hash of its provider and token, so a token is on one account at +/// a time: the phone belongs to whoever signed in on it last. +@Entity +public class PushDevice { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + /// `fcm`, `apns`, `huawei`, `wns` or `web`. + @Column(nullable = false, type = "VARCHAR(16)") + private String provider = ""; + + @Column(nullable = false, type = "VARCHAR(1024)") + private String token = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String platform = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public PushDevice() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/RaceEntry.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/RaceEntry.java new file mode 100644 index 00000000000..d10df087f80 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/RaceEntry.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// One player's line of a [RaceRecord], so a player's history is an indexed +/// lookup rather than a search through comma-separated lists. +@Entity +public class RaceEntry { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(230)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(32)") + private String raceId = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(160)") + private String level = ""; + + @Column(nullable = false) + private int place; + + @Column(nullable = false) + private int runners; + + @Column(nullable = false) + private int score; + + @Column(nullable = false) + private int trophyChange; + + @Column(nullable = false) + private int ticks; + + @Column(nullable = false) + private Instant endedAt = Instant.EPOCH; + + public RaceEntry() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getRaceId() { + return raceId; + } + + public void setRaceId(String raceId) { + this.raceId = raceId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public int getPlace() { + return place; + } + + public void setPlace(int place) { + this.place = place; + } + + public int getRunners() { + return runners; + } + + public void setRunners(int runners) { + this.runners = runners; + } + + public int getScore() { + return score; + } + + public void setScore(int score) { + this.score = score; + } + + public int getTrophyChange() { + return trophyChange; + } + + public void setTrophyChange(int trophyChange) { + this.trophyChange = trophyChange; + } + + public int getTicks() { + return ticks; + } + + public void setTicks(int ticks) { + this.ticks = ticks; + } + + public Instant getEndedAt() { + return endedAt; + } + + public void setEndedAt(Instant endedAt) { + this.endedAt = endedAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/RaceRecord.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/RaceRecord.java new file mode 100644 index 00000000000..d0fd6dfc555 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/RaceRecord.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A finished race: the level, who ran, where they placed, and the replay that +/// reproduces every runner. Lists are comma separated, in seat order; a bot's +/// username is empty. +@Entity +public class RaceRecord { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(32)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(160)") + private String level = ""; + + @Column(nullable = false, type = "VARCHAR(1200)") + private String players = ""; + + @Column(nullable = false, type = "VARCHAR(300)") + private String names = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String places = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String scores = ""; + + @Column(nullable = false, type = "VARCHAR(120)") + private String trophies = ""; + + @Column(nullable = false) + private int runners; + + @Column(nullable = false) + private int humans; + + @Column(nullable = false) + private int ticks; + + @Column(nullable = false) + private Instant endedAt = Instant.EPOCH; + + @Column(nullable = false, type = "VARCHAR(16)") + private String rulesVersion = ""; + + @Column(nullable = false) + private String replay = ""; + + public RaceRecord() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLevel() { + return level; + } + + public void setLevel(String level) { + this.level = level; + } + + public String getPlayers() { + return players; + } + + public void setPlayers(String players) { + this.players = players; + } + + public String getNames() { + return names; + } + + public void setNames(String names) { + this.names = names; + } + + public String getPlaces() { + return places; + } + + public void setPlaces(String places) { + this.places = places; + } + + public String getScores() { + return scores; + } + + public void setScores(String scores) { + this.scores = scores; + } + + public String getTrophies() { + return trophies; + } + + public void setTrophies(String trophies) { + this.trophies = trophies; + } + + public int getRunners() { + return runners; + } + + public void setRunners(int runners) { + this.runners = runners; + } + + public int getHumans() { + return humans; + } + + public void setHumans(int humans) { + this.humans = humans; + } + + public int getTicks() { + return ticks; + } + + public void setTicks(int ticks) { + this.ticks = ticks; + } + + public Instant getEndedAt() { + return endedAt; + } + + public void setEndedAt(Instant endedAt) { + this.endedAt = endedAt; + } + + public String getRulesVersion() { + return rulesVersion; + } + + public void setRulesVersion(String rulesVersion) { + this.rulesVersion = rulesVersion; + } + + public String getReplay() { + return replay; + } + + public void setReplay(String replay) { + this.replay = replay; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Referral.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Referral.java new file mode 100644 index 00000000000..ab80fcde2d8 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Referral.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player who joined through someone's invite, keyed by that player: one +/// inviter each, for good. +@Entity +public class Referral { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(190)") + private String invitee = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String inviter = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String campaign = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String channel = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String matchType = ""; + + @Column(nullable = false, type = "VARCHAR(10)") + private String state = ""; + + @Column(nullable = false) + private boolean rewarded; + + @Column(nullable = false) + private Instant joinedAt = Instant.EPOCH; + + @Column(nullable = false) + private Instant playedAt = Instant.EPOCH; + + public Referral() { + } + + public String getInvitee() { + return invitee; + } + + public void setInvitee(String invitee) { + this.invitee = invitee; + } + + public String getInviter() { + return inviter; + } + + public void setInviter(String inviter) { + this.inviter = inviter; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getCampaign() { + return campaign; + } + + public void setCampaign(String campaign) { + this.campaign = campaign; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public String getMatchType() { + return matchType; + } + + public void setMatchType(String matchType) { + this.matchType = matchType; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public boolean isRewarded() { + return rewarded; + } + + public void setRewarded(boolean rewarded) { + this.rewarded = rewarded; + } + + public Instant getJoinedAt() { + return joinedAt; + } + + public void setJoinedAt(Instant joinedAt) { + this.joinedAt = joinedAt; + } + + public Instant getPlayedAt() { + return playedAt; + } + + public void setPlayedAt(Instant playedAt) { + this.playedAt = playedAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/ReferralCode.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/ReferralCode.java new file mode 100644 index 00000000000..a47c594b385 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/ReferralCode.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A player's invite code: what their links carry, so the server knows whose friend +/// has joined. +@Entity +public class ReferralCode { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(16)") + private String code = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public ReferralCode() { + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Spotlight.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Spotlight.java new file mode 100644 index 00000000000..24a7bdd199d --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/Spotlight.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// The home screen's banner. One row, `home`. +@Entity +public class Spotlight { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(10)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(140)") + private String message = ""; + + @Column(nullable = false, type = "VARCHAR(40)") + private String packId = ""; + + @Column(nullable = false, type = "VARCHAR(20)") + private String screen = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String updatedBy = ""; + + @Column(nullable = false) + private Instant updatedAt = Instant.EPOCH; + + public Spotlight() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPackId() { + return packId; + } + + public void setPackId(String packId) { + this.packId = packId; + } + + public String getScreen() { + return screen; + } + + public void setScreen(String screen) { + this.screen = screen; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/StorePurchase.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/StorePurchase.java new file mode 100644 index 00000000000..1c67f82122d --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/domain/StorePurchase.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.domain; + +import com.codename1.annotations.Column; +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; +import java.time.Instant; + +/// A verified store transaction. The key is the store's transaction id, so a receipt can only ever be redeemed once. +@Entity +public class StorePurchase { + @Id(autoIncrement = false) + @Column(nullable = false, type = "VARCHAR(64)") + private String id = ""; + + @Column(nullable = false, type = "VARCHAR(190)") + private String username = ""; + + @Column(nullable = false, type = "VARCHAR(16)") + private String store = ""; + + @Column(nullable = false, type = "VARCHAR(64)") + private String sku = ""; + + @Column(nullable = false) + private int gems; + + @Column(nullable = false) + private Instant createdAt = Instant.EPOCH; + + public StorePurchase() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getStore() { + return store; + } + + public void setStore(String store) { + this.store = store; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public int getGems() { + return gems; + } + + public void setGems(int gems) { + this.gems = gems; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Arenas.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Arenas.java new file mode 100644 index 00000000000..986332c1cb4 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Arenas.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.Mode; + +/// A playlist reference: `arena:MODE`, such as `lot7:CAPTURE_THE_FLAG`. It is +/// what a client asks to play and what a match record stores as its level. +public final class Arenas { + public static final String DEFAULT = "lot7:TEAM_PAINT"; + + private Arenas() { + } + + /// The canonical form of `ref`, or null when it names no arena and mode. + /// A bare arena id plays Team Paint; null or empty plays the default. + public static String normalize(String ref) { + if (ref == null || ref.length() == 0) { + return DEFAULT; + } + int colon = ref.indexOf(':'); + String arena = colon < 0 ? ref : ref.substring(0, colon); + String mode = colon < 0 ? Mode.TEAM_PAINT.name() : ref.substring(colon + 1); + try { + Catalog.arena(arena); + return arena + ":" + Mode.byName(mode).name(); + } catch (IllegalArgumentException unknown) { + return null; + } + } + + public static String arena(String ref) { + return ref.substring(0, ref.indexOf(':')); + } + + public static Mode mode(String ref) { + return Mode.byName(ref.substring(ref.indexOf(':') + 1)); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Challenges.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Challenges.java new file mode 100644 index 00000000000..cca9b055bf6 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Challenges.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.api.ChallengeDto; +import com.codenameone.examples.splat.push.GamePushes; +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/// Private race links: "open this link and play me". +/// +/// The link opens the installed app straight into the race. Codes live in memory: a challenge is meant to be used in minutes, +/// and losing the open ones on a restart costs a new link, not data. +// tag::splat-challenge[] +@Component +public class Challenges { + public static final int TTL_SECONDS = 600; + + private final GamePushes pushes; + private final String publicUrl; + private final Map hosts = new HashMap(); + private final Map expiry = new HashMap(); + + public Challenges(GamePushes pushes, @Value("${splat.public-url:}") String publicUrl) { + this.pushes = pushes; + this.publicUrl = publicUrl; + } + + public synchronized ChallengeDto create(String host) throws IOException { + sweep(); + String code = Ids.hex(Crypto.randomBytes(5)); + hosts.put(code, host); + expiry.put(code, Long.valueOf(Clock.now() + TTL_SECONDS * 1000L)); + // With a hosted browser build (splat.public-url) the link opens the + // game in a browser; otherwise it opens the installed app through its + // splat: URL scheme. Either way the app reads the code after + // "challenge=". + String link = publicUrl.length() > 0 ? publicUrl + "/?challenge=" + code + : "splat://join?challenge=" + code; + return new ChallengeDto(code, link, TTL_SECONDS); + } + + synchronized String hostOf(String code) { + sweep(); + return code == null ? null : hosts.get(code); + } + + synchronized void used(String code) { + hosts.remove(code); + expiry.remove(code); + } + + /// The challenged player arrived before the host: tell the host by push. + void joinedWhileAway(String code, String host, String challenger) { + pushes.challengeAccepted(host, challenger, code); + } + + private void sweep() { + long now = Clock.now(); + for (Iterator> it = expiry.entrySet().iterator(); it.hasNext();) { + Map.Entry e = it.next(); + if (e.getValue().longValue() < now) { + hosts.remove(e.getKey()); + it.remove(); + } + } + } +} +// end::splat-challenge[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameEndpoint.java new file mode 100644 index 00000000000..4acdc419274 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameEndpoint.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codename1.appkit.server.Caller; +import com.codenameone.examples.splat.account.Lists; +import com.codenameone.examples.splat.admin.Flags; +import com.codenameone.examples.splat.api.ChallengeDto; +import com.codenameone.examples.splat.api.CrewCreateDto; +import com.codenameone.examples.splat.api.CrewDto; +import com.codenameone.examples.splat.api.FlagDto; +import com.codenameone.examples.splat.api.GameApiServer; +import com.codenameone.examples.splat.api.LeaderDto; +import com.codenameone.examples.splat.api.RaceSummaryDto; +import com.codenameone.examples.splat.api.ReplayDto; +import com.codenameone.examples.splat.api.ReportDto; +import com.codenameone.examples.splat.api.RunnerDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.RaceEntry; +import com.codenameone.examples.splat.domain.RaceRecord; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.RaceEntryRepository; +import com.codenameone.examples.splat.repository.RaceRecordRepository; +import com.codenameone.examples.splat.shop.Catalog; +import com.codenameone.examples.splat.social.Crews; +import java.util.ArrayList; +import java.util.List; + +@Component +public class GameEndpoint implements GameApiServer { + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final RaceEntryRepository entries; + private final Challenges challenges; + private final Flags flags; + private final Crews crews; + + public GameEndpoint(ProfileRepository profiles, RaceRecordRepository races, RaceEntryRepository entries, + Challenges challenges, Flags flags, Crews crews) { + this.profiles = profiles; + this.races = races; + this.entries = entries; + this.challenges = challenges; + this.flags = flags; + this.crews = crews; + } + + @Override + @Transactional(readOnly = true) + public RunnerDto runner() throws Exception { + return new RunnerDto(profile(Caller.name()).getRunner()); + } + + /// The runner is a look, never an advantage: every runner has the same + /// physics. Only one the player owns may be picked. + @Override + @Transactional + public RunnerDto saveRunner(RunnerDto runner) throws Exception { + String id = runner == null ? null : runner.runner(); + Profile p = profile(Caller.name()); + if (id == null || !Catalog.isRunner(id) || !Catalog.FREE_RUNNER.equals(id) + && !Lists.split(p.getOwned()).contains(id)) { + throw new ResponseStatusException(400, "You do not own that runner"); + } + p.setRunner(id); + profiles.save(p); + return new RunnerDto(id); + } + + @Override + @Transactional(readOnly = true) + public List history() throws Exception { + return summaries(entries.history(Caller.name(), new PageRequest(0, 30))); + } + + public static List summaries(List rows) { + List out = new ArrayList(); + for (RaceEntry e : rows) { + out.add(new RaceSummaryDto(e.getRaceId(), e.getLevel(), e.getPlace(), e.getRunners(), e.getScore(), + e.getTrophyChange(), e.getEndedAt().toEpochMilli(), e.getTicks())); + } + return out; + } + + /// A finished match. Anyone signed in may watch: replays are how players + /// learn, and they hold nothing private. + @Override + @Transactional(readOnly = true) + public ReplayDto replay(String id) throws Exception { + RaceRecord m = id == null ? null : races.findById(id); + if (m == null) { + throw new ResponseStatusException(404, "No such race"); + } + List points = new ArrayList(); + for (String v : Lists.split(m.getScores())) { + points.add(Integer.valueOf(Integer.parseInt(v))); + } + return new ReplayDto(m.getId(), m.getLevel(), Lists.split(m.getNames()), points, m.getReplay(), + m.getRulesVersion(), m.getEndedAt().toEpochMilli()); + } + + @Override + @Transactional(readOnly = true) + public List leaderboard(int limit) throws Exception { + int n = limit < 1 || limit > 100 ? 50 : limit; + List out = new ArrayList(); + int rank = 1; + for (Profile p : profiles.leaders(new PageRequest(0, n))) { + out.add(new LeaderDto(rank++, p.getDisplayName(), p.getTrophies(), p.getWins())); + } + return out; + } + + @Override + public ChallengeDto challenge() throws Exception { + return challenges.create(Caller.name()); + } + + @Override + public List crews() throws Exception { + return crews.of(Caller.name()); + } + + @Override + public CrewDto createCrew(CrewCreateDto crew) throws Exception { + return crews.create(Caller.name(), crew == null ? null : crew.name()); + } + + @Override + public CrewDto joinCrew(String code) throws Exception { + return crews.join(Caller.name(), code); + } + + @Override + public CrewDto leaveCrew(String code) throws Exception { + return crews.leave(Caller.name(), code); + } + + @Override + public FlagDto report(ReportDto report) throws Exception { + if (report == null) { + throw new ResponseStatusException(400, "Nothing to report"); + } + return flags.report(Caller.name(), report.raceId(), report.player(), report.reason()); + } + + private Profile profile(String username) { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return p; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameSocket.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameSocket.java new file mode 100644 index 00000000000..0d590b98ac6 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameSocket.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.Json; +import com.codename1.backend.WebSocket; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.WebSocketMapping; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.rules.Input; +import java.util.Map; + +/// The game socket. It is opened with a one-shot ticket from +/// `POST /api/game/ticket`, then carries [GameProtocol] messages. +// tag::splat-socket[] +@WebSocketMapping("/ws/game") +public class GameSocket implements WebSocket { + private final GameTickets tickets; + private final Accounts accounts; + private final Rooms rooms; + + public GameSocket(GameTickets tickets, Accounts accounts, Rooms rooms) { + this.tickets = tickets; + this.accounts = accounts; + this.rooms = rooms; + } + + @Override + public void onOpen(WebSocketSession session) throws Exception { + String username = tickets.redeem(ticket(session.getQuery())); + if (username == null || !accounts.isEnabled(username)) { + session.close(4401, "ticket"); + return; + } + session.setAttachment(username); + session.sendText(Messages.simple(GameProtocol.READY)); + rooms.connected(username, accounts.isAdmin(username), session); + } + + @Override + public void onText(WebSocketSession session, String message) throws Exception { + Object who = session.getAttachment(); + if (!(who instanceof String) || message == null || message.length() > GameProtocol.MAX_MESSAGE) { + return; + } + String username = (String) who; + Map m; + try { + m = Json.parseObject(message); + } catch (Exception bad) { + session.sendText(Messages.error("Not a message")); + return; + } + String type = string(m, "type"); + if (GameProtocol.PING.equals(type)) { + session.sendText(Messages.simple(GameProtocol.PONG)); + } else if (GameProtocol.INPUT.equals(type)) { + Input in = input(string(m, "i")); + if (in != null) { + rooms.input(username, number(m, "s"), in); + } + } else if (GameProtocol.VOICE.equals(type)) { + rooms.voice(username, string(m, "to"), string(m, "data")); + } else if (GameProtocol.MUTE.equals(type)) { + rooms.mute(username, Boolean.TRUE.equals(m.get("on"))); + } else if (GameProtocol.EMOTE.equals(type) || GameProtocol.CHAT.equals(type)) { + if (!rooms.maySay(username)) { + return; + } + Profile p = accounts.profile(username); + if (p.isMuted()) { + return; + } + if (GameProtocol.EMOTE.equals(type)) { + rooms.emote(username, p.getDisplayName(), string(m, "id")); + } else { + rooms.chat(username, p.getDisplayName(), string(m, "text")); + } + } else if (GameProtocol.QUICK.equals(type)) { + rooms.quick(accounts.profile(username), session, string(m, "level")); + } else if (GameProtocol.HOST.equals(type)) { + rooms.host(accounts.profile(username), session, string(m, "level"), string(m, "crew")); + } else if (GameProtocol.JOIN.equals(type)) { + rooms.join(accounts.profile(username), session, string(m, "code")); + } else if (GameProtocol.GO.equals(type)) { + rooms.go(username, string(m, "level")); + } else if (GameProtocol.CANCEL.equals(type)) { + rooms.cancel(username); + } else if (GameProtocol.LEAVE.equals(type)) { + rooms.leave(username); + } else if (GameProtocol.RESUME.equals(type)) { + rooms.connected(username, accounts.isAdmin(username), session); + } else if (GameProtocol.SPECTATE.equals(type)) { + if (!accounts.isAdmin(username) || !rooms.spectate(string(m, "match"), session)) { + session.sendText(Messages.error("No such match")); + } + } + } + + @Override + public void onBinary(WebSocketSession session, byte[] message, int offset, int length) throws Exception { + session.close(1003, "text only"); + } + + @Override + public void onError(WebSocketSession session, Exception error) { + System.err.println("[socket] error on " + session.getAttachment() + ": " + error); + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) { + Object who = session.getAttachment(); + if (who instanceof String) { + rooms.disconnected((String) who, session); + } + } + + /// Parses an input without trusting it: anything malformed is dropped. + static Input input(String s) { + if (s == null || s.length() > 64) { + return null; + } + java.util.List w = com.codenameone.examples.splat.rules.Text.words(s); + if (w.size() != 6) { + return null; + } + int[] v = new int[6]; + for (int i = 0; i < 6; i++) { + String p = w.get(i); + int start = p.startsWith("-") ? 1 : 0; + if (p.length() == start || p.length() > 9) { + return null; + } + for (int c = start; c < p.length(); c++) { + if (p.charAt(c) < '0' || p.charAt(c) > '9') { + return null; + } + } + v[i] = Integer.parseInt(p); + } + return new Input(v[0], v[1], v[2], v[3], v[4], v[5]); + } + + private static String string(Map m, String key) { + Object v = m.get(key); + return v instanceof String ? (String) v : null; + } + + private static int number(Map m, String key) { + Object v = m.get(key); + return v instanceof Number ? ((Number) v).intValue() : -1; + } + + static String ticket(String query) { + if (query == null) { + return null; + } + int at = 0; + while (at < query.length()) { + int end = query.indexOf('&', at); + if (end < 0) { + end = query.length(); + } + if (query.startsWith("ticket=", at)) { + return query.substring(at + 7, end); + } + at = end + 1; + } + return null; + } +} +// end::splat-socket[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameTickets.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameTickets.java new file mode 100644 index 00000000000..acca5bc7da0 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/GameTickets.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.Crypto; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.api.TicketDto; +import com.codenameone.examples.splat.domain.GameTicket; +import com.codenameone.examples.splat.repository.GameTicketRepository; + +import java.io.IOException; +import java.time.Instant; + +/// Tickets for the live channel. +/// +/// A WebSocket handshake is one request the app cannot attach its bearer token +/// to on every platform -- a browser's WebSocket takes no headers at all -- and a +/// token in the address would end up in every access log on the way. So the app +/// asks for a ticket over the authenticated API and presents that instead: it +/// is random, it names nothing, it is good for one connection, and it is stale +/// in half a minute. +@Component +public class GameTickets { + static final int TICKET_SECONDS = 30; + + private final GameTicketRepository tickets; + + public GameTickets(GameTicketRepository tickets) { + this.tickets = tickets; + } + + /// Not held against the account being closed, as the rows that last are. + /// A ticket issued in the instant an account is erased outlives it by at + /// most [#TICKET_SECONDS] plus one [#sweep], and opens nothing meanwhile: + /// `LiveEndpoint` checks the account again when the ticket is redeemed. + /// A row lock on every socket connect is not worth that minute. + @Transactional + public TicketDto issue(String username) throws IOException { + String ticket = Ids.hex(Crypto.randomBytes(24)); + GameTicket row = new GameTicket(); + row.setTicketHash(Ids.sha256(ticket)); + row.setUsername(username); + row.setExpiresAt(Instant.now().plusMillis(TICKET_SECONDS * 1000L)); + tickets.insert(row); + return new TicketDto(ticket, TICKET_SECONDS); + } + + /// The account a ticket was issued to, or null when it is unknown, used or + /// stale. Redeeming it uses it up: the row is deleted, and only the + /// connection whose delete removed it is let in, so a ticket presented twice + /// at the same moment still opens one connection. + @Transactional + public String redeem(String ticket) throws IOException { + if (ticket == null || ticket.length() == 0 || ticket.length() > 128) { + return null; + } + String hash = Ids.sha256(ticket); + GameTicket row = tickets.findById(hash); + if (row == null) { + return null; + } + String username = row.getUsername(); + Instant expiresAt = row.getExpiresAt(); + // One delete statement: the count it answers is what says this + // connection, and not another presenting the same ticket, used it. + int removed = tickets.deleteByTicketHash(hash); + if (removed != 1 || expiresAt.isBefore(Instant.now())) { + return null; + } + return username; + } + + /// Clears the tickets nobody came back for. + @Scheduled(fixedDelay = 60000, initialDelay = 60000) + @Transactional + public void sweep() throws IOException { + tickets.deleteByExpiresAtLessThan(Instant.now()); + } + + /// Forgets the tickets of an account that is being closed. + @Transactional + public void forget(String username) { + tickets.deleteByUsername(username); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/MatchRoom.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/MatchRoom.java new file mode 100644 index 00000000000..1a046389e78 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/MatchRoom.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.WebSocketSession; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.rules.Event; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Replay; +import com.codenameone.examples.splat.rules.Slot; +import com.codenameone.examples.splat.rules.Trig; +import java.util.ArrayList; +import java.util.List; + +/// One match in progress, and the server's authority over it. +/// +/// Unlike a lockstep game, nobody else's inputs ever reach a client: the server +/// steps the [Match] with whatever each player sent last and sends every player +/// a snapshot of the result every [GameProtocol#SNAP_EVERY] ticks, with the +/// sequence number of their own last input it used. The client restores the +/// snapshot and replays its inputs the server has not used yet, which is +/// prediction and reconciliation in a dozen lines, because both sides run the +/// same rules. Nothing here is thread safe; [Rooms] holds the lock. +// tag::splat-match-room[] +final class MatchRoom { + /// Inputs one seat may send in a second. A client sends one per tick. + static final int FLOOD_LIMIT = Match.TICK_HZ + 12; + /// A turn larger than this in one tick is a mouse flick at best; many of them + /// in a match is an aimbot. + static final int SNAP_TURN = Trig.FULL / 3; + + final String id; + final String level; + final Mode mode; + final Seat[] seats; + final Match match; + final Replay replay; + final long startedAt; + final List spectators = new ArrayList(); + /// The inputs each seat has sent and the server has not used yet, oldest + /// first, one per tick. A client simulates one tick per input it sends, + /// so two that arrive within one server tick must both be played, or the + /// client's prediction runs a tick ahead and a trigger pull in the first + /// is never fired. + static final int QUEUE = 8; + private final Input[][] queued; + private final int[][] queuedSeq; + private final int[] queueHead; + private final int[] queueCount; + /// Wipes each seat got away with, for the achievement nobody should be proud of. + private final int[] wipes; + /// Events since the last snapshot, already encoded. + private final StringBuilder events = new StringBuilder(); + + MatchRoom(String id, String level, String arena, Mode mode, long seed, Seat[] seats, long startedAt) { + this.id = id; + this.level = level; + this.mode = mode; + this.seats = seats; + List slots = new ArrayList(); + for (int i = 0; i < seats.length; i++) { + Seat s = seats[i]; + // Bots of mixed skill, so a bot-filled match has someone to laugh at. + int skill = s.isBot() ? 35 + 15 * (i % 4) : 0; + slots.add(new Slot(s.name, s.team, s.marker, skill)); + } + this.replay = new Replay(seed, arena, mode, slots); + this.match = replay.newMatch(); + this.startedAt = startedAt; + this.queued = new Input[seats.length][QUEUE]; + this.queuedSeq = new int[seats.length][QUEUE]; + this.queueHead = new int[seats.length]; + this.queueCount = new int[seats.length]; + this.wipes = new int[seats.length]; + } + + int seatOf(String username) { + for (int i = 0; i < seats.length; i++) { + if (seats[i].username.equals(username)) { + return i; + } + } + return -1; + } + + /// A player's input with its sequence number. Answers false when it is + /// dropped: a flood, an old or repeated sequence, or a seat that is not theirs. + boolean input(int seat, int seq, Input in) { + if (seat < 0 || match.over() || in == null) { + return false; + } + Seat s = seats[seat]; + if (seq <= s.lastSeq) { + return false; + } + int now = match.tick(); + int inWindow = 0; + int n = Math.min(s.recentCount, s.recent.length); + for (int i = 0; i < n; i++) { + if (now - s.recent[i] < Match.TICK_HZ) { + inWindow++; + } + } + if (inWindow >= FLOOD_LIMIT) { + s.floods++; + return false; + } + s.recent[s.recentCount % s.recent.length] = now; + s.recentCount++; + Input previous = queueCount[seat] > 0 + ? queued[seat][(queueHead[seat] + queueCount[seat] - 1) % QUEUE] : match.player(seat).input(); + if (Math.abs(Trig.delta(previous.yaw, in.yaw)) > SNAP_TURN) { + s.snaps++; + } + s.lastSeq = seq; + if (queueCount[seat] == QUEUE) { + // A client this far ahead of the server is snapped back by the + // next snapshot anyway; its oldest guess goes. + queueHead[seat] = (queueHead[seat] + 1) % QUEUE; + queueCount[seat]--; + } + int at = (queueHead[seat] + queueCount[seat]) % QUEUE; + queued[seat][at] = in; + queuedSeq[seat][at] = seq; + queueCount[seat]++; + return true; + } + + void step() { + int now = match.tick(); + Input[] frame = new Input[seats.length]; + for (int i = 0; i < seats.length; i++) { + if (queueCount[i] > 0) { + int h = queueHead[i]; + frame[i] = queued[i][h]; + // Only the input actually played is acknowledged. + seats[i].appliedSeq = queuedSeq[i][h]; + queued[i][h] = null; + queueHead[i] = (h + 1) % QUEUE; + queueCount[i]--; + } else if (seats[i].left) { + frame[i] = Input.IDLE; + } + } + replay.record(now, frame); + match.step(frame); + List happened = match.events(); + for (int i = 0; i < happened.size(); i++) { + Event e = happened.get(i); + if (e.kind == Event.WIPE_OK) { + wipes[e.a]++; + } + if (e.kind == Event.SHOT) { + // the snapshot carries the ball; sending every shot doubles the traffic + continue; + } + if (events.length() > 0) { + events.append(';'); + } + events.append(e.kind).append(',').append(e.tick).append(',').append(e.a).append(',').append(e.b) + .append(',').append(e.x).append(',').append(e.y).append(',').append(e.z); + } + } + + /// Takes the events gathered since the last snapshot. + String drainEvents() { + String out = events.toString(); + events.setLength(0); + return out; + } + + /// Over when the rules say so, or when no human is left on the field. + boolean isOver() { + if (match.over()) { + return true; + } + for (Seat s : seats) { + if (!s.isBot() && !s.left) { + return false; + } + } + return true; + } + + /// 0 for the winning team, 1 for the losers, and everyone 0 on a draw. + int[] places() { + int[] out = new int[seats.length]; + int w = match.winner(); + for (int i = 0; i < seats.length; i++) { + out[i] = w < 0 || seats[i].team == w ? 0 : 1; + } + return out; + } + + int[] wipes() { + return wipes.clone(); + } + + int humans() { + int h = 0; + for (Seat s : seats) { + if (!s.isBot()) { + h++; + } + } + return h; + } + + /// The seat with the most paints, for the admin's live list. + int leader() { + int best = 0; + for (int i = 1; i < seats.length; i++) { + if (match.player(i).paints() > match.player(best).paints()) { + best = i; + } + } + return best; + } +} +// end::splat-match-room[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Messages.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Messages.java new file mode 100644 index 00000000000..ddfebffb3fc --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Messages.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.Json; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.rules.RulesVersion; +import com.codenameone.examples.splat.rules.Slot; +import java.util.List; + +/// Builds the game socket's outgoing JSON. Strings go through [Json#write], so a +/// player name can never break a message. +final class Messages { + private Messages() { + } + + static String simple(String type) { + return "{\"type\":\"" + type + "\"}"; + } + + static String error(String message) { + return "{\"type\":\"" + GameProtocol.ERROR + "\",\"message\":" + Json.write(message) + "}"; + } + + /// `startIn` is how many milliseconds of countdown are left; `you` is -1 + /// for a spectator. `slots` are the roster in [Slot#encode] form: with the + /// seed, the field and the mode, everything a client needs to build the same + /// [com.codenameone.examples.splat.rules.Match]; `snap` is where it stands now. + static String start(MatchRoom room, int you, long startIn, List handles) { + String players = array(handles); + StringBuilder slots = new StringBuilder(); + for (int i = 0; i < room.seats.length; i++) { + if (i > 0) { + slots.append(','); + } + slots.append(Json.write(room.replay.slots().get(i).encode())); + } + return "{\"type\":\"" + GameProtocol.START + "\",\"match\":\"" + room.id + "\",\"level\":" + + Json.write(room.level) + ",\"seed\":\"" + room.match.seed() + "\",\"you\":" + you + + ",\"players\":[" + players + "],\"slots\":[" + slots + "],\"rules\":" + RulesVersion.ID + + ",\"startIn\":" + Math.max(0, startIn) + ",\"snap\":\"" + ints(room.match.snapshot()) + "\"}"; + } + + /// `ack` is the sequence number of the newest of the receiver's inputs the + /// state includes, -1 for a spectator; `e` the events since the last one. + static String snap(int tick, int ack, int[] state, String events) { + return "{\"type\":\"" + GameProtocol.SNAP + "\",\"t\":" + tick + ",\"ack\":" + ack + ",\"s\":\"" + + ints(state) + "\",\"e\":\"" + events + "\"}"; + } + + static String ints(int[] v) { + return join(v); + } + + /// `players` are the members' handles, in the order of `names`: what a + /// client addresses voice signaling to; `you` is the receiver's own. + static String party(String code, String url, String level, String host, String you, List names, + List players) { + return "{\"type\":\"" + GameProtocol.PARTY + "\",\"code\":" + Json.write(code) + ",\"url\":" + + Json.write(url) + ",\"level\":" + Json.write(level) + ",\"host\":" + Json.write(host) + + ",\"you\":" + Json.write(you) + + ",\"names\":[" + array(names) + "],\"players\":[" + array(players) + "]}"; + } + + private static String array(List items) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < items.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(Json.write(items.get(i))); + } + return sb.toString(); + } + + static String emote(String from, String id) { + return "{\"type\":\"" + GameProtocol.EMOTE + "\",\"from\":" + Json.write(from) + ",\"id\":" + Json.write(id) + "}"; + } + + static String chat(String from, String text) { + return "{\"type\":\"" + GameProtocol.CHAT + "\",\"from\":" + Json.write(from) + ",\"text\":" + Json.write(text) + "}"; + } + + /// WebRTC signaling, relayed untouched: `data` is an offer, an answer or + /// an ICE candidate, opaque to the server. + static String voice(String from, String data) { + return "{\"type\":\"" + GameProtocol.VOICE + "\",\"from\":" + Json.write(from) + ",\"data\":" + Json.write(data) + "}"; + } + + static String mute(String from, boolean on) { + return "{\"type\":\"" + GameProtocol.MUTE + "\",\"from\":" + Json.write(from) + ",\"on\":" + on + "}"; + } + + static String end(String matchId, int[] places, int[] scores, int trophies, int coins) { + return "{\"type\":\"" + GameProtocol.END + "\",\"match\":\"" + matchId + "\",\"places\":\"" + join(places) + + "\",\"scores\":\"" + join(scores) + "\",\"trophies\":" + trophies + ",\"coins\":" + coins + "}"; + } + + static String join(int[] v) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < v.length; i++) { + if (i > 0) { + sb.append(','); + } + sb.append(v[i]); + } + return sb.toString(); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Results.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Results.java new file mode 100644 index 00000000000..0cc070ddb95 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Results.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.RaceEntry; +import com.codenameone.examples.splat.domain.RaceRecord; +import com.codenameone.examples.splat.progress.Progression; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.RaceEntryRepository; +import com.codenameone.examples.splat.repository.RaceRecordRepository; +import com.codenameone.examples.splat.rules.Player; +import com.codenameone.examples.splat.rules.RulesVersion; +import com.codenameone.examples.splat.social.Referrals; +import java.io.IOException; +import java.time.Instant; + +/// Records a finished match: the replay, and what it did to each player. +// tag::splat-results[] +@Component +public class Results { + /// Trophies for a win, and lost for a loss; a draw moves nothing. + public static final int WIN_TROPHIES = 20; + public static final int LOSS_TROPHIES = 15; + /// Coins per paint, and the most a match can pay. + public static final int COINS_PER_PAINT = 3; + public static final int MAX_MATCH_COINS = 60; + + /// What one runner got out of a race. + public static final class Outcome { + public final int trophies; + public final int coins; + + public Outcome(int trophies, int coins) { + this.trophies = trophies; + this.coins = coins; + } + } + + private final RaceRecordRepository races; + private final RaceEntryRepository entries; + private final ProfileRepository profiles; + private final Progression progression; + private final Referrals referrals; + + public Results(RaceRecordRepository races, RaceEntryRepository entries, ProfileRepository profiles, + Progression progression, Referrals referrals) { + this.races = races; + this.entries = entries; + this.profiles = profiles; + this.progression = progression; + this.referrals = referrals; + } + + /// The trophy change for a player whose team won (`place` 0) or lost, with + /// `humans` players in the match. A draw is `place` 0 for everyone and + /// `draw` true. Against bots alone it is halved and never negative: losing to + /// the server should not cost a ranking. + public static int trophies(int place, boolean draw, int humans) { + int change = draw ? 0 : place == 0 ? WIN_TROPHIES : -LOSS_TROPHIES; + if (humans <= 1) { + change = Math.max(0, change / 2); + } + return change; + } + + @Transactional(rollbackFor = IOException.class) + Outcome[] record(MatchRoom room) throws IOException { + Outcome[] out = save(room); + // After every profile is saved: paying an inviter writes a profile, + // and that inviter may be another runner of this very race. + for (Seat s : room.seats) { + if (!s.isBot()) { + referrals.played(s.username); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + Outcome[] save(MatchRoom room) throws IOException { + int n = room.seats.length; + int humans = room.humans(); + int[] places = room.places(); + boolean draw = room.match.winner() < 0; + int[] wipes = room.wipes(); + Outcome[] out = new Outcome[n]; + Instant now = Instant.now(); + StringBuilder players = new StringBuilder(); + StringBuilder names = new StringBuilder(); + StringBuilder placeList = new StringBuilder(); + StringBuilder scoreList = new StringBuilder(); + StringBuilder trophyList = new StringBuilder(); + for (int i = 0; i < n; i++) { + Seat s = room.seats[i]; + Player r = room.match.player(i); + out[i] = new Outcome(0, 0); + if (!s.isBot()) { + Profile p = profiles.findById(s.username); + if (p != null) { + // A runner who walked out never wins, however well their + // team did without them: otherwise leaving while ahead is + // a free win. + int place = s.left ? Math.max(1, places[i]) : places[i]; + int t = trophies(place, draw, humans); + t = Math.max(t, -p.getTrophies()); + int coins = s.left ? 0 : Math.min(MAX_MATCH_COINS, r.paints() * COINS_PER_PAINT); + p.setTrophies(p.getTrophies() + t); + p.setCoins(p.getCoins() + coins); + if (draw) { + p.setDraws(p.getDraws() + 1); + } else if (place == 0) { + p.setWins(p.getWins() + 1); + } else { + p.setLosses(p.getLosses() + 1); + } + p.setLeftStreak(s.left ? p.getLeftStreak() + 1 : 0); + p.setLastSeen(now); + progression.afterMatch(p, !draw && place == 0, i == room.leader(), r.paints(), wipes[i]); + profiles.save(p); + out[i] = new Outcome(t, coins); + RaceEntry e = new RaceEntry(); + e.setId(room.id + "|" + s.username); + e.setRaceId(room.id); + e.setUsername(s.username); + e.setLevel(room.level); + e.setPlace(places[i]); + e.setRunners(n); + e.setScore(r.paints()); + e.setTrophyChange(t); + e.setTicks(room.match.tick()); + e.setEndedAt(now); + entries.insert(e); + } + } + String sep = i == 0 ? "" : ","; + players.append(sep).append(s.username); + names.append(sep).append(s.name.replace(',', ' ')); + placeList.append(sep).append(places[i]); + scoreList.append(sep).append(r.paints()); + trophyList.append(sep).append(out[i].trophies); + } + RaceRecord m = new RaceRecord(); + m.setId(room.id); + m.setLevel(room.level); + m.setPlayers(players.toString()); + m.setNames(names.toString()); + m.setPlaces(placeList.toString()); + m.setScores(scoreList.toString()); + m.setTrophies(trophyList.toString()); + m.setRunners(n); + m.setHumans(humans); + m.setTicks(room.match.tick()); + m.setEndedAt(now); + m.setRulesVersion(String.valueOf(RulesVersion.ID)); + m.setReplay(room.replay.encode()); + races.insert(m); + return out; + } +} +// end::splat-results[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Rooms.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Rooms.java new file mode 100644 index 00000000000..429d18794e2 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Rooms.java @@ -0,0 +1,834 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.Crypto; +import com.codename1.backend.WebSocketSession; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Value; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.admin.AdminChannel; +import com.codenameone.examples.splat.admin.Flags; +import com.codenameone.examples.splat.api.ChallengeDto; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.api.LiveRaceDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.mission.Missions; +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.social.Crews; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Parties, quick-match queues and every match in progress. +/// +/// All state is guarded by this object's monitor: socket callbacks and the +/// ticker run on different threads. Database work and the final messages of a +/// race happen outside the lock, in [#tick], so a slow database never stalls +/// the other races. +// tag::splat-rooms[] +@Component +public class Rooms { + /// The ticker runs every 50 ms and steps each race to the wall clock. + static final long TICKER_MILLIS = 50; + /// How many ticks one ticker run may catch up, so a long pause cannot stall + /// the server re-simulating minutes of race. + static final int MAX_CATCH_UP = 30; + /// A quick match is 4v4, bots filling the gaps. + public static final int QUICK_PLAYERS = 8; + /// The bots are the regulars every paintball field has. + static final String[] BOT_NAMES = {"Uncle Dave", "Brayden (11)", "Tactical Greg", "Sharon From Accounts", + "Coach Phil", "The Groom", "Best Man Kyle", "Steve Who Brought His Own Marker", "Grandpa Lou", + "Kevin (Disputes Everything)", "Mandy", "A Guy In Jeans"}; + + private final Results results; + private final Flags flags; + private final Challenges challenges; + private final AdminChannel admins; + private final Crews crews; + private final Missions missions; + private final long botAfterMillis; + + private final Map byId = new HashMap(); + private final Map byPlayer = new HashMap(); + /// Quick-race queues, one per level. + private final Map> queues = new HashMap>(); + /// Parties by challenge code, and the code each member is in. + private final Map parties = new HashMap(); + private final Map partyOf = new HashMap(); + /// Every socket an account has open, seated or not: a suspension closes + /// them all, including one idling on the home screen. + private final Map> open = new HashMap>(); + /// When each account last chatted or emoted; the server throttles what a + /// modified client would otherwise relay to every seat. + private final Map saidAt = new HashMap(); + static final long SAY_INTERVAL = 400; + private final Map handles = new HashMap(); + private final Map owners = new HashMap(); + private long handleCount; + + static final class Waiting { + final Profile profile; + WebSocketSession session; + final long since; + + Waiting(Profile profile, WebSocketSession session, long since) { + this.profile = profile; + this.session = session; + this.since = since; + } + } + + /// Friends gathered on one code before the host says go. Voice and chat + /// work in the lobby too, so the party can talk before it races. + static final class Party { + final String code; + final String url; + final String host; + String level; + final List members = new ArrayList(); + + Party(String code, String url, String host, String level) { + this.code = code; + this.url = url; + this.host = host; + this.level = level; + } + } + + public Rooms(Results results, Flags flags, Challenges challenges, AdminChannel admins, + Crews crews, Missions missions, @Value("${splat.race.bot-after-ms:6000}") long botAfterMillis) { + this.crews = crews; + this.missions = missions; + this.results = results; + this.flags = flags; + this.challenges = challenges; + this.admins = admins; + this.botAfterMillis = botAfterMillis; + } + + // ------------------------------------------------------------ connections + + synchronized void connected(String username, boolean admin, WebSocketSession session) { + List mine = open.get(username); + if (mine == null) { + mine = new ArrayList(); + open.put(username, mine); + } + if (!mine.contains(session)) { + mine.add(session); + } + if (admin) { + admins.add(username, session); + } + MatchRoom room = byPlayer.get(username); + // A seat its player left -- or was removed from by a suspension -- is + // not theirs to resume. + if (room != null && !room.seats[room.seatOf(username)].left) { + // Back from a drop: the new socket gets the whole log and catches up. + int seat = room.seatOf(username); + room.seats[seat].session = session; + // A relaunched app numbers its inputs from 1 again; the old count + // would drop every one of them until the new one passed it. + room.seats[seat].lastSeq = -1; + send(session, startMessage(room, seat, room.startedAt - Clock.now())); + return; + } + Party party = parties.get(partyOf.get(username)); + if (party != null) { + for (Waiting w : party.members) { + if (w.profile.getUsername().equals(username)) { + w.session = session; + } + } + send(session, partyMessage(party, username)); + } + } + + synchronized void disconnected(String username, WebSocketSession session) { + List mine = open.get(username); + if (mine != null) { + mine.remove(session); + if (mine.isEmpty()) { + open.remove(username); + saidAt.remove(username); + } + } + admins.remove(username, session); + removeQueued(username); + MatchRoom room = byPlayer.get(username); + if (room != null) { + int seat = room.seatOf(username); + System.err.println("[socket] seat " + seat + " of " + room.id + " dropped at tick " + room.match.tick() + + (room.seats[seat].session == session ? "" : " (an older socket)")); + if (room.seats[seat].session == session) { + room.seats[seat].session = null; + } + } + for (MatchRoom r : byId.values()) { + r.spectators.remove(session); + } + // A party member who drops keeps their place until the race starts or + // they leave; the socket is simply gone until they reconnect. + Party party = parties.get(partyOf.get(username)); + if (party != null) { + for (Waiting w : party.members) { + if (w.session == session) { + w.session = null; + } + } + } + } + + /// Closes everything `username` has open: used when an admin suspends or bans. + public void kick(String username) { + // Every socket the account has: a queued or partied one is not seated + // yet, and a suspended player must not keep any of them. + List sessions = new ArrayList(); + synchronized (this) { + for (List q : queues.values()) { + collect(q, username, sessions); + } + Party party = parties.get(partyOf.get(username)); + if (party != null) { + collect(party.members, username, sessions); + } + List mine = open.remove(username); + if (mine != null) { + for (WebSocketSession s : mine) { + if (!sessions.contains(s)) { + sessions.add(s); + } + } + } + removeQueued(username); + leaveParty(username); + MatchRoom room = byPlayer.get(username); + if (room != null) { + Seat s = room.seats[room.seatOf(username)]; + if (s.session != null && !sessions.contains(s.session)) { + sessions.add(s.session); + } + s.session = null; + s.left = true; + } + } + for (WebSocketSession session : sessions) { + send(session, Messages.simple(GameProtocol.KICKED)); + session.close(4403, "suspended"); + } + } + + private static void collect(List from, String username, List into) { + for (Waiting w : from) { + if (w.profile.getUsername().equals(username) && w.session != null && !into.contains(w.session)) { + into.add(w.session); + } + } + } + + // ------------------------------------------------------------ parties + + /// Opens a party on `level` and answers its share link. With a `crew` + /// code the host belongs to, the rest of that crew is pushed the invite. + void host(Profile player, WebSocketSession session, String level, String crew) throws IOException { + String ref = Arenas.normalize(missions.resolve(level)); + if (ref == null) { + send(session, Messages.error("No such field or mode")); + return; + } + ChallengeDto c = challenges.create(player.getUsername()); + synchronized (this) { + abandonMatch(player.getUsername()); + leaveParty(player.getUsername()); + removeQueued(player.getUsername()); + Party party = new Party(c.code(), c.url(), player.getUsername(), ref); + party.members.add(new Waiting(player, session, Clock.now())); + parties.put(party.code, party); + partyOf.put(player.getUsername(), party.code); + sendParty(party); + } + if (crew != null) { + crews.callToRace(player.getUsername(), player.getDisplayName(), crew, c.code()); + } + } + + synchronized void join(Profile player, WebSocketSession session, String code) { + Party party = code == null ? null : parties.get(code); + if (party == null || challenges.hostOf(code) == null) { + send(session, Messages.error("That party has closed")); + return; + } + abandonMatch(player.getUsername()); + if (code.equals(partyOf.get(player.getUsername()))) { + send(session, partyMessage(party, player.getUsername())); + return; + } + if (party.members.size() >= GameProtocol.MAX_PLAYERS) { + send(session, Messages.error("That party is full")); + return; + } + leaveParty(player.getUsername()); + removeQueued(player.getUsername()); + party.members.add(new Waiting(player, session, Clock.now())); + partyOf.put(player.getUsername(), code); + challenges.joinedWhileAway(code, party.host, player.getDisplayName()); + sendParty(party); + } + + /// The host starts the race. A party of one gets bots, so hosting alone + /// is still a race. + synchronized void go(String username, String level) { + Party party = parties.get(partyOf.get(username)); + if (party == null || !party.host.equals(username)) { + return; + } + if (level != null) { + String ref = Arenas.normalize(missions.resolve(level)); + if (ref != null) { + party.level = ref; + } + } + parties.remove(party.code); + for (Waiting w : party.members) { + partyOf.remove(w.profile.getUsername()); + } + challenges.used(party.code); + // Bots round every party up to even teams of at least four. + int n = Math.max(QUICK_PLAYERS, party.members.size() + (party.members.size() & 1)); + start(party.level, party.members, n); + } + + private Party leaveParty(String username) { + String code = partyOf.remove(username); + Party party = code == null ? null : parties.get(code); + if (party == null) { + return null; + } + for (Iterator it = party.members.iterator(); it.hasNext();) { + if (it.next().profile.getUsername().equals(username)) { + it.remove(); + } + } + if (party.members.isEmpty() || party.host.equals(username)) { + // The host leaving closes the party for everyone. + parties.remove(code); + for (Waiting w : party.members) { + partyOf.remove(w.profile.getUsername()); + send(w.session, Messages.error("The host closed the party")); + } + } else { + sendParty(party); + } + return party; + } + + /// Each member gets the roster with their own handle as `you`. + private void sendParty(Party party) { + for (Waiting w : party.members) { + send(w.session, partyMessage(party, w.profile.getUsername())); + } + } + + private String partyMessage(Party party, String forUsername) { + List names = new ArrayList(); + List players = new ArrayList(); + for (Waiting w : party.members) { + names.add(w.profile.getDisplayName()); + players.add(handle(w.profile.getUsername())); + } + return Messages.party(party.code, party.url, party.level, handle(party.host), handle(forUsername), names, + players); + } + + /// The name other clients know an account by. A username is an email + /// address, so it never goes to another runner: rosters, voice signaling + /// and mutes carry this opaque handle instead, and voice addressed to one + /// is resolved back here. Handles live as long as this process, so a + /// reconnecting runner keeps theirs. + String handle(String username) { + if (username == null) { + return ""; + } + String h = handles.get(username); + if (h == null) { + h = "p" + Long.toString(++handleCount, 36); + handles.put(username, h); + owners.put(h, username); + } + return h; + } + + private String startMessage(MatchRoom room, int you, long startIn) { + List players = new ArrayList(); + for (Seat s : room.seats) { + // A bot has no account: an empty handle, which a client knows + // not to call. + players.add(handle(s.username)); + } + return Messages.start(room, you, startIn, players); + } + + // ------------------------------------------------------------ quick races + + synchronized void quick(Profile player, WebSocketSession session, String level) { + String ref = Arenas.normalize(missions.resolve(level)); + if (ref == null) { + send(session, Messages.error("No such field or mode")); + return; + } + abandonMatch(player.getUsername()); + removeQueued(player.getUsername()); + leaveParty(player.getUsername()); + List q = queues.get(ref); + if (q == null) { + q = new ArrayList(); + queues.put(ref, q); + } + q.add(new Waiting(player, session, Clock.now())); + send(session, Messages.simple(GameProtocol.QUEUED)); + match(Clock.now()); + } + + synchronized void cancel(String username) { + removeQueued(username); + leaveParty(username); + } + + private void removeQueued(String username) { + for (List q : queues.values()) { + for (Iterator it = q.iterator(); it.hasNext();) { + if (it.next().profile.getUsername().equals(username)) { + it.remove(); + } + } + } + } + + /// Starts a queue's race once it is full, or once its oldest player has + /// waited [#botAfterMillis], with bots in the empty lanes. + private void match(long now) { + for (Iterator>> it = queues.entrySet().iterator(); it.hasNext();) { + Map.Entry> e = it.next(); + List q = e.getValue(); + if (q.isEmpty()) { + it.remove(); + continue; + } + if (q.size() >= QUICK_PLAYERS || now - q.get(0).since >= botAfterMillis) { + List runners = new ArrayList(); + while (!q.isEmpty() && runners.size() < QUICK_PLAYERS) { + runners.add(q.remove(0)); + } + start(e.getKey(), runners, QUICK_PLAYERS); + } + } + } + + private void start(String level, List humans, int players) { + String id; + long seed; + try { + id = Ids.next().substring(0, 24); + byte[] r = Crypto.randomBytes(8); + seed = 0; + for (int i = 0; i < 8; i++) { + seed = (seed << 8) | (r[i] & 0xff); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + int n = Math.max(humans.size(), Math.min(players, GameProtocol.MAX_PLAYERS)); + Seat[] seats = new Seat[n]; + int avg = 0; + for (int i = 0; i < humans.size(); i++) { + Profile p = humans.get(i).profile; + // Humans alternate teams, so a party of friends is split fairly and the + // bots that fill in are split the same way. + seats[i] = new Seat(p.getUsername(), p.getDisplayName(), marker(p.getRunner()), p.getTrophies(), i % 2, + humans.get(i).session); + avg += p.getTrophies(); + } + avg = humans.isEmpty() ? 0 : avg / humans.size(); + for (int i = humans.size(); i < n; i++) { + String name = BOT_NAMES[(int) (((seed >>> 3) + i) % BOT_NAMES.length)]; + String marker = Catalog.MARKERS[(int) (((seed >>> 7) + i) % Catalog.MARKERS.length)].id; + seats[i] = new Seat("", name, marker, avg, i % 2, null); + } + MatchRoom room = new MatchRoom(id, level, Arenas.arena(level), Arenas.mode(level), seed, seats, + Clock.now() + GameProtocol.COUNTDOWN_MILLIS); + missions.played(Arenas.arena(level)); + byId.put(id, room); + for (Seat s : seats) { + if (!s.isBot()) { + byPlayer.put(s.username, room); + } + } + for (int i = 0; i < n; i++) { + send(seats[i].session, startMessage(room, i, GameProtocol.COUNTDOWN_MILLIS)); + } + } + + /// The marker a profile carries, or the default when it names none. + static String marker(String id) { + for (int i = 0; i < Catalog.MARKERS.length; i++) { + if (Catalog.MARKERS[i].id.equals(id)) { + return id; + } + } + return "semi"; + } + + // ------------------------------------------------------------ in a match + + synchronized void input(String username, int seq, Input in) { + MatchRoom room = byPlayer.get(username); + // An input during the countdown is simply the first one used at tick 0. + if (room != null) { + room.input(room.seatOf(username), seq, in); + } + } + + /// Emotes, chat and voice signaling go to whoever shares a race or a party + /// with the sender. + /// Both carry the sender's display name, never the account name: that is + /// an email address, and other runners see it. + /// Whether `username` may chat or emote now, at most once per + /// `SAY_INTERVAL`; asked before the profile is read, so a flood costs + /// the server neither a database read nor a relay. + synchronized boolean maySay(String username) { + long now = Clock.now(); + Long last = saidAt.get(username); + if (last != null && now - last.longValue() < SAY_INTERVAL) { + return false; + } + saidAt.put(username, Long.valueOf(now)); + return true; + } + + synchronized void emote(String username, String name, String emote) { + if (emote == null || emote.length() > 32) { + return; + } + relay(username, Messages.emote(name, emote), false); + } + + synchronized void chat(String username, String name, String text) { + if (text == null || text.trim().length() == 0) { + return; + } + String t = text.length() > 200 ? text.substring(0, 200) : text; + relay(username, Messages.chat(name, t), false); + } + + /// One WebRTC signaling message to one peer. Only peers in the same race or + /// party can be reached, so the socket is no relay for strangers. + synchronized void voice(String username, String to, String data) { + if (to == null || data == null || data.length() > GameProtocol.MAX_MESSAGE) { + return; + } + for (WebSocketSession s : peers(username, to)) { + send(s, Messages.voice(handle(username), data)); + } + } + + synchronized void mute(String username, boolean on) { + relay(username, Messages.mute(handle(username), on), true); + } + + private void relay(String username, String msg, boolean includeSelf) { + MatchRoom room = byPlayer.get(username); + if (room != null) { + for (Seat s : room.seats) { + if (includeSelf || !username.equals(s.username)) { + send(s.session, msg); + } + } + return; + } + Party party = parties.get(partyOf.get(username)); + if (party != null) { + for (Waiting w : party.members) { + if (includeSelf || !w.profile.getUsername().equals(username)) { + send(w.session, msg); + } + } + } + } + + private List peers(String username, String toHandle) { + List out = new ArrayList(); + String to = owners.get(toHandle); + if (to == null) { + return out; + } + MatchRoom room = byPlayer.get(username); + if (room != null) { + int seat = room.seatOf(to); + if (seat >= 0 && room.seats[seat].session != null) { + out.add(room.seats[seat].session); + } + return out; + } + Party party = parties.get(partyOf.get(username)); + if (party != null && party.code.equals(partyOf.get(to))) { + for (Waiting w : party.members) { + if (w.profile.getUsername().equals(to) && w.session != null) { + out.add(w.session); + } + } + } + return out; + } + + /// A player still seated in a match who asks for a party or a new match + /// has walked away from the old one: reconnecting puts them back in their + /// seat, but an explicit request for something else must win, or it is + /// silently ignored until that match ends. + private void abandonMatch(String username) { + MatchRoom room = byPlayer.get(username); + if (room != null) { + Seat s = room.seats[room.seatOf(username)]; + s.left = true; + // The socket is about to carry another match or a party; the old + // room must stop streaming its snapshots and its end into it. + s.session = null; + byPlayer.remove(username); + } + } + + synchronized void leave(String username) { + abandonMatch(username); + leaveParty(username); + removeQueued(username); + } + + synchronized boolean spectate(String raceId, WebSocketSession session) { + MatchRoom room = byId.get(raceId); + if (room == null) { + return false; + } + if (!room.spectators.contains(session)) { + room.spectators.add(session); + } + send(session, startMessage(room, -1, room.startedAt - Clock.now())); + return true; + } + + public synchronized String partyCode(String username) { + String code = partyOf.get(username); + return code == null ? "" : code; + } + + public synchronized String raceOf(String username) { + MatchRoom room = byPlayer.get(username); + return room == null ? "" : room.id; + } + + public synchronized List live() { + List out = new ArrayList(); + for (MatchRoom r : byId.values()) { + List players = new ArrayList(); + List names = new ArrayList(); + for (Seat s : r.seats) { + players.add(s.username); + names.add(s.name); + } + out.add(new LiveRaceDto(r.id, r.level, players, names, r.match.tick(), r.seats[r.leader()].name, + r.seats.length - r.humans())); + } + return out; + } + + public synchronized int online() { + int waiting = 0; + for (List q : queues.values()) { + waiting += q.size(); + } + return byPlayer.size() + waiting + partyOf.size(); + } + + // ------------------------------------------------------------ the ticker + + @Scheduled(fixedRate = TICKER_MILLIS, initialDelay = TICKER_MILLIS) + public void ticker() { + tick(Clock.now()); + } + + /// Advances every race to where the wall clock says it should be, then + /// records the ones that ended. Tests call this with their own clock. + public void tick(long now) { + long began = System.currentTimeMillis(); + int stepped = 0; + List ended = new ArrayList(); + synchronized (this) { + match(now); + for (MatchRoom room : byId.values()) { + if (now < room.startedAt) { + continue; + } + long due = (now - room.startedAt) * Match.TICK_HZ / 1000; + int steps = 0; + while (!room.isOver() && room.match.tick() < due && steps < MAX_CATCH_UP) { + step(room); + steps++; + } + stepped += steps; + + if (room.isOver()) { + ended.add(room); + } + } + for (MatchRoom room : ended) { + detach(room); + } + } + for (MatchRoom room : ended) { + finish(room); + } + long took = System.currentTimeMillis() - began; + if (took > SLOW_TICK_MILLIS) { + // A tick slower than its period puts every match behind the wall + // clock, and every client runs out of prediction and freezes. + System.err.println("[race] a tick took " + took + " ms for " + stepped + " steps over " + byId.size() + + " matches"); + } + } + + static final long SLOW_TICK_MILLIS = 200; + + private void step(MatchRoom room) { + room.step(); + if (room.match.tick() % GameProtocol.SNAP_EVERY == 0 || room.isOver()) { + snap(room); + } + } + + /// Steps one room straight to the end without waiting for the clock; tests use + /// it to run a whole race in milliseconds. + public void fastForward(String raceId) { + MatchRoom room; + synchronized (this) { + room = byId.get(raceId); + if (room == null) { + return; + } + while (!room.isOver()) { + step(room); + } + // Taken out under the same lock, so this thread records it and a + // caller reading the history straight after this returns finds it. + detach(room); + } + finish(room); + } + + private void detach(MatchRoom room) { + byId.remove(room.id); + for (Seat s : room.seats) { + if (byPlayer.get(s.username) == room) { + byPlayer.remove(s.username); + } + } + } + + private void finish(MatchRoom room) { + Results.Outcome[] outcome; + try { + outcome = results.record(room); + } catch (IOException | RuntimeException failed) { + System.err.println("[race] could not record " + room.id + ": " + failed); + outcome = new Results.Outcome[room.seats.length]; + for (int i = 0; i < outcome.length; i++) { + outcome[i] = new Results.Outcome(0, 0); + } + } + int[] places = room.places(); + int[] scores = new int[room.seats.length]; + for (int i = 0; i < scores.length; i++) { + scores[i] = room.match.player(i).paints(); + } + for (int i = 0; i < room.seats.length; i++) { + Seat s = room.seats[i]; + send(s.session, Messages.end(room.id, places, scores, outcome[i].trophies, outcome[i].coins)); + if (!s.isBot() && s.floods >= 25) { + flags.auto(s.username, "Sent " + s.floods + " inputs over the rate limit in one match", room.id); + } + if (!s.isBot() && s.snaps >= 20) { + flags.auto(s.username, "Turned " + s.snaps + " times faster than a wrist can in one match", room.id); + } + } + String end = Messages.end(room.id, places, scores, 0, 0); + for (WebSocketSession s : room.spectators) { + send(s, end); + } + } + + /// Sends each player the state, with the last of their own inputs it used. + private void snap(MatchRoom room) { + int[] state = room.match.snapshot(); + String events = room.drainEvents(); + for (int i = 0; i < room.seats.length; i++) { + Seat s = room.seats[i]; + if (s.session != null) { + send(s.session, Messages.snap(room.match.tick(), s.appliedSeq, state, events)); + } + } + String watch = Messages.snap(room.match.tick(), -1, state, events); + for (WebSocketSession s : room.spectators) { + send(s, watch); + } + } + + private void broadcast(MatchRoom room, String msg) { + for (Seat s : room.seats) { + send(s.session, msg); + } + for (WebSocketSession s : room.spectators) { + send(s, msg); + } + } + + private void broadcast(Party party, String msg) { + for (Waiting w : party.members) { + send(w.session, msg); + } + } + + static void send(WebSocketSession session, String msg) { + try { + // A seat with no session is a headless runner: a bot, a dropped + // player, or a test driving the room directly. + if (session != null && session.isOpen()) { + session.sendText(msg); + } + } catch (IOException failed) { + // The socket is going away; its close callback cleans up. + System.err.println("[socket] a send failed: " + failed); + } + } +} +// end::splat-rooms[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Seat.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Seat.java new file mode 100644 index 00000000000..3645d1b8f50 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/game/Seat.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.WebSocketSession; + +/// One place in a [MatchRoom]: a player and the socket they are on now, which +/// changes when they reconnect, or one of the server's bots. +final class Seat { + final String username; + final String name; + final String marker; + final int trophies; + final int team; + WebSocketSession session; + /// Arrival ticks of recent inputs, for flood detection. + final int[] recent = new int[48]; + int recentCount; + int floods; + /// Inputs that turned further in one tick than a wrist can. + int snaps; + /// The newest input sequence number received, and the one the last step used. + int lastSeq = -1; + int appliedSeq = -1; + /// The player left; their paintballer stands there, bewildered, with no input. + boolean left; + + Seat(String username, String name, String marker, int trophies, int team, WebSocketSession session) { + this.username = username; + this.name = name; + this.marker = marker; + this.trophies = trophies; + this.team = team; + this.session = session; + } + + boolean isBot() { + return username.length() == 0; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/mission/MissionEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/mission/MissionEndpoint.java new file mode 100644 index 00000000000..dd4c69c98ff --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/mission/MissionEndpoint.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.mission; + +import com.codename1.appkit.server.Caller; +import com.codename1.backend.annotations.Component; +import com.codenameone.examples.splat.api.MissionApiServer; +import com.codenameone.examples.splat.api.MissionDto; +import java.util.List; + +/// `/api/missions`: signed-in players only, like the rest of `/api/**`. +@Component +public class MissionEndpoint implements MissionApiServer { + private final Missions missions; + + public MissionEndpoint(Missions missions) { + this.missions = missions; + } + + @Override + public MissionDto publishMission(MissionDto mission) throws Exception { + return missions.publish(Caller.name(), mission == null ? null : mission.json()); + } + + @Override + public List missions() throws Exception { + return missions.list(Caller.name()); + } + + @Override + public MissionDto mission(String code) throws Exception { + return missions.get(code); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/mission/Missions.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/mission/Missions.java new file mode 100644 index 00000000000..1f5db821c5a --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/mission/Missions.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.mission; + +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.splat.api.MissionDto; +import com.codenameone.examples.splat.domain.MissionRecord; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.Referral; +import com.codenameone.examples.splat.repository.MissionRecordRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.ReferralRepository; +import com.codenameone.examples.splat.rules.Mission; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// Player-built missions: storing them, listing them, and making one +/// playable online. The rules module resolves an arena id through +/// `Catalog.arena`, which knows a mission only once it has been +/// `register()`ed, so anything that takes a level from a player calls +/// [#resolve] before `Arenas.normalize` -- `Arenas` itself stays free of the +/// database. +/// +/// A player's friends here are the players they invited and the one who +/// invited them, the same relation `/api/social/friends` shows. +@Component +public class Missions { + public static final int MAX_BODY = 16 * 1024; + public static final int MAX_PER_OWNER = 50; + public static final int LIST_LIMIT = 50; + public static final int CODE_LENGTH = 8; + private static final int FRIEND_PAGE = 100; + private static final char[] ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); + + private final MissionRecordRepository missions; + private final ProfileRepository profiles; + private final ReferralRepository referrals; + + public Missions(MissionRecordRepository missions, ProfileRepository profiles, ReferralRepository referrals) { + this.missions = missions; + this.profiles = profiles; + this.referrals = referrals; + } + + @Transactional(rollbackFor = IOException.class) + public MissionDto publish(String username, String json) throws IOException { + if (json == null || json.length() == 0) { + throw new ResponseStatusException(400, "No mission was sent"); + } + if (json.length() > MAX_BODY) { + throw new ResponseStatusException(413, "A mission is at most " + MAX_BODY + " characters"); + } + Mission m; + try { + m = Mission.fromJson(json); + } catch (IllegalArgumentException bad) { + throw new ResponseStatusException(400, "That is not a mission"); + } + if (missions.byOwner(username, new PageRequest(0, MAX_PER_OWNER)).size() >= MAX_PER_OWNER) { + throw new ResponseStatusException(409, "You have published " + MAX_PER_OWNER + " missions already"); + } + // The client's code means nothing here: the server names every mission. + String code = freshCode(); + m.code = code; + String problem = m.problem(); + if (problem != null) { + throw new ResponseStatusException(400, problem); + } + MissionRecord r = new MissionRecord(); + r.setCode(code); + r.setOwner(username); + r.setName(m.name); + r.setMode(m.mode.name()); + r.setBody(m.toJson()); + r.setCreatedAt(Instant.now()); + missions.insert(r); + return view(r, true); + } + + /// The caller's own missions and their friends', newest first. + @Transactional(readOnly = true) + public List list(String username) throws IOException { + Set owners = new HashSet(); + owners.add(username); + for (Referral r : referrals.of(username, new PageRequest(0, FRIEND_PAGE))) { + owners.add(r.getInvitee()); + } + Referral invitedBy = referrals.findById(username); + if (invitedBy != null) { + owners.add(invitedBy.getInviter()); + } + List all = new ArrayList(); + for (String owner : owners) { + all.addAll(missions.byOwner(owner, new PageRequest(0, LIST_LIMIT))); + } + Collections.sort(all, new Comparator() { + @Override + public int compare(MissionRecord a, MissionRecord b) { + return b.getCreatedAt().compareTo(a.getCreatedAt()); + } + }); + List out = new ArrayList(); + for (int i = 0; i < all.size() && i < LIST_LIMIT; i++) { + out.add(view(all.get(i), false)); + } + return out; + } + + /// Any signed-in player may fetch a mission by its code: the code is the + /// share link, as a party code is. + @Transactional(readOnly = true) + public MissionDto get(String code) throws IOException { + MissionRecord r = code == null ? null : missions.findById(code); + if (r == null) { + throw new ResponseStatusException(404, "No mission has that code"); + } + return view(r, true); + } + + /// Makes a level reference playable: when its arena is a mission + /// (`m-`), loads it and registers it with the rules catalog. Answers + /// the reference unchanged, so callers pass the result straight on to + /// `Arenas.normalize`, which then accepts or rejects it as usual. An + /// unknown mission stays unregistered and normalizes to null. + @Transactional(readOnly = true) + public String resolve(String ref) { + if (ref == null || !ref.startsWith(Mission.PREFIX)) { + return ref; + } + int colon = ref.indexOf(':'); + String code = ref.substring(Mission.PREFIX.length(), colon < 0 ? ref.length() : colon); + if (code.length() == 0 || code.length() > 16) { + return ref; + } + MissionRecord r = missions.findById(code); + if (r != null) { + try { + Mission.fromJson(r.getBody()).register(); + } catch (IllegalArgumentException stale) { + // A stored body the rules no longer read: leave it unplayable. + return ref; + } + } + return ref; + } + + /// Counts a race started on a mission arena. Not transactional with the + /// race: a lost count costs nothing. + @Transactional(rollbackFor = IOException.class) + public void played(String arenaId) { + if (arenaId == null || !arenaId.startsWith(Mission.PREFIX)) { + return; + } + MissionRecord r = missions.findById(arenaId.substring(Mission.PREFIX.length())); + if (r != null) { + r.setPlays(r.getPlays() + 1); + missions.save(r); + } + } + + private String freshCode() throws IOException { + while (true) { + byte[] bytes = Crypto.randomBytes(CODE_LENGTH); + char[] out = new char[CODE_LENGTH]; + for (int i = 0; i < CODE_LENGTH; i++) { + out[i] = ALPHABET[(bytes[i] & 0xff) % ALPHABET.length]; + } + String code = new String(out); + if (missions.findById(code) == null) { + return code; + } + } + } + + private MissionDto view(MissionRecord r, boolean withBody) { + Profile p = profiles.findById(r.getOwner()); + return new MissionDto(r.getCode(), r.getName(), r.getMode(), r.getOwner(), + p == null ? r.getOwner() : p.getDisplayName(), r.getPlays(), r.getCreatedAt().toEpochMilli(), + withBody ? r.getBody() : ""); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/Goals.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/Goals.java new file mode 100644 index 00000000000..0046bcab1aa --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/Goals.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.progress; + +/// The quests and achievements, as data. A quest or an achievement counts one +/// of the [Progress][com.codenameone.examples.splat.domain.Progress] +/// statistics; adding one is a line here. +public final class Goals { + public static final int WINS = 0; + public static final int RACES = 1; + public static final int METRES = 2; + public static final int PICKUPS = 3; + public static final int PODIUMS = 4; + public static final int STREAK = 5; + public static final int TUTORIAL = 6; + public static final int LEVEL = 7; + + /// A goal: the statistic it counts, how many, and what reaching it pays. + public static final class Goal { + public final String id; + public final String title; + public final String detail; + public final int stat; + public final int goal; + public final int coins; + public final int gems; + public final int xp; + + Goal(String id, String title, String detail, int stat, int goal, int coins, int gems, int xp) { + this.id = id; + this.title = title; + this.detail = detail; + this.stat = stat; + this.goal = goal; + this.coins = coins; + this.gems = gems; + this.xp = xp; + } + } + + /// The daily pool. Each player gets three of these a day, picked by + /// [#questsFor]. + static final Goal[] QUESTS = { + new Goal("win-1", "Win a match", "", WINS, 1, 25, 0, 40), + new Goal("win-2", "Win 2 matches", "", WINS, 2, 45, 0, 70), + new Goal("play-3", "Play 3 matches", "", RACES, 3, 35, 0, 50), + new Goal("paints-15", "Paint 15 people", "", METRES, 15, 30, 0, 40), + new Goal("wipes-2", "Get away with 2 wipes", "", PICKUPS, 2, 35, 0, 45), + new Goal("mvp-1", "Be the top painter once", "", PODIUMS, 1, 50, 0, 70), + }; + + static final Goal[] ACHIEVEMENTS = { + new Goal("recruit", "Waiver Signed", "Finish the safety briefing", TUTORIAL, 1, 0, 10, 50), + new Goal("first-blood", "First Paint", "Win your first match", WINS, 1, 0, 10, 50), + new Goal("veteran", "Weekend Veteran", "Play 10 matches", RACES, 10, 0, 15, 80), + new Goal("conqueror", "Field Marshal", "Win 10 matches", WINS, 10, 0, 25, 120), + new Goal("medalist", "Most Valuable Painter", "Top painter in 10 matches", PODIUMS, 10, 0, 20, 100), + new Goal("marathon", "Abstract Expressionist", "Paint 500 people", METRES, 500, 0, 20, 100), + new Goal("magpie", "It Was A Bounce", "Get away with 25 wipes", PICKUPS, 25, 0, 20, 100), + new Goal("unstoppable", "Unbearable At Barbecues", "Win 3 matches in a row", STREAK, 3, 0, 25, 120), + new Goal("seasoned", "Seasoned", "Reach level 5", LEVEL, 5, 0, 30, 0), + }; + + private Goals() { + } + + /// Today's three quests for a player: the same all day, different between + /// players and days, and never two alike. + static Goal[] questsFor(String username, long day) { + int h = (username + ":" + day).hashCode(); + int first = (h & 0x7fffffff) % QUESTS.length; + int step = 1 + ((h >>> 8) & 0x7fffffff) % (QUESTS.length - 1); + Goal[] out = new Goal[3]; + for (int n = 0; n < 3; n++) { + int at = (first + n * step) % QUESTS.length; + while (taken(out, n, QUESTS[at])) { + at = (at + 1) % QUESTS.length; + } + out[n] = QUESTS[at]; + } + return out; + } + + private static boolean taken(Goal[] picked, int count, Goal g) { + for (int i = 0; i < count; i++) { + if (picked[i] == g) { + return true; + } + } + return false; + } + + static Goal quest(String id) { + for (Goal g : QUESTS) { + if (g.id.equals(id)) { + return g; + } + } + return null; + } + + /// Experience at which `level` starts: 0, 100, 300, 600, 1000... + public static int levelStart(int level) { + return 50 * level * (level - 1); + } + + public static int levelOf(int xp) { + int level = 1; + while (xp >= levelStart(level + 1)) { + level++; + } + return level; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/ProgressEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/ProgressEndpoint.java new file mode 100644 index 00000000000..45425fd5222 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/ProgressEndpoint.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.progress; + +import com.codename1.backend.annotations.Component; +import com.codename1.appkit.server.Caller; +import com.codenameone.examples.splat.api.ClaimDto; +import com.codenameone.examples.splat.api.ProgressApiServer; +import com.codenameone.examples.splat.api.ProgressDto; +import com.codenameone.examples.splat.api.RewardDto; +import com.codenameone.examples.splat.social.Referrals; + +@Component +public class ProgressEndpoint implements ProgressApiServer { + private final Progression progression; + private final Referrals referrals; + + public ProgressEndpoint(Progression progression, Referrals referrals) { + this.progression = progression; + this.referrals = referrals; + } + + @Override + public ProgressDto progress() throws Exception { + return progression.view(Caller.name()); + } + + @Override + public RewardDto claimQuest(ClaimDto quest) throws Exception { + return progression.claim(Caller.name(), quest == null ? null : quest.questId()); + } + + @Override + public RewardDto openChest() throws Exception { + return progression.openChest(Caller.name()); + } + + @Override + public RewardDto tutorialDone() throws Exception { + RewardDto reward = progression.tutorialDone(Caller.name()); + // The tutorial run is a race: an invited player who finished it + // has played, and their inviter is paid. + referrals.played(Caller.name()); + return reward; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/Progression.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/Progression.java new file mode 100644 index 00000000000..a8c40b51d39 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/progress/Progression.java @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.progress; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.api.AchievementDto; +import com.codenameone.examples.splat.api.ProgressDto; +import com.codenameone.examples.splat.api.QuestDto; +import com.codenameone.examples.splat.api.RewardDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.Progress; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.ProgressRepository; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/// Experience and levels, daily quests, chests and achievements: the reasons +/// to play one more race. Every reward is computed and granted here; the +/// client only shows what it is told. +// tag::splat-progression[] +@Component +public class Progression { + /// Chests wait in slots; a win with every slot full earns none. + public static final int CHEST_SLOTS = 4; + public static final int WIN_XP = 40; + public static final int PODIUM_XP = 25; + public static final int RACE_XP = 15; + /// A metre is worth a hundredth of an experience point, up to this. + public static final int METRE_XP_CAP = 30; + public static final int TUTORIAL_XP = 100; + public static final int TUTORIAL_COINS = 100; + public static final int LEVEL_UP_COINS = 60; + public static final int LEVEL_UP_GEMS = 5; + + private final ProgressRepository progress; + private final ProfileRepository profiles; + + public Progression(ProgressRepository progress, ProfileRepository profiles) { + this.progress = progress; + this.profiles = profiles; + } + + /// Counts a finished match for one player. Called from inside the + /// transaction that records the match, with the profile it is about to + /// save, so the coins and gems this grants are saved with it. The stored + /// counters keep their first names: `metres` counts paints, `pickups` the + /// wipes the referee missed, `podiums` matches as the top painter. + public RewardDto afterMatch(Profile p, boolean won, boolean mvp, int paints, int wipes) throws IOException { + Progress g = load(p.getUsername()); + int[] before = stats(g); + boolean podium = mvp; + g.setRaces(g.getRaces() + 1); + g.setMetres(g.getMetres() + paints); + g.setPickups(g.getPickups() + wipes); + g.setPodiums(g.getPodiums() + (podium ? 1 : 0)); + int chests = 0; + if (won) { + g.setWins(g.getWins() + 1); + g.setStreak(g.getStreak() + 1); + g.setBestStreak(Math.max(g.getBestStreak(), g.getStreak())); + if (g.getChests() < CHEST_SLOTS) { + g.setChests(g.getChests() + 1); + chests = 1; + } + } else { + g.setStreak(0); + } + int[] after = stats(g); + Goals.Goal[] quests = Goals.questsFor(p.getUsername(), g.getQuestDay()); + for (int i = 0; i < 3; i++) { + int gained = after[quests[i].stat] - before[quests[i].stat]; + setQuest(g, i, Math.min(quests[i].goal, quest(g, i) + gained)); + } + int xp = (won ? WIN_XP : podium ? PODIUM_XP : RACE_XP) + Math.min(METRE_XP_CAP, paints * 3); + Reward r = new Reward(won ? "Victory. Somebody call your mother." : podium ? "Top painter" + : "Match over. Nobody saw anything."); + r.chests = chests; + r.xp(g, p, xp); + unlock(g, p, r); + progress.save(g); + return r.dto(g); + } + + @Transactional(rollbackFor = IOException.class) + public ProgressDto view(String username) throws IOException { + Profile p = profile(username); + // Reading never creates the row: the first race's result, recorded + // on the race ticker's thread, may be inserting it at this moment, + // and a second insert of the same key fails (MySQL waits on the + // uncommitted row, then reports a duplicate). + boolean stored = progress.findById(username) != null; + Progress g = stored ? load(username) : fresh(username); + if (stored) { + progress.save(g); + } + return dto(g, p); + } + + @Transactional(rollbackFor = IOException.class) + public RewardDto claim(String username, String questId) throws IOException { + Profile p = profile(username); + Progress g = load(username); + Goals.Goal[] quests = Goals.questsFor(username, g.getQuestDay()); + int index = -1; + for (int i = 0; i < 3; i++) { + if (quests[i].id.equals(questId)) { + index = i; + } + } + if (index < 0) { + throw new ResponseStatusException(404, "That is not one of today's quests"); + } + if ((g.getClaimed() & (1 << index)) != 0) { + throw new ResponseStatusException(409, "Already claimed"); + } + Goals.Goal q = quests[index]; + if (quest(g, index) < q.goal) { + throw new ResponseStatusException(409, "Not finished yet"); + } + g.setClaimed(g.getClaimed() | (1 << index)); + Reward r = new Reward(q.title); + r.coins(p, q.coins); + r.xp(g, p, q.xp); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + return r.dto(g); + } + + /// Opens the oldest chest. What is inside grows with the level; every + /// third chest also holds gems. + @Transactional(rollbackFor = IOException.class) + public RewardDto openChest(String username) throws IOException { + Profile p = profile(username); + Progress g = load(username); + if (g.getChests() <= 0) { + throw new ResponseStatusException(409, "No chest to open"); + } + int level = Goals.levelOf(g.getXp()); + g.setChests(g.getChests() - 1); + g.setChestsOpened(g.getChestsOpened() + 1); + boolean rare = g.getChestsOpened() % 3 == 0; + Reward r = new Reward(rare ? "Gold Chest" : "Wooden Chest"); + r.coins(p, 40 + 15 * level + (rare ? 40 : 0)); + if (rare) { + r.gems(p, 4 + level); + } + r.xp(g, p, 20); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + return r.dto(g); + } + + /// The tutorial run pays once. + @Transactional(rollbackFor = IOException.class) + public RewardDto tutorialDone(String username) throws IOException { + Profile p = profile(username); + Progress g = load(username); + Reward r = new Reward("Training complete"); + if (!g.isTutorialDone()) { + g.setTutorialDone(true); + r.coins(p, TUTORIAL_COINS); + if (g.getChests() < CHEST_SLOTS) { + g.setChests(g.getChests() + 1); + r.chests = 1; + } + r.xp(g, p, TUTORIAL_XP); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + } + return r.dto(g); + } + + /// Pays for stars won in a level. The caller has already verified + /// the result and decided the amounts; this only books them, so experience + /// still levels the player up and reaches achievements as it does anywhere. + @Transactional(rollbackFor = IOException.class) + public RewardDto levelReward(String username, String title, int coins, int xp) throws IOException { + Profile p = profile(username); + Progress g = load(username); + Reward r = new Reward(title); + if (coins > 0 || xp > 0) { + r.coins(p, coins); + r.xp(g, p, xp); + unlock(g, p, r); + progress.save(g); + profiles.save(p); + } + return r.dto(g); + } + + /// Gives a demo account some history -- a few levels, two chests, a + /// finished quest waiting to be claimed -- so the development server's + /// home screen shows what a regular player sees, and the tutorial does not + /// start on every sign-in. + @Transactional(rollbackFor = IOException.class) + public void seedDemo(String username) throws IOException { + Progress g = load(username); + if (g.getRaces() > 0) { + return; + } + g.setTutorialDone(true); + g.setXp(420); + g.setChests(2); + g.setChestsOpened(1); + g.setRaces(7); + g.setWins(4); + g.setMetres(6200); + g.setPickups(140); + g.setPodiums(6); + g.setStreak(2); + g.setBestStreak(2); + Goals.Goal[] today = Goals.questsFor(username, g.getQuestDay()); + g.setQuest0(today[0].goal); + g.setQuest1(today[1].goal / 2); + // Unlocked for what the counters say, without paying for it: the + // profile here is a throwaway, never saved. + Profile scratch = new Profile(); + unlock(g, scratch, new Reward("")); + g.setXp(420); + progress.save(g); + } + + private Profile profile(String username) throws IOException { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such player"); + } + return p; + } + + /// The player's row, created on first use, with today's quests reset when + /// the day has turned. + private Progress load(String username) throws IOException { + Progress g = progress.findById(username); + long today = Clock.dayOf(Clock.now()); + if (g == null) { + g = fresh(username); + progress.insert(g); + } + if (g.getQuestDay() != today) { + g.setQuestDay(today); + g.setQuest0(0); + g.setQuest1(0); + g.setQuest2(0); + g.setClaimed(0); + } + return g; + } + + private static Progress fresh(String username) { + Progress g = new Progress(); + g.setUsername(username); + g.setQuestDay(Clock.dayOf(Clock.now())); + return g; + } + + private static int[] stats(Progress g) { + return new int[] {g.getWins(), g.getRaces(), g.getMetres(), g.getPickups(), g.getPodiums(), + g.getBestStreak(), g.isTutorialDone() ? 1 : 0, Goals.levelOf(g.getXp())}; + } + + private static int quest(Progress g, int i) { + return i == 0 ? g.getQuest0() : i == 1 ? g.getQuest1() : g.getQuest2(); + } + + private static void setQuest(Progress g, int i, int value) { + if (i == 0) { + g.setQuest0(value); + } else if (i == 1) { + g.setQuest1(value); + } else { + g.setQuest2(value); + } + } + + private static boolean unlocked(Progress g, String id) { + return ("," + g.getAchievements() + ",").indexOf("," + id + ",") >= 0; + } + + /// Unlocks every achievement now reached. Unlocking pays experience, which + /// can level up, which can reach the level achievement: hence the loop. + private static void unlock(Progress g, Profile p, Reward r) { + boolean more = true; + while (more) { + more = false; + int[] s = stats(g); + for (Goals.Goal a : Goals.ACHIEVEMENTS) { + if (!unlocked(g, a.id) && s[a.stat] >= a.goal) { + g.setAchievements(g.getAchievements().isEmpty() ? a.id : g.getAchievements() + "," + a.id); + r.unlocked.add(a.title); + r.gems(p, a.gems); + r.xp(g, p, a.xp); + more = true; + } + } + } + } + + private static ProgressDto dto(Progress g, Profile p) { + int level = Goals.levelOf(g.getXp()); + Goals.Goal[] today = Goals.questsFor(g.getUsername(), g.getQuestDay()); + List quests = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + Goals.Goal q = today[i]; + quests.add(new QuestDto(q.id, q.title, quest(g, i), q.goal, q.coins, q.xp, + (g.getClaimed() & (1 << i)) != 0)); + } + int[] s = stats(g); + List achievements = new ArrayList<>(); + for (Goals.Goal a : Goals.ACHIEVEMENTS) { + achievements.add(new AchievementDto(a.id, a.title, a.detail, Math.min(a.goal, s[a.stat]), a.goal, + unlocked(g, a.id))); + } + return new ProgressDto(level, g.getXp(), Goals.levelStart(level), Goals.levelStart(level + 1), + g.isTutorialDone(), g.getChests(), p.getCoins(), p.getGems(), g.getStreak(), g.getWins(), + quests, achievements); + } + + /// A reward being put together; granting through it keeps the popup and + /// the balances in step. + private static final class Reward { + final String title; + final List unlocked = new ArrayList<>(); + int coins; + int gems; + int xp; + int chests; + boolean levelUp; + + Reward(String title) { + this.title = title; + } + + void coins(Profile p, int n) { + p.setCoins(p.getCoins() + n); + coins += n; + } + + void gems(Profile p, int n) { + p.setGems(p.getGems() + n); + gems += n; + } + + /// Adds experience; each level gained pays coins, gems and a chest. + void xp(Progress g, Profile p, int n) { + int from = Goals.levelOf(g.getXp()); + g.setXp(g.getXp() + n); + xp += n; + int to = Goals.levelOf(g.getXp()); + for (int l = from; l < to; l++) { + levelUp = true; + coins(p, LEVEL_UP_COINS); + gems(p, LEVEL_UP_GEMS); + if (g.getChests() < CHEST_SLOTS) { + g.setChests(g.getChests() + 1); + chests++; + } + } + } + + RewardDto dto(Progress g) { + return new RewardDto(title, coins, gems, xp, chests, Goals.levelOf(g.getXp()), levelUp, unlocked); + } + } +} +// end::splat-progression[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/push/GamePushes.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/push/GamePushes.java new file mode 100644 index 00000000000..60f8ae8ab44 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/push/GamePushes.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.push; + +import com.codename1.appkit.server.push.PushSender; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.push.PushNotification; +import com.codename1.backend.push.PushTarget; +import java.util.ArrayList; +import java.util.List; + +/// The notifications the game sends. Each one brings a player back for a +/// reason they care about; none is sent for the sake of sending. +// tag::splat-pushes[] +@Component +public class GamePushes { + private final PushDevices devices; + private final PushSender sender; + + public GamePushes(PushDevices devices, PushSender sender) { + this.devices = devices; + this.sender = sender; + } + + /// Someone opened the host's challenge link while the host was not waiting. + public void challengeAccepted(String host, String challenger, String code) { + send(host, PushNotification.builder() + .title("Challenge accepted") + .body(challenger + " is waiting for you at the start line") + .collapseKey("challenge-" + code) + .ttlSeconds(600) + .data("challenge", code)); + } + + /// A friend's run went past the player's best on a level they both ran. + /// One per level at a time: a newer one replaces it in the tray. + public void scoreBeaten(String username, String friend, String level, int score) { + send(username, PushNotification.builder() + .title(friend + " beat your score") + .body(score + " points on " + level + ". Run it again?") + .collapseKey("beaten-" + level) + .ttlSeconds(86400) + .data("screen", "level:" + level)); + } + + /// A crewmate opened a party: the crew's race is about to start. + public void crewRacing(String username, String host, String crew, String code) { + send(username, PushNotification.builder() + .title(crew + " is racing") + .body(host + " opened a party. Jump in before the start") + .collapseKey("party-" + code) + .ttlSeconds(900) + .data("challenge", code)); + } + + /// The daily rewarded-ad allowance is back. + public void rewardsReady(String username) { + send(username, PushNotification.builder() + .title("Free chest ready") + .body("Your daily rewards are back. Come and claim them") + .collapseKey("rewards") + .data("screen", "shop")); + } + + /// An admin learns about a new flag without watching the console. + public void flagRaised(List admins, String username, String reason) { + for (String admin : admins) { + send(admin, PushNotification.builder() + .title("Player flagged") + .body(username + ": " + reason) + .collapseKey("flags") + .data("screen", "admin") + .data("player", username)); + } + } + + /// Someone joined through the player's invite. + public void friendJoined(String inviter, String friend) { + send(inviter, PushNotification.builder() + .title("Your friend joined") + .body(friend + " joined Splat through your invite") + .collapseKey("friends") + .data("screen", "friends")); + } + + /// That friend finished a race, and the inviter was paid for it. + public void friendPlayed(String inviter, String friend, int coins) { + send(inviter, PushNotification.builder() + .title("Invite reward") + .body(friend + " played their first race. " + coins + " coins are yours") + .collapseKey("friends") + .data("screen", "friends")); + } + + /// An operator's message to many players at once, through the same + /// Codename One push service as everything else. `screen` and `campaign` + /// ride in the data: the app opens that screen and reports the open to + /// Codename One analytics under the campaign. Answers how many of the + /// players had a device to send to. + public int broadcast(List usernames, String id, String title, String body, String screen, + String campaign) { + List targets = new ArrayList(); + int reached = 0; + for (String u : usernames) { + List mine = devices.targetsOf(u); + if (!mine.isEmpty()) { + reached++; + targets.addAll(mine); + } + } + if (!targets.isEmpty()) { + sender.send(targets, PushNotification.builder() + .id(id) + .title(title) + .body(body) + .collapseKey("broadcast") + .ttlSeconds(24 * 60 * 60) + .data("screen", screen) + .data("campaign", campaign) + .data("broadcast", id) + .build(), null); + } + return reached; + } + + private void send(String username, PushNotification.Builder message) { + List targets = devices.targets(username); + if (!targets.isEmpty()) { + sender.send(targets, message.build(), null); + } + } +} +// end::splat-pushes[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/push/PushDevices.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/push/PushDevices.java new file mode 100644 index 00000000000..56f1ea58be5 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/push/PushDevices.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.push; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.backend.push.PushTarget; +import com.codenameone.examples.splat.Ids; +import com.codename1.appkit.server.Text; +import com.codenameone.examples.splat.api.PushDeviceDto; +import com.codenameone.examples.splat.domain.PushDevice; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.PushDeviceRepository; + +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +// tag::splat-push-devices[] +/// The devices each account is sent notifications on. +/// +/// The app registers its token when somebody signs in and removes it when they +/// sign out. A token registered by a second account moves to it: the phone +/// belongs to whoever signed in on it last, and the one before is not to be +/// told about the new owner's deliveries. +@Component +public class PushDevices { + /// How many devices one account keeps. A new one past this replaces the + /// one heard from longest ago: a token the platform has replaced is never + /// removed by its app, and would otherwise be sent to for good. + static final int MAX_PER_ACCOUNT = 8; + + private final PushDeviceRepository devices; + private final ProfileRepository profiles; + + public PushDevices(PushDeviceRepository devices, ProfileRepository profiles) { + this.devices = devices; + this.profiles = profiles; + } + + @Transactional + public PushDeviceDto register(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + String id = key(target); + long now = System.currentTimeMillis(); + // Before anything is read, the account's profile row is held: a no-op + // update that answers 0 when the profile is gone and otherwise keeps + // the row locked until this commits. It settles two races. + // + // Against closing the account: Accounts.erase holds the same row + // first and deletes it before it commits, so a registration either + // runs first (and erase's cleanup then deletes the device with the + // rest) or waits, finds no profile and is refused. Without it, a + // registration authorized just before the closure could insert its + // device after erase removed the account's, leaving a device of an + // account that no longer exists and that nobody can unregister. + // + // Against itself: two devices registering at once for an account one + // short of the cap would both count the same rows in makeRoom(), both + // skip the eviction and both insert, one past the cap. Behind the hold + // the second counts after the first committed, and evicts. + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + // The device row itself is not locked. Two accounts registering one + // token at the same moment -- a registration of the account that + // signed out still in flight as the next one signs in -- can leave it + // with the earlier account. The app settles it: a registration that + // answers after its account signed out is sent again for the account + // signed in now, and that one, sent after the stale one committed, + // lands last (Notifications.signedIn). + PushDevice row = devices.findById(id); + if (row == null || !username.equals(row.getUsername())) { + // A device new to this account, whether its token is new or moves + // here from another account, takes a place under the cap. + makeRoom(username); + } + if (row == null) { + row = new PushDevice(); + row.setId(id); + row.setProvider(target.getProvider()); + row.setToken(target.getToken()); + fill(row, username, device, now); + devices.insert(row); + } else { + fill(row, username, device, now); + } + return new PushDeviceDto(target.getProvider(), target.getToken(), row.getPlatform()); + } + + /// Removes `username`'s devices heard from longest ago until one more fits + /// under [#MAX_PER_ACCOUNT]. + private void makeRoom(String username) { + List held = new ArrayList(devices.findByUsername(username)); + while (held.size() >= MAX_PER_ACCOUNT) { + PushDevice oldest = held.get(0); + for (PushDevice each : held) { + if (each.getUpdatedAt().isBefore(oldest.getUpdatedAt())) { + oldest = each; + } + } + devices.deleteById(oldest.getId()); + held.remove(oldest); + } + } + + /// Forgets a device, if it is this account's. Somebody else's is left alone. + @Transactional + public PushDeviceDto unregister(String username, PushDeviceDto device) throws IOException { + PushTarget target = target(device); + devices.deleteByIdAndUsername(key(target), username); + return new PushDeviceDto(target.getProvider(), target.getToken(), ""); + } + + /// Where to reach `username`: every device they registered. + @Transactional(readOnly = true) + public List targets(String username) { + return targetsOf(username); + } + + /// [#targets] for a caller already inside a transaction. + public List targetsOf(String username) { + List out = new ArrayList(); + if (username == null || username.length() == 0) { + return out; + } + for (PushDevice each : devices.findByUsername(username)) { + out.add(PushTarget.of(each.getProvider(), each.getToken())); + } + return out; + } + // end::splat-push-devices[] + + private static void fill(PushDevice row, String username, PushDeviceDto device, long now) { + row.setUsername(username); + row.setPlatform(Text.optional(device.platform(), 32, "the platform")); + row.setUpdatedAt(Instant.ofEpochMilli(now)); + } + + /// The device as a target of the push service, which also spells its + /// provider the one way: `gcm` and `fcm` are one device. + private static PushTarget target(PushDeviceDto device) { + if (device == null) { + throw new ResponseStatusException(400, "Tell us the device"); + } + String token = Text.required(device.token(), 1024, "the device's token"); + try { + return PushTarget.of(Text.required(device.provider(), 16, "the push provider"), token); + } catch (IllegalArgumentException err) { + throw new ResponseStatusException(400, err.getMessage()); + } + } + + private static String key(PushTarget target) throws IOException { + return Ids.sha256(target.getProvider() + ":" + target.getToken()); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/AdminActionRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/AdminActionRepository.java new file mode 100644 index 00000000000..4db8cc40dec --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/AdminActionRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.AdminAction; +import java.util.List; + +@Repository(entity = AdminAction.class, key = String.class) +public interface AdminActionRepository extends CrudRepository { + @Query("select a from AdminAction a order by a.createdAt desc, a.id asc") + List newest(PageRequest page); + + @Query("select a from AdminAction a where a.username = :u order by a.createdAt desc, a.id asc") + List of(@Param("u") String username, PageRequest page); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/BroadcastRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/BroadcastRepository.java new file mode 100644 index 00000000000..eaa642ad84e --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/BroadcastRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Broadcast; +import java.time.Instant; +import java.util.List; + +@Repository(entity = Broadcast.class, key = String.class) +public interface BroadcastRepository extends CrudRepository { + @Query("select b from Broadcast b order by b.sentAt desc, b.id asc") + List newest(PageRequest page); + + long countBySentAtGreaterThan(Instant since); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/CrewMemberRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/CrewMemberRepository.java new file mode 100644 index 00000000000..cb1ce03b167 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/CrewMemberRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.CrewMember; +import java.util.List; + +@Repository(entity = CrewMember.class, key = String.class) +public interface CrewMemberRepository extends CrudRepository { + @Query("select m from CrewMember m where m.username = :u order by m.joinedAt asc") + List crewsOf(@Param("u") String username, PageRequest page); + + @Query("select m from CrewMember m where m.code = :c order by m.joinedAt asc") + List members(@Param("c") String code, PageRequest page); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/CrewRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/CrewRepository.java new file mode 100644 index 00000000000..a5a4bccf925 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/CrewRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Crew; + +@Repository(entity = Crew.class, key = String.class) +public interface CrewRepository extends CrudRepository { +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/FlagRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/FlagRepository.java new file mode 100644 index 00000000000..93935cd1f37 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/FlagRepository.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Flag; +import java.util.List; + +@Repository(entity = Flag.class, key = String.class) +public interface FlagRepository extends CrudRepository { + @Query("select f from Flag f where f.pending = :pending order by f.createdAt desc, f.id asc") + List inState(@Param("pending") boolean pending, PageRequest page); + + @Query("select f from Flag f where f.username = :u order by f.createdAt desc, f.id asc") + List of(@Param("u") String username, PageRequest page); + + long countByPendingTrue(); + + long countByUsernameAndPendingTrue(String username); + + long countByReporterAndRaceId(String reporter, String raceId); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/GameTicketRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/GameTicketRepository.java new file mode 100644 index 00000000000..5cf21d5aec1 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/GameTicketRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.GameTicket; +import java.time.Instant; + +/// The tickets that open a live connection. Only the hash of a ticket is +/// stored, and it is the row's key. +@Repository(entity = GameTicket.class, key = String.class) +public interface GameTicketRepository extends CrudRepository { + /// Deletes a ticket and answers how many rows that removed: 1 for the one + /// caller that used it, 0 for anyone presenting it after. A statement and + /// not a read followed by a removal, because that count is the whole + /// point. + int deleteByTicketHash(String ticketHash); + + /// Deletes the tickets that went stale before `now`. + int deleteByExpiresAtLessThan(Instant now); + + int deleteByUsername(String username); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/MissionRecordRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/MissionRecordRepository.java new file mode 100644 index 00000000000..4adddd5f25b --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/MissionRecordRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.MissionRecord; +import java.util.List; + +@Repository(entity = MissionRecord.class, key = String.class) +public interface MissionRecordRepository extends CrudRepository { + @Query("select m from MissionRecord m where m.owner = :o order by m.createdAt desc") + List byOwner(@Param("o") String owner, PageRequest page); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PipelineHitRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PipelineHitRepository.java new file mode 100644 index 00000000000..5c2eaeb7fa0 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PipelineHitRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.PipelineHit; +import java.util.List; + +@Repository(entity = PipelineHit.class, key = String.class) +public interface PipelineHitRepository extends CrudRepository { + List findByPipeline(String pipeline); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PipelineRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PipelineRepository.java new file mode 100644 index 00000000000..d712561a8be --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PipelineRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Pipeline; +import java.util.List; + +@Repository(entity = Pipeline.class, key = String.class) +public interface PipelineRepository extends CrudRepository { + @Query("select p from Pipeline p order by p.name asc, p.id asc") + List byName(); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ProfileRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ProfileRepository.java new file mode 100644 index 00000000000..a6b5cbb0711 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ProfileRepository.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Modifying; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Profile; +import java.time.Instant; +import java.util.List; + +@Repository(entity = Profile.class, key = String.class) +public interface ProfileRepository extends CrudRepository { + /// Locks the row for the rest of the transaction; answers 0 when there is none. + @Modifying + @Query("update Profile p set p.createdAt = p.createdAt where p.username = :username") + int hold(@Param("username") String username); + + @Query("select p from Profile p where p.banned = false order by p.trophies desc, p.username asc") + List leaders(PageRequest page); + + @Query("select p from Profile p where p.username like :q or p.displayName like :q " + + "order by p.lastSeen desc, p.username asc") + List search(@Param("q") String like, PageRequest page); + + long countByFlaggedTrue(); + + @Query("select count(p) from Profile p where p.banned = true or p.suspendedUntil > :now") + Long suspended(@Param("now") Instant now); + + /// The players a broadcast reaches, a page at a time. Banned players are + /// never sent anything; `seenBefore` and `joinedAfter` narrow it to the + /// lapsed and the new (pass `Instant.ofEpochMilli(Long.MAX_VALUE)` and + /// `Instant.ofEpochMilli(Long.MIN_VALUE)` to leave them out). + @Query("select p from Profile p where p.banned = false and p.lastSeen < :seen and p.createdAt > :joined " + + "order by p.username asc") + List audience(@Param("seen") Instant seenBefore, @Param("joined") Instant joinedAfter, PageRequest page); + + @Query("select count(p) from Profile p where p.banned = false and p.lastSeen < :seen and p.createdAt > :joined") + Long audienceSize(@Param("seen") Instant seenBefore, @Param("joined") Instant joinedAfter); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ProgressRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ProgressRepository.java new file mode 100644 index 00000000000..be2f5dad997 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ProgressRepository.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Progress; + +/// Each account's levels, quests, chests and achievements. +@Repository(entity = Progress.class, key = String.class) +public interface ProgressRepository extends CrudRepository { +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PushDeviceRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PushDeviceRepository.java new file mode 100644 index 00000000000..8b987cdfd7e --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/PushDeviceRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.PushDevice; + +import java.util.List; + +/// The devices each account is sent notifications on. +@Repository(entity = PushDevice.class, key = String.class) +public interface PushDeviceRepository extends CrudRepository { + List findByUsername(String username); + + int deleteByUsername(String username); + + int deleteByIdAndUsername(String id, String username); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/RaceEntryRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/RaceEntryRepository.java new file mode 100644 index 00000000000..6b7666c2bdd --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/RaceEntryRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.RaceEntry; +import java.time.Instant; +import java.util.List; + +@Repository(entity = RaceEntry.class, key = String.class) +public interface RaceEntryRepository extends CrudRepository { + @Query("select e from RaceEntry e where e.username = :u order by e.endedAt desc, e.id asc") + List history(@Param("u") String username, PageRequest page); + + @Query("select count(e) from RaceEntry e where e.username = :u") + Long countFor(@Param("u") String username); + + @Query("select count(e) from RaceEntry e where e.username = :u and e.endedAt > :since") + Long countForSince(@Param("u") String username, @Param("since") Instant since); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/RaceRecordRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/RaceRecordRepository.java new file mode 100644 index 00000000000..34612ac8885 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/RaceRecordRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.RaceRecord; +import java.time.Instant; +import java.util.List; + +@Repository(entity = RaceRecord.class, key = String.class) +public interface RaceRecordRepository extends CrudRepository { + long countByEndedAtGreaterThan(Instant since); + + @Query("select r from RaceRecord r where r.endedAt >= :since") + List since(@Param("since") Instant since); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ReferralCodeRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ReferralCodeRepository.java new file mode 100644 index 00000000000..aa5df453980 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ReferralCodeRepository.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.ReferralCode; +import java.util.List; + +@Repository(entity = ReferralCode.class, key = String.class) +public interface ReferralCodeRepository extends CrudRepository { + List findByUsername(String username); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ReferralRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ReferralRepository.java new file mode 100644 index 00000000000..865d92d5c68 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/ReferralRepository.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.PageRequest; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Referral; +import java.util.List; + +@Repository(entity = Referral.class, key = String.class) +public interface ReferralRepository extends CrudRepository { + @Query("select r from Referral r where r.inviter = :u order by r.joinedAt desc, r.invitee asc") + List of(@Param("u") String inviter, PageRequest page); + + @Query("select r from Referral r order by r.joinedAt desc, r.invitee asc") + List newest(PageRequest page); + + long countByInviter(String inviter); + + long countByInviterAndRewardedTrue(String inviter); + + @Query("select count(r) from Referral r where r.state = :s") + Long inState(@Param("s") String state); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/SpotlightRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/SpotlightRepository.java new file mode 100644 index 00000000000..0216189cb81 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/SpotlightRepository.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.Spotlight; + +@Repository(entity = Spotlight.class, key = String.class) +public interface SpotlightRepository extends CrudRepository { +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/StorePurchaseRepository.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/StorePurchaseRepository.java new file mode 100644 index 00000000000..7232dfb3285 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/repository/StorePurchaseRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.repository; + +import com.codename1.orm.repository.CrudRepository; +import com.codename1.orm.repository.Param; +import com.codename1.orm.repository.Query; +import com.codename1.orm.repository.Repository; +import com.codenameone.examples.splat.domain.StorePurchase; +import java.time.Instant; +import java.util.List; + +@Repository(entity = StorePurchase.class, key = String.class) +public interface StorePurchaseRepository extends CrudRepository { + @Query("select s from StorePurchase s where s.username = :u order by s.createdAt desc, s.id asc") + List of(@Param("u") String username); + + @Query("select s from StorePurchase s where s.createdAt >= :since") + List since(@Param("since") Instant since); +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/Catalog.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/Catalog.java new file mode 100644 index 00000000000..98fa9a34086 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/Catalog.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +import com.codenameone.examples.splat.api.ShopItemDto; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// Everything the shop sells. The markers are side-grades, not upgrades -- each +/// trades rate of fire against accuracy and range in the rules' own balance, and +/// bot matches are played with all three -- so a purchase never decides a match. +/// The rest is paint colour and emotes. +/// +/// The `sku` of a real-money item must match the product id configured in App +/// Store Connect and the Play Console. +// tag::splat-catalog[] +public final class Catalog { + public static final String GEMS = "gems"; + public static final String OUTFIT = "outfit"; + public static final String RUNNER = "runner"; + public static final String EMOTES = "emotes"; + public static final String PASS = "pass"; + + public static final String DEFAULT_OUTFIT = "paint-classic"; + public static final String FREE_RUNNER = "semi"; + public static final String DEFAULT_EMOTES = "emotes-classic"; + public static final String GOLD_PASS = "gold-pass"; + + private static final List ITEMS; + + static { + List items = new ArrayList(); + items.add(new ShopItemDto("gems-80", "Pouch of 80 gems", GEMS, 0, 0, "splat.gems_80")); + items.add(new ShopItemDto("gems-500", "Crate of 500 gems", GEMS, 0, 0, "splat.gems_500")); + items.add(new ShopItemDto(GOLD_PASS, "Gold Pass: paint that is somehow gold", PASS, 0, 0, "splat.gold_pass")); + items.add(new ShopItemDto("pump", "The Purist (pump marker)", RUNNER, 500, 0, "")); + items.add(new ShopItemDto("ramp", "The Ramping Dad (full auto)", RUNNER, 0, 60, "")); + items.add(new ShopItemDto("paint-neon", "Neon paint, visible from space", OUTFIT, 400, 0, "")); + items.add(new ShopItemDto("paint-glitter", "Glitter paint (banned in 3 states)", OUTFIT, 0, 40, "")); + items.add(new ShopItemDto("emotes-taunt", "Dispute emotes: 'that was a bounce!'", EMOTES, 0, 30, "")); + ITEMS = Collections.unmodifiableList(items); + } + + private Catalog() { + } + + public static List items() { + return ITEMS; + } + + public static ShopItemDto item(String id) { + for (ShopItemDto i : ITEMS) { + if (i.id().equals(id)) { + return i; + } + } + return null; + } + + public static ShopItemDto bySku(String sku) { + for (ShopItemDto i : ITEMS) { + if (i.sku().length() > 0 && i.sku().equals(sku)) { + return i; + } + } + return null; + } + + /// Gems a gem pack grants. + public static int gems(ShopItemDto item) { + if ("gems-80".equals(item.id())) { + return 80; + } + if ("gems-500".equals(item.id())) { + return 500; + } + return 0; + } + + /// The paint an owned item unlocks, or null. + public static String outfitOf(String itemId) { + if (GOLD_PASS.equals(itemId)) { + return "paint-gold"; + } + return itemId.startsWith("paint-") ? itemId : null; + } + + /// Whether `id` names a marker: the free one, or one the shop sells. + public static boolean isRunner(String id) { + if (FREE_RUNNER.equals(id)) { + return true; + } + ShopItemDto item = item(id); + return item != null && RUNNER.equals(item.kind()); + } +} +// end::splat-catalog[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/MisconfiguredReceipts.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/MisconfiguredReceipts.java new file mode 100644 index 00000000000..5c7e454803d --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/MisconfiguredReceipts.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +import com.codename1.backend.ResponseStatusException; + +/// Stands in when the store settings are missing, so the server still starts and +/// says what is wrong instead of granting purchases it cannot check. +public class MisconfiguredReceipts implements ReceiptValidator { + private final String problem; + + public MisconfiguredReceipts(String problem) { + this.problem = problem; + System.err.println("[config] " + problem); + } + + @Override + public String verify(String store, String sku, String transactionId, String receipt) { + throw new ResponseStatusException(503, "Purchases are not available right now"); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/ReceiptValidator.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/ReceiptValidator.java new file mode 100644 index 00000000000..85935b32cc6 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/ReceiptValidator.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +import java.io.IOException; + +/// Asks a store whether a purchase is real. The server grants nothing on the +/// app's word: a modified client can claim any purchase it likes. +// tag::splat-receipt-validator[] +public interface ReceiptValidator { + /// The store's transaction id when `receipt` proves a purchase of `sku`, or + /// null when it does not. + String verify(String store, String sku, String transactionId, String receipt) throws IOException; +} +// end::splat-receipt-validator[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/Shop.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/Shop.java new file mode 100644 index 00000000000..6c8a12a0d55 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/Shop.java @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codename1.appkit.server.Text; +import com.codenameone.examples.splat.account.Lists; +import com.codenameone.examples.splat.api.ReceiptDto; +import com.codenameone.examples.splat.api.ShopItemDto; +import com.codenameone.examples.splat.api.WalletDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.StorePurchase; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.StorePurchaseRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// The wallet: coins (earned by winning and by rewarded ads), gems (bought) and +/// the cosmetic items they unlock. +// tag::splat-shop[] +@Component +public class Shop { + public static final int AD_REWARDS_PER_DAY = 3; + public static final int AD_REWARD_COINS = 25; + + private final ProfileRepository profiles; + private final StorePurchaseRepository purchases; + private final ReceiptValidator validator; + + public Shop(ProfileRepository profiles, StorePurchaseRepository purchases, ReceiptValidator validator) { + this.profiles = profiles; + this.purchases = purchases; + this.validator = validator; + } + + @Transactional(readOnly = true) + public WalletDto wallet(String username) { + return toDto(profile(username)); + } + + /// Spends coins or gems on a cosmetic. + @Transactional(rollbackFor = IOException.class) + public WalletDto buy(String username, String itemId) { + ShopItemDto item = Catalog.item(itemId); + if (item == null || item.sku().length() > 0) { + throw new ResponseStatusException(404, "That is not for sale for coins or gems"); + } + hold(username); + Profile p = profile(username); + if (Lists.contains(p.getOwned(), item.id())) { + throw new ResponseStatusException(409, "You already own it"); + } + if (p.getCoins() < item.priceCoins() || p.getGems() < item.priceGems()) { + throw new ResponseStatusException(402, "Not enough coins or gems"); + } + p.setCoins(p.getCoins() - item.priceCoins()); + p.setGems(p.getGems() - item.priceGems()); + grant(p, item.id()); + profiles.save(p); + return toDto(p); + } + + @Transactional(rollbackFor = IOException.class) + public WalletDto equip(String username, String outfit) { + Profile p = profile(username); + boolean owned = false; + for (String id : Lists.split(p.getOwned())) { + String unlocked = Catalog.outfitOf(id); + owned |= unlocked != null && unlocked.equals(outfit); + } + if (!owned) { + throw new ResponseStatusException(403, "You do not own that trail"); + } + p.setOutfit(outfit); + profiles.save(p); + return toDto(p); + } + + /// Grants a store purchase once its receipt checks out. The store's + /// transaction id is the row's key, so replaying a receipt grants nothing. + public WalletDto receipt(String username, ReceiptDto receipt) throws IOException { + if (receipt == null) { + throw new ResponseStatusException(400, "No receipt"); + } + String store = Text.oneOf(receipt.store(), new String[] {"itunes", "play", "simulator"}, "", + "the store"); + ShopItemDto item = Catalog.bySku(receipt.sku()); + if (item == null) { + throw new ResponseStatusException(404, "Unknown product"); + } + String transaction = validator.verify(store, receipt.sku(), + Text.optional(receipt.transactionId(), 190, "the transaction"), receipt.receipt()); + if (transaction == null) { + throw new ResponseStatusException(402, "The store did not confirm this purchase"); + } + return redeem(username, store, item, transaction); + } + + @Transactional(rollbackFor = IOException.class) + WalletDto redeem(String username, String store, ShopItemDto item, String transaction) throws IOException { + hold(username); + Profile p = profile(username); + String key = Ids.sha256(transaction); + StorePurchase seen = purchases.findById(key); + if (seen != null) { + if (!seen.getUsername().equals(username)) { + throw new ResponseStatusException(409, "That purchase belongs to another account"); + } + return toDto(p); + } + StorePurchase row = new StorePurchase(); + row.setId(key); + row.setUsername(username); + row.setStore(store); + row.setSku(item.sku()); + int gems = Catalog.gems(item); + row.setGems(gems); + row.setCreatedAt(Instant.now()); + purchases.insert(row); + if (gems > 0) { + p.setGems(p.getGems() + gems); + } else { + grant(p, item.id()); + } + profiles.save(p); + return toDto(p); + } + + /// The reward for a watched rewarded ad: a few coins, a few times a day. + /// + /// The cap is the guard here. In production, have the ad network call the + /// server directly (AdMob's server-side verification) and grant from that + /// callback, so not even a modified app can claim a reward it did not earn. + @Transactional(rollbackFor = IOException.class) + public WalletDto adReward(String username) { + hold(username); + Profile p = profile(username); + long today = Clock.dayOf(Clock.now()); + if (p.getAdDay() != today) { + p.setAdDay(today); + p.setAdCount(0); + } + if (p.getAdCount() >= AD_REWARDS_PER_DAY) { + throw new ResponseStatusException(429, "That is all the rewards for today"); + } + p.setAdCount(p.getAdCount() + 1); + p.setCoins(p.getCoins() + AD_REWARD_COINS); + profiles.save(p); + return toDto(p); + } + + @Transactional(readOnly = true) + public List purchasesOf(String username) { + List out = new ArrayList(); + for (StorePurchase s : purchases.of(username)) { + out.add(s.getStore() + " " + s.getSku() + " " + s.getCreatedAt()); + } + return out; + } + + private static void grant(Profile p, String itemId) { + if (!Lists.contains(p.getOwned(), itemId)) { + p.setOwned(p.getOwned().length() == 0 ? itemId : p.getOwned() + "," + itemId); + } + } + + private void hold(String username) { + if (profiles.hold(username) == 0) { + throw new ResponseStatusException(404, "No such account"); + } + } + + private Profile profile(String username) { + Profile p = profiles.findById(username); + if (p == null) { + throw new ResponseStatusException(404, "No such account"); + } + return p; + } + + static WalletDto toDto(Profile p) { + int left = p.getAdDay() == Clock.dayOf(Clock.now()) ? AD_REWARDS_PER_DAY - p.getAdCount() : AD_REWARDS_PER_DAY; + return new WalletDto(p.getCoins(), p.getGems(), Lists.split(p.getOwned()), p.getOutfit(), left); + } +} +// end::splat-shop[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/ShopEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/ShopEndpoint.java new file mode 100644 index 00000000000..69d38f600c1 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/ShopEndpoint.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +import com.codename1.backend.annotations.Component; +import com.codename1.appkit.server.Caller; +import com.codenameone.examples.splat.api.AdRewardDto; +import com.codenameone.examples.splat.api.BuyDto; +import com.codenameone.examples.splat.api.EquipDto; +import com.codenameone.examples.splat.api.ReceiptDto; +import com.codenameone.examples.splat.api.ShopApiServer; +import com.codenameone.examples.splat.api.ShopItemDto; +import com.codenameone.examples.splat.api.WalletDto; +import java.util.List; + +@Component +public class ShopEndpoint implements ShopApiServer { + private final Shop shop; + + public ShopEndpoint(Shop shop) { + this.shop = shop; + } + + @Override + public List items() throws Exception { + return Catalog.items(); + } + + @Override + public WalletDto wallet() throws Exception { + return shop.wallet(Caller.name()); + } + + @Override + public WalletDto buy(BuyDto item) throws Exception { + return shop.buy(Caller.name(), item == null ? null : item.itemId()); + } + + @Override + public WalletDto equip(EquipDto outfit) throws Exception { + return shop.equip(Caller.name(), outfit == null ? null : outfit.outfit()); + } + + @Override + public WalletDto receipt(ReceiptDto receipt) throws Exception { + return shop.receipt(Caller.name(), receipt); + } + + @Override + public WalletDto adReward(AdRewardDto reward) throws Exception { + return shop.adReward(Caller.name()); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/SimulatedReceipts.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/SimulatedReceipts.java new file mode 100644 index 00000000000..0f89d320e32 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/SimulatedReceipts.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +/// Development and tests: accepts the receipts the simulator's store produces, +/// which carry no signature to check. Refused outside development profiles. +public class SimulatedReceipts implements ReceiptValidator { + @Override + public String verify(String store, String sku, String transactionId, String receipt) { + if (transactionId == null || transactionId.length() == 0 || sku == null) { + return null; + } + return "sim:" + transactionId; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/StoreReceipts.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/StoreReceipts.java new file mode 100644 index 00000000000..2639141586a --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/shop/StoreReceipts.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.shop; + +import com.codename1.backend.Json; +import com.codename1.backend.Web; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/// Verifies receipts with the stores themselves. +/// +/// - Apple: the receipt is posted to `verifyReceipt` with the app's shared +/// secret; a sandbox receipt (status 21007) is retried against the sandbox. +/// Apple now recommends the App Store Server API for new code; this endpoint +/// still answers, and keeps the sample free of a signing-key setup. Swap the +/// method for a call to `/inApps/v1/transactions/{id}` when you outgrow it. +/// - Google Play: the purchase token is checked with the Play Developer API, +/// authorized by an OAuth access token for a service account with the +/// "View financial data" permission (`splat.iap.google.token`). Minting +/// that token from the service account key is left to your deployment. +// tag::splat-store-receipts[] +public class StoreReceipts implements ReceiptValidator { + static final String APPLE_PRODUCTION = "https://buy.itunes.apple.com/verifyReceipt"; + static final String APPLE_SANDBOX = "https://sandbox.itunes.apple.com/verifyReceipt"; + + private final String appleSecret; + private final String appleBundle; + private final String googlePackage; + private final String googleToken; + + public StoreReceipts(String appleSecret, String appleBundle, String googlePackage, String googleToken) { + this.appleSecret = appleSecret; + this.appleBundle = appleBundle; + this.googlePackage = googlePackage; + this.googleToken = googleToken; + } + + @Override + public String verify(String store, String sku, String transactionId, String receipt) throws IOException { + if ("itunes".equals(store)) { + return apple(sku, transactionId, receipt); + } + if ("play".equals(store)) { + return google(sku, receipt); + } + return null; + } + + private String apple(String sku, String transactionId, String receipt) throws IOException { + if (appleSecret.length() == 0) { + return null; + } + String body = "{\"receipt-data\":" + Json.write(receipt) + ",\"password\":" + Json.write(appleSecret) + + ",\"exclude-old-transactions\":true}"; + Map answer = Json.parseObject(Web.postJson(APPLE_PRODUCTION, body, null).getBodyAsString()); + if (answer.get("status") instanceof Number && ((Number) answer.get("status")).intValue() == 21007) { + answer = Json.parseObject(Web.postJson(APPLE_SANDBOX, body, null).getBodyAsString()); + } + if (!(answer.get("status") instanceof Number) || ((Number) answer.get("status")).intValue() != 0) { + return null; + } + Object r = answer.get("receipt"); + // A receipt is only good for the app it was issued to. + if (!(r instanceof Map) || !appleBundle.equals(((Map) r).get("bundle_id"))) { + return null; + } + Object inApp = r instanceof Map ? ((Map) r).get("in_app") : null; + if (!(inApp instanceof List)) { + return null; + } + for (Object each : (List) inApp) { + if (each instanceof Map) { + Map t = (Map) each; + if (sku.equals(t.get("product_id")) && transactionId != null + && transactionId.equals(t.get("transaction_id"))) { + return "itunes:" + transactionId; + } + } + } + return null; + } + + private String google(String sku, String purchaseToken) throws IOException { + if (googleToken.length() == 0 || googlePackage.length() == 0 || purchaseToken == null) { + return null; + } + // The token and the SKU come from the client: anything but the + // characters Play uses would change which resource the request reads. + if (!pathSafe(sku) || !pathSafe(purchaseToken)) { + return null; + } + String url = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/" + + googlePackage + "/purchases/products/" + sku + "/tokens/" + purchaseToken; + Web.Result answer = Web.getJson(url, googleToken); + if (!answer.isSuccess()) { + return null; + } + Map purchase = Json.parseObject(answer.getBodyAsString()); + Object state = purchase.get("purchaseState"); + Object order = purchase.get("orderId"); + if (!(state instanceof Number) || ((Number) state).intValue() != 0 || !(order instanceof String)) { + return null; + } + return "play:" + order; + } + + private static boolean pathSafe(String s) { + if (s == null || s.length() == 0 || s.length() > 512) { + return false; + } + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' + || c == '.' || c == '_' || c == '-')) { + return false; + } + } + return !s.contains(".."); + } +} +// end::splat-store-receipts[] diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Audience.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Audience.java new file mode 100644 index 00000000000..590d81f05a7 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Audience.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.ResponseStatusException; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.Referral; +import com.codenameone.examples.splat.domain.StorePurchase; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The player query the console's query tool and every engagement pipeline +/// share: a list of conditions such as `daysAway >= 7` and `purchases = 0`, +/// all of which a player must meet. +/// +/// The fields are what the game itself knows about a player -- their +/// account, races, purchases and invites -- never events tracked for +/// analytics, which live in Codename One analytics. +/// +/// It reads every account into memory, which is right for a sample and a +/// game of a few thousand players. A larger game turns the conditions into +/// a `where` clause instead; the list of fields stays the same. +// tag::splat-audience[] +public final class Audience { + /// Every field a condition may name. + public static final String[] FIELDS = {"daysAway", "daysSinceJoin", "trophies", "races", "wins", + "gems", "coins", "purchases", "friends"}; + public static final String[] OPS = {">=", "<=", "=", ">", "<", "!="}; + public static final int MAX_CONDITIONS = 6; + /// Every field is a count of something, never negative. + public static final int MAX_VALUE = 1000000; + + private final List conditions; + private final long now; + private final Map purchases; + private final Map friends; + + private Audience(List conditions, long now, Map purchases, + Map friends) { + this.conditions = conditions; + this.now = now; + this.purchases = purchases; + this.friends = friends; + } + + /// Checks the conditions and gathers what they need to be tested. + public static Audience of(List conditions, List sales, List invites) { + check(conditions); + Map paid = new HashMap<>(); + for (StorePurchase s : sales) { + add(paid, s.getUsername()); + } + Map brought = new HashMap<>(); + for (Referral r : invites) { + add(brought, r.getInviter()); + } + return new Audience(conditions, Clock.now(), paid, brought); + } + + /// Refuses an unknown field or operator, and more than [#MAX_CONDITIONS]. + public static void check(List conditions) { + if (conditions == null || conditions.size() > MAX_CONDITIONS) { + throw new ResponseStatusException(400, "Up to " + MAX_CONDITIONS + " conditions"); + } + for (ConditionDto c : conditions) { + if (c == null || indexOf(FIELDS, c.field()) < 0 || indexOf(OPS, c.op()) < 0 || c.value() < 0 + || c.value() > MAX_VALUE) { + throw new ResponseStatusException(400, "Unknown condition " + + (c == null ? "" : c.field() + " " + c.op())); + } + } + } + + /// True when the player meets every condition. Banned players never do. + public boolean matches(Profile p) { + if (p.isBanned()) { + return false; + } + for (ConditionDto c : conditions) { + if (!test(value(p, c.field()), c.op(), c.value())) { + return false; + } + } + return true; + } + + /// The players who match, in the order given. + public List filter(List all) { + List out = new ArrayList<>(); + for (Profile p : all) { + if (matches(p)) { + out.add(p); + } + } + return out; + } + + private int value(Profile p, String field) { + switch (field) { + case "daysAway": + return days(now - p.getLastSeen().toEpochMilli()); + case "daysSinceJoin": + return days(now - p.getCreatedAt().toEpochMilli()); + case "trophies": + return p.getTrophies(); + case "races": + return p.getWins() + p.getLosses() + p.getDraws(); + case "wins": + return p.getWins(); + case "gems": + return p.getGems(); + case "coins": + return p.getCoins(); + case "purchases": + return count(purchases, p.getUsername()); + default: + return count(friends, p.getUsername()); + } + } + + private static boolean test(int v, String op, int against) { + switch (op) { + case ">=": + return v >= against; + case "<=": + return v <= against; + case "=": + return v == against; + case ">": + return v > against; + case "<": + return v < against; + default: + return v != against; + } + } + // end::splat-audience[] + + /// Conditions as one line, `daysAway >= 7; purchases = 0`, for the database. + public static String encode(List conditions) { + StringBuilder sb = new StringBuilder(); + for (ConditionDto c : conditions) { + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(c.field()).append(' ').append(c.op()).append(' ').append(c.value()); + } + return sb.toString(); + } + + public static List decode(String line) { + List out = new ArrayList<>(); + if (line == null) { + return out; + } + // String.split is a JDK method the compiled backend does not have. + for (String part : words(line, ';')) { + List w = words(part, ' '); + if (w.size() == 3) { + out.add(new ConditionDto(w.get(0), w.get(1), Integer.parseInt(w.get(2)))); + } + } + return out; + } + + private static List words(String s, char sep) { + List out = new ArrayList<>(); + int start = 0; + for (int i = 0; i <= s.length(); i++) { + if (i == s.length() || s.charAt(i) == sep) { + String w = s.substring(start, i).trim(); + if (w.length() > 0) { + out.add(w); + } + start = i + 1; + } + } + return out; + } + + private static int days(long millis) { + return millis <= 0 ? 0 : (int) (millis / Clock.DAY); + } + + private static int count(Map m, String key) { + Integer n = m.get(key); + return n == null ? 0 : n; + } + + private static void add(Map m, String key) { + m.put(key, count(m, key) + 1); + } + + private static int indexOf(String[] all, String s) { + for (int i = 0; i < all.length; i++) { + if (all[i].equals(s)) { + return i; + } + } + return -1; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Crews.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Crews.java new file mode 100644 index 00000000000..6e0f466b385 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Crews.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.Crypto; +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.api.CrewDto; +import com.codenameone.examples.splat.domain.Crew; +import com.codenameone.examples.splat.domain.CrewMember; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.push.GamePushes; +import com.codenameone.examples.splat.repository.CrewMemberRepository; +import com.codenameone.examples.splat.repository.CrewRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Crews: small groups of friends with their own leaderboard. A crew is +/// joined by its code, which is also its share link. +@Component +public class Crews { + public static final int MAX_MEMBERS = 30; + public static final int MAX_CREWS = 5; + + private final CrewRepository crews; + private final CrewMemberRepository members; + private final ProfileRepository profiles; + private final GamePushes pushes; + + public Crews(CrewRepository crews, CrewMemberRepository members, ProfileRepository profiles, + GamePushes pushes) { + this.crews = crews; + this.members = members; + this.profiles = profiles; + this.pushes = pushes; + } + + /// The host opened a party for their crew: every other member hears about + /// it. Only a member may call their crew, so a crew code alone cannot be + /// used to push strangers. + @Transactional(readOnly = true) + public void callToRace(String host, String hostName, String code, String party) throws IOException { + Crew c = code == null ? null : crews.findById(code); + if (c == null || members.findById(code + "|" + host) == null) { + return; + } + for (CrewMember m : members.members(code, new PageRequest(0, MAX_MEMBERS))) { + if (!m.getUsername().equals(host)) { + pushes.crewRacing(m.getUsername(), hostName, c.getName(), party); + } + } + } + + @Transactional(readOnly = true) + public List of(String username) throws IOException { + List out = new ArrayList(); + for (CrewMember m : members.crewsOf(username, new PageRequest(0, MAX_CREWS))) { + Crew c = crews.findById(m.getCode()); + if (c != null) { + out.add(view(c)); + } + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public CrewDto create(String username, String name) throws IOException { + String n = name == null ? "" : name.trim(); + if (n.length() < 2 || n.length() > 40) { + throw new ResponseStatusException(400, "A crew name is 2 to 40 characters"); + } + if (members.crewsOf(username, new PageRequest(0, MAX_CREWS)).size() >= MAX_CREWS) { + throw new ResponseStatusException(409, "You are in " + MAX_CREWS + " crews already"); + } + Crew c = new Crew(); + c.setCode(Ids.hex(Crypto.randomBytes(4)).toUpperCase(java.util.Locale.ROOT)); + c.setName(n); + c.setOwner(username); + c.setCreatedAt(Instant.now()); + crews.insert(c); + return join(username, c.getCode()); + } + + @Transactional(rollbackFor = IOException.class) + public CrewDto join(String username, String code) throws IOException { + Crew c = code == null ? null : crews.findById(code); + if (c == null) { + throw new ResponseStatusException(404, "No crew has that code"); + } + String id = code + "|" + username; + if (members.findById(id) == null) { + if (c.getMembers() >= MAX_MEMBERS) { + throw new ResponseStatusException(409, "That crew is full"); + } + if (members.crewsOf(username, new PageRequest(0, MAX_CREWS)).size() >= MAX_CREWS) { + throw new ResponseStatusException(409, "You are in " + MAX_CREWS + " crews already"); + } + CrewMember m = new CrewMember(); + m.setId(id); + m.setCode(code); + m.setUsername(username); + m.setJoinedAt(Instant.now()); + members.insert(m); + // Counted, not incremented: two joins at once must not lose one. + c.setMembers(count(code)); + crews.save(c); + } + return view(c); + } + + @Transactional(rollbackFor = IOException.class) + public CrewDto leave(String username, String code) throws IOException { + Crew c = code == null ? null : crews.findById(code); + CrewMember m = c == null ? null : members.findById(code + "|" + username); + if (m == null) { + throw new ResponseStatusException(404, "You are not in that crew"); + } + members.deleteById(m.getId()); + c.setMembers(count(code)); + crews.save(c); + return view(c); + } + + private CrewDto view(Crew c) { + List usernames = new ArrayList(); + List names = new ArrayList(); + for (CrewMember m : members.members(c.getCode(), new PageRequest(0, MAX_MEMBERS))) { + Profile p = profiles.findById(m.getUsername()); + usernames.add(m.getUsername()); + names.add(p == null ? m.getUsername() : p.getDisplayName()); + } + return new CrewDto(c.getCode(), c.getName(), c.getOwner(), usernames, names); + } + + private int count(String code) { + return members.members(code, new PageRequest(0, MAX_MEMBERS + 8)).size(); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Insights.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Insights.java new file mode 100644 index 00000000000..c7ebbfa88bc --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Insights.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.api.NameCountDto; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.QueryDto; +import com.codenameone.examples.splat.api.QueryResultDto; +import com.codenameone.examples.splat.api.InsightsDto; +import com.codenameone.examples.splat.account.Lists; +import com.codenameone.examples.splat.domain.RaceRecord; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.Referral; +import com.codenameone.examples.splat.domain.StorePurchase; +import com.codenameone.examples.splat.repository.RaceRecordRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.ReferralRepository; +import com.codenameone.examples.splat.repository.StorePurchaseRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/// The console's reports and its query tool, read from the game's own +/// records: accounts, finished races, store purchases and invites. +/// +/// These are the numbers the game can answer about itself. What players do +/// inside the app -- screens, taps, sessions, invite shares, push opens -- is +/// reported by the app to Codename One analytics and read there. +// tag::splat-insights[] +@Component +public class Insights { + public static final int MAX_DAYS = 90; + /// How many matching players a query lists; the count covers them all. + public static final int LISTED = 50; + private static final int[] RETURNED_AFTER = {1, 7, 30}; + private static final int[] BANDS = {0, 100, 300, 600, 1000}; + + private final ProfileRepository profiles; + private final RaceRecordRepository races; + private final StorePurchaseRepository purchases; + private final ReferralRepository referrals; + private final Accounts accounts; + + public Insights(ProfileRepository profiles, RaceRecordRepository races, StorePurchaseRepository purchases, + ReferralRepository referrals, Accounts accounts) { + this.profiles = profiles; + this.races = races; + this.purchases = purchases; + this.referrals = referrals; + this.accounts = accounts; + } + + @Transactional(readOnly = true) + public InsightsDto report(int days) { + int n = days < 1 ? 1 : days > MAX_DAYS ? MAX_DAYS : days; + long now = Clock.now(); + long first = Clock.dayOf(now) - (n - 1) * Clock.DAY; + List all = profiles.findAll(); + int[] joined = new int[n]; + for (Profile p : all) { + bump(joined, first, p.getCreatedAt().toEpochMilli()); + } + int[] raced = new int[n]; + List> fought = new ArrayList<>(); + for (int i = 0; i < n; i++) { + fought.add(new HashSet()); + } + for (RaceRecord m : races.since(Instant.ofEpochMilli(first))) { + int d = dayIndex(first, m.getEndedAt().toEpochMilli(), n); + if (d >= 0) { + raced[d]++; + fought.get(d).addAll(Lists.split(m.getPlayers())); + } + } + Set known = new HashSet<>(); + for (Profile p : all) { + known.add(p.getUsername()); + } + int[] active = new int[n]; + for (int i = 0; i < n; i++) { + // Only players count, never a bot. + fought.get(i).retainAll(known); + active[i] = fought.get(i).size(); + } + int[] gems = new int[n]; + List sales = purchases.since(Instant.EPOCH); + Set paid = new HashSet<>(); + for (StorePurchase s : sales) { + int d = dayIndex(first, s.getCreatedAt().toEpochMilli(), n); + if (d >= 0) { + gems[d] += s.getGems(); + } + paid.add(s.getUsername()); + } + Set inviters = new HashSet<>(); + for (Referral r : referrals.findAll()) { + inviters.add(r.getInviter()); + } + int played = 0; + int won = 0; + for (Profile p : all) { + if (p.getWins() + p.getLosses() + p.getDraws() > 0) { + played++; + } + if (p.getWins() > 0) { + won++; + } + } + List funnel = new ArrayList<>(); + funnel.add(new NameCountDto("Joined", all.size())); + funnel.add(new NameCountDto("Played", played)); + funnel.add(new NameCountDto("Won", won)); + funnel.add(new NameCountDto("Paid", paid.size())); + funnel.add(new NameCountDto("Invited", inviters.size())); + return new InsightsDto(first, list(active), list(joined), list(raced), list(gems), funnel, + retention(all, now), bands(all)); + } + + /// Of the players who joined at least N days ago, the share seen N days + /// or more after joining. The server keeps when a player was last seen, + /// not every visit, so this is "came back at least once that late". + private static List retention(List all, long now) { + List out = new ArrayList<>(); + for (int after : RETURNED_AFTER) { + long span = after * Clock.DAY; + int cohort = 0; + int back = 0; + for (Profile p : all) { + if (p.getCreatedAt().toEpochMilli() <= now - span) { + cohort++; + if (p.getLastSeen().toEpochMilli() - p.getCreatedAt().toEpochMilli() >= span) { + back++; + } + } + } + out.add(new NameCountDto("Day " + after, cohort == 0 ? 0 : back * 100 / cohort)); + } + return out; + } + + private static List bands(List all) { + int[] counts = new int[BANDS.length]; + for (Profile p : all) { + int b = 0; + while (b + 1 < BANDS.length && p.getTrophies() >= BANDS[b + 1]) { + b++; + } + counts[b]++; + } + List out = new ArrayList<>(); + for (int b = 0; b < BANDS.length; b++) { + out.add(new NameCountDto(b + 1 < BANDS.length ? BANDS[b] + "-" + (BANDS[b + 1] - 1) : BANDS[b] + "+", + counts[b])); + } + return out; + } + + /// The players meeting every condition: how many, and the first + /// [#LISTED] of them, the longest away first. + @Transactional(readOnly = true) + public QueryResultDto query(QueryDto q) { + Audience audience = audience(q == null ? null : q.conditions()); + List found = audience.filter(profiles.findAll()); + Collections.sort(found, (a, b) -> a.getLastSeen().isBefore(b.getLastSeen()) ? -1 + : a.getLastSeen().isAfter(b.getLastSeen()) ? 1 : a.getUsername().compareTo(b.getUsername())); + List listed = new ArrayList<>(); + for (int i = 0; i < found.size() && i < LISTED; i++) { + listed.add(accounts.toDto(found.get(i))); + } + return new QueryResultDto(found.size(), listed); + } + + /// The audience for a list of conditions, with the purchases and invites + /// the conditions may ask about. + public Audience audience(List conditions) { + Audience.check(conditions); + return Audience.of(conditions, purchases.since(Instant.EPOCH), referrals.findAll()); + } + // end::splat-insights[] + + private static void bump(int[] series, long first, long at) { + int d = dayIndex(first, at, series.length); + if (d >= 0) { + series[d]++; + } + } + + private static int dayIndex(long first, long at, int n) { + if (at < first) { + return -1; + } + long d = (at - first) / Clock.DAY; + return d >= n ? -1 : (int) d; + } + + private static List list(int[] values) { + List out = new ArrayList<>(); + for (int v : values) { + out.add(v); + } + return out; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Pipelines.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Pipelines.java new file mode 100644 index 00000000000..217ea0d1d65 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Pipelines.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Scheduled; +import com.codename1.backend.annotations.Transactional; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.api.PipelineDto; +import com.codenameone.examples.splat.domain.AdminAction; +import com.codenameone.examples.splat.domain.Pipeline; +import com.codenameone.examples.splat.domain.PipelineHit; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.push.GamePushes; +import com.codenameone.examples.splat.repository.AdminActionRepository; +import com.codenameone.examples.splat.repository.PipelineHitRepository; +import com.codenameone.examples.splat.repository.PipelineRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Engagement pipelines: "a player who has not played for 7 days gets a +/// push that opens the shop", "a player who reached 300 trophies and never +/// bought anything gets 20 gems". +/// +/// A pipeline is a query ([Audience]) and an action. Once an hour the +/// server runs every enabled one: each matching player it has not acted on +/// within the pipeline's cool-down gets the action, and the time is kept in +/// `pipeline_hit`, so nobody is pushed twice for the same reason. Pushes go +/// through Codename One push with the campaign `pipeline-`, so an open +/// is attributed to the pipeline in Codename One analytics. +// tag::splat-pipelines[] +@Component +public class Pipelines { + public static final String PUSH = "push"; + public static final String GEMS = "gems"; + public static final int MAX_PIPELINES = 20; + public static final int MAX_GEMS = 500; + public static final long EVERY = 60L * 60 * 1000; + + private final PipelineRepository pipelines; + private final PipelineHitRepository hits; + private final ProfileRepository profiles; + private final Insights insights; + private final GamePushes pushes; + private final AdminActionRepository audit; + + public Pipelines(PipelineRepository pipelines, PipelineHitRepository hits, ProfileRepository profiles, + Insights insights, GamePushes pushes, AdminActionRepository audit) { + this.pipelines = pipelines; + this.hits = hits; + this.profiles = profiles; + this.insights = insights; + this.pushes = pushes; + this.audit = audit; + } + + // tag::splat-pipelines-schedule[] + /// Every enabled pipeline, once an hour, on one server at a time. + /// Transactional itself: a call to [#run] from here is a call on `this`, + /// which does not go through the transaction wrapper. + @Scheduled(fixedDelay = EVERY, initialDelay = 5L * 60 * 1000, lock = "splat-pipelines") + @Transactional(rollbackFor = IOException.class) + public void runAll() throws IOException { + for (Pipeline p : pipelines.byName()) { + if (p.isEnabled()) { + run(p.getId()); + } + } + } + // end::splat-pipelines-schedule[] + + /// Runs one pipeline now: answers it with how many players it reached + /// in total. + @Transactional(rollbackFor = IOException.class) + public PipelineDto run(String id) throws IOException { + Pipeline p = find(id); + long now = Clock.now(); + Audience audience = insights.audience(Audience.decode(p.getConditions())); + // One read of this pipeline's hits, by its index, not one per player. + Map last = new HashMap<>(); + for (PipelineHit h : hits.findByPipeline(p.getId())) { + last.put(h.getUsername(), h); + } + List due = new ArrayList<>(); + for (Profile player : audience.filter(profiles.findAll())) { + String key = p.getId() + ":" + player.getUsername(); + PipelineHit hit = last.get(player.getUsername()); + if (hit != null && hit.getHitAt().toEpochMilli() > now - p.getCooldownDays() * Clock.DAY) { + continue; + } + if (GEMS.equals(p.getAction())) { + player.setGems(player.getGems() + p.getAmount()); + profiles.save(player); + } + if (hit == null) { + hit = new PipelineHit(); + hit.setId(key); + hit.setPipeline(p.getId()); + hit.setUsername(player.getUsername()); + hit.setHitAt(Instant.ofEpochMilli(now)); + hits.insert(hit); + } else { + hit.setHitAt(Instant.ofEpochMilli(now)); + hits.save(hit); + } + due.add(player.getUsername()); + } + // "Reached" counts the players the pipeline acted on. A push goes to + // the devices a player registered; one without any still had their + // turn, and gets the gems, so they are not chosen again every hour. + if (!due.isEmpty()) { + pushes.broadcast(due, Ids.next(), p.getTitle(), p.getBody(), p.getScreen(), "pipeline-" + p.getName()); + } + p.setReached(p.getReached() + due.size()); + p.setLastRun(Instant.ofEpochMilli(now)); + pipelines.save(p); + return dto(p); + } + // end::splat-pipelines[] + + @Transactional(readOnly = true) + public List list() { + List out = new ArrayList<>(); + for (Pipeline p : pipelines.byName()) { + out.add(dto(p)); + } + return out; + } + + @Transactional(rollbackFor = IOException.class) + public PipelineDto save(String admin, PipelineDto d) throws IOException { + check(d); + boolean fresh = d.id() == null || d.id().length() == 0; + Pipeline p; + if (fresh) { + if (pipelines.count() >= MAX_PIPELINES) { + throw new ResponseStatusException(400, "At most " + MAX_PIPELINES + " pipelines"); + } + p = new Pipeline(); + p.setId(Ids.next()); + p.setCreatedBy(admin); + } else { + p = find(d.id()); + } + p.setName(d.name().trim()); + p.setEnabled(d.enabled()); + p.setConditions(Audience.encode(d.conditions())); + p.setAction(d.action()); + p.setTitle(d.title().trim()); + p.setBody(d.body().trim()); + p.setScreen(d.screen() == null ? "" : d.screen()); + p.setAmount(GEMS.equals(d.action()) ? d.amount() : 0); + p.setCooldownDays(d.cooldownDays()); + if (fresh) { + pipelines.insert(p); + } else { + pipelines.save(p); + } + record(admin, fresh ? "PIPELINE_NEW" : "PIPELINE_EDIT", p.getName() + ": " + p.getConditions()); + return dto(p); + } + + @Transactional(rollbackFor = IOException.class) + public PipelineDto delete(String admin, String id) throws IOException { + Pipeline p = find(id); + for (PipelineHit h : hits.findByPipeline(id)) { + hits.deleteById(h.getId()); + } + pipelines.deleteById(p.getId()); + record(admin, "PIPELINE_DELETE", p.getName()); + return dto(p); + } + + private Pipeline find(String id) { + Pipeline p = id == null ? null : pipelines.findById(id); + if (p == null) { + throw new ResponseStatusException(404, "No such pipeline"); + } + return p; + } + + private static void check(PipelineDto d) { + if (d == null || d.name() == null || d.name().trim().length() == 0 || d.name().trim().length() > 40) { + throw new ResponseStatusException(400, "A name of up to 40 characters"); + } + Audience.check(d.conditions()); + if (d.conditions().isEmpty()) { + throw new ResponseStatusException(400, "At least one condition: a pipeline for everyone is a broadcast"); + } + if (Audience.encode(d.conditions()).length() > 500) { + throw new ResponseStatusException(400, "The conditions are too long"); + } + if (!PUSH.equals(d.action()) && !GEMS.equals(d.action())) { + throw new ResponseStatusException(400, "The action is push or gems"); + } + if (GEMS.equals(d.action()) && (d.amount() < 1 || d.amount() > MAX_GEMS)) { + throw new ResponseStatusException(400, "Between 1 and " + MAX_GEMS + " gems"); + } + int title = d.title() == null ? 0 : d.title().trim().length(); + int body = d.body() == null ? 0 : d.body().trim().length(); + if (title == 0 || title > 60 || body == 0 || body > 200) { + throw new ResponseStatusException(400, "A title of up to 60 characters and a message of up to 200"); + } + if (d.cooldownDays() < 1 || d.cooldownDays() > 365) { + throw new ResponseStatusException(400, "The cool-down is between 1 and 365 days"); + } + boolean known = false; + for (String s : Promotion.SCREENS) { + known |= s.equals(d.screen() == null ? "" : d.screen()); + } + if (!known) { + throw new ResponseStatusException(400, "Unknown screen " + d.screen()); + } + } + + private static PipelineDto dto(Pipeline p) { + List c = Audience.decode(p.getConditions()); + return new PipelineDto(p.getId(), p.getName(), p.isEnabled(), c, p.getAction(), p.getTitle(), p.getBody(), + p.getScreen(), p.getAmount(), p.getCooldownDays(), p.getReached(), p.getLastRun().toEpochMilli()); + } + + private void record(String admin, String action, String reason) throws IOException { + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(admin); + a.setUsername("promote"); + a.setAction(action); + a.setReason(reason.length() > 255 ? reason.substring(0, 255) : reason); + a.setCreatedAt(Instant.now()); + audit.insert(a); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Promotion.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Promotion.java new file mode 100644 index 00000000000..9037a900c05 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Promotion.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.api.BroadcastDto; +import com.codenameone.examples.splat.api.InviterDto; +import com.codenameone.examples.splat.api.PromoteDto; +import com.codenameone.examples.splat.api.SpotlightDto; +import com.codenameone.examples.splat.domain.AdminAction; +import com.codenameone.examples.splat.domain.Broadcast; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.Referral; +import com.codenameone.examples.splat.domain.Spotlight; +import com.codenameone.examples.splat.push.GamePushes; +import com.codenameone.examples.splat.repository.AdminActionRepository; +import com.codenameone.examples.splat.repository.BroadcastRepository; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.ReferralRepository; +import com.codenameone.examples.splat.repository.SpotlightRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The operator's side of growing the game: who brought whom in, pushes to a +/// segment of players, and the banner on every home screen. +/// +/// Engagement itself -- daily players, retention, the invite funnel, revenue +/// per campaign -- is measured by Codename One analytics, from the events the +/// app reports, and read in the Codename One console. This class keeps only +/// what the game needs to act on: the invites it paid for, the pushes it sent +/// and the banner. Pushes go out through Codename One push ([GamePushes]), +/// with the campaign in their data so an open is attributed in analytics. +@Component +public class Promotion { + public static final String ALL = "all"; + public static final String LAPSED = "lapsed"; + public static final String NEW = "new"; + public static final String INVITED = "invited"; + /// What a broadcast or the banner may open in the app. + public static final String[] SCREENS = {"", "shop", "friends", "loadout", "play", "crews"}; + public static final int MAX_BROADCASTS_PER_DAY = 5; + public static final int MAX_AUDIENCE = 20000; + private static final String HOME = "home"; + private static final int PAGE = 500; + private static final int TOP_INVITERS = 5; + private static final int RECENT = 10; + + private final ProfileRepository profiles; + private final ReferralRepository referrals; + private final BroadcastRepository broadcasts; + private final SpotlightRepository spotlights; + private final AdminActionRepository audit; + private final GamePushes pushes; + + public Promotion(ProfileRepository profiles, ReferralRepository referrals, BroadcastRepository broadcasts, + SpotlightRepository spotlights, AdminActionRepository audit, GamePushes pushes) { + this.profiles = profiles; + this.referrals = referrals; + this.broadcasts = broadcasts; + this.spotlights = spotlights; + this.audit = audit; + this.pushes = pushes; + } + + @Transactional(readOnly = true) + public PromoteDto overview() throws IOException { + long now = Clock.now(); + Map by = new HashMap<>(); + for (Referral r : referrals.newest(new PageRequest(0, PAGE))) { + int[] n = by.get(r.getInviter()); + if (n == null) { + n = new int[2]; + by.put(r.getInviter(), n); + } + n[0]++; + if (Referrals.PLAYED.equals(r.getState())) { + n[1]++; + } + } + List top = new ArrayList<>(); + List names = new ArrayList<>(by.keySet()); + while (top.size() < TOP_INVITERS && !names.isEmpty()) { + String best = names.get(0); + for (String n : names) { + int[] a = by.get(n); + int[] b = by.get(best); + if (a[0] > b[0] || a[0] == b[0] && n.compareTo(best) < 0) { + best = n; + } + } + names.remove(best); + Profile p = profiles.findById(best); + top.add(new InviterDto(p == null ? best : p.getDisplayName(), by.get(best)[0], by.get(best)[1])); + } + List sent = new ArrayList<>(); + for (Broadcast b : broadcasts.newest(new PageRequest(0, RECENT))) { + sent.add(dto(b)); + } + return new PromoteDto((int) profiles.count(), + profiles.audienceSize(Instant.ofEpochMilli(Long.MAX_VALUE), Instant.ofEpochMilli(now - 7 * Clock.DAY)).intValue(), + (int) referrals.count(), referrals.inState(Referrals.PLAYED).intValue(), top, sent, spotlightOf()); + } + + /// The broadcast as it would go: checked, and with the number of players + /// it would reach in `targets`. + @Transactional(readOnly = true) + public BroadcastDto audience(BroadcastDto b) throws IOException { + check(b, false); + return with(b, audienceOf(b).size(), "", 0); + } + + // tag::splat-promotion[] + @Transactional(rollbackFor = IOException.class) + public BroadcastDto broadcast(String admin, BroadcastDto b) throws IOException { + check(b, true); + long now = Clock.now(); + if (broadcasts.countBySentAtGreaterThan(Instant.ofEpochMilli(now - Clock.DAY)) >= MAX_BROADCASTS_PER_DAY) { + throw new ResponseStatusException(429, "At most " + MAX_BROADCASTS_PER_DAY + + " broadcasts a day: players who are pushed too often switch notifications off"); + } + String id = Ids.next(); + String campaign = b.campaign() == null || b.campaign().trim().length() == 0 + ? "broadcast-" + id.substring(0, 8) : b.campaign().trim(); + int reached = pushes.broadcast(audienceOf(b), id, b.title().trim(), b.body().trim(), screen(b.screen()), + campaign); + Broadcast row = new Broadcast(); + row.setId(id); + row.setSegment(b.segment()); + row.setDays(b.days()); + row.setTitle(b.title().trim()); + row.setBody(b.body().trim()); + row.setScreen(screen(b.screen())); + row.setCampaign(campaign.length() > 40 ? campaign.substring(0, 40) : campaign); + row.setTargets(reached); + row.setSentBy(admin); + row.setSentAt(Instant.ofEpochMilli(now)); + broadcasts.insert(row); + record(admin, "BROADCAST", b.segment() + ": " + b.title().trim()); + return dto(row); + } + // end::splat-promotion[] + + @Transactional(readOnly = true) + public SpotlightDto spotlight() throws IOException { + return spotlightOf(); + } + + @Transactional(rollbackFor = IOException.class) + public SpotlightDto spotlight(String admin, SpotlightDto s) throws IOException { + String text = s == null || s.text() == null ? "" : s.text().trim(); + if (text.length() > 140) { + throw new ResponseStatusException(400, "The banner is at most 140 characters"); + } + Spotlight row = spotlights.findById(HOME); + boolean fresh = row == null; + if (fresh) { + row = new Spotlight(); + row.setId(HOME); + } + row.setMessage(text); + row.setPackId(s.packId() == null ? "" : s.packId().trim()); + row.setScreen(screen(s.screen())); + row.setUpdatedBy(admin); + row.setUpdatedAt(Instant.now()); + if (fresh) { + spotlights.insert(row); + } else { + spotlights.save(row); + } + record(admin, "SPOTLIGHT", text.length() == 0 ? "(cleared)" : text); + return spotlightOf(); + } + + private SpotlightDto spotlightOf() { + Spotlight s = spotlights.findById(HOME); + return s == null ? new SpotlightDto("", "", "", 0) + : new SpotlightDto(s.getMessage(), s.getPackId(), s.getScreen(), s.getUpdatedAt().toEpochMilli()); + } + + private List audienceOf(BroadcastDto b) { + List out = new ArrayList<>(); + if (INVITED.equals(b.segment())) { + for (int page = 0; out.size() < MAX_AUDIENCE; page++) { + List rows = referrals.newest(new PageRequest(page, PAGE)); + for (Referral r : rows) { + out.add(r.getInvitee()); + } + if (rows.size() < PAGE) { + break; + } + } + return out; + } + long now = Clock.now(); + Instant seen = Instant.ofEpochMilli(LAPSED.equals(b.segment()) ? now - b.days() * Clock.DAY : Long.MAX_VALUE); + Instant joined = Instant.ofEpochMilli(NEW.equals(b.segment()) ? now - b.days() * Clock.DAY : Long.MIN_VALUE); + for (int page = 0; out.size() < MAX_AUDIENCE; page++) { + List rows = profiles.audience(seen, joined, new PageRequest(page, PAGE)); + for (Profile p : rows) { + out.add(p.getUsername()); + } + if (rows.size() < PAGE) { + break; + } + } + return out; + } + + private static void check(BroadcastDto b, boolean sending) { + if (b == null || b.segment() == null) { + throw new ResponseStatusException(400, "Choose who to send to"); + } + String s = b.segment(); + if (!ALL.equals(s) && !LAPSED.equals(s) && !NEW.equals(s) && !INVITED.equals(s)) { + throw new ResponseStatusException(400, "Unknown segment " + s); + } + if ((LAPSED.equals(s) || NEW.equals(s)) && (b.days() < 1 || b.days() > 365)) { + throw new ResponseStatusException(400, "Days must be between 1 and 365"); + } + if (sending) { + int title = b.title() == null ? 0 : b.title().trim().length(); + int body = b.body() == null ? 0 : b.body().trim().length(); + if (title == 0 || title > 60 || body == 0 || body > 200) { + throw new ResponseStatusException(400, "A title of up to 60 characters and a message of up to 200"); + } + } + screen(b.screen()); + } + + private static String screen(String s) { + String v = s == null ? "" : s; + for (String ok : SCREENS) { + if (ok.equals(v)) { + return v; + } + } + throw new ResponseStatusException(400, "Unknown screen " + v); + } + + private static BroadcastDto with(BroadcastDto b, int targets, String by, long at) { + return new BroadcastDto(b.id(), b.segment(), b.days(), b.title(), b.body(), b.screen(), b.campaign(), + targets, by, at); + } + + private static BroadcastDto dto(Broadcast b) { + return new BroadcastDto(b.getId(), b.getSegment(), b.getDays(), b.getTitle(), b.getBody(), b.getScreen(), + b.getCampaign(), b.getTargets(), b.getSentBy(), b.getSentAt().toEpochMilli()); + } + + private void record(String admin, String action, String reason) throws IOException { + AdminAction a = new AdminAction(); + a.setId(Ids.next()); + a.setAdmin(admin); + a.setUsername("promote"); + a.setAction(action); + a.setReason(reason.length() > 255 ? reason.substring(0, 255) : reason); + a.setCreatedAt(Instant.now()); + audit.insert(a); + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Referrals.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Referrals.java new file mode 100644 index 00000000000..e258b65c345 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/Referrals.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.ResponseStatusException; +import com.codename1.backend.annotations.Component; +import com.codename1.backend.annotations.Transactional; +import com.codename1.orm.repository.PageRequest; +import com.codenameone.examples.splat.Clock; +import com.codenameone.examples.splat.Ids; +import com.codenameone.examples.splat.api.FriendDto; +import com.codenameone.examples.splat.api.InviteClaimDto; +import com.codenameone.examples.splat.api.InviteDto; +import com.codenameone.examples.splat.api.RewardDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.domain.Referral; +import com.codenameone.examples.splat.domain.ReferralCode; +import com.codenameone.examples.splat.progress.Progression; +import com.codenameone.examples.splat.push.GamePushes; +import com.codenameone.examples.splat.repository.ProfileRepository; +import com.codenameone.examples.splat.repository.ReferralCodeRepository; +import com.codenameone.examples.splat.repository.ReferralRepository; +import java.io.IOException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/// Invite a friend, from the server's side. +/// +/// The link is not made here. The app mints it with Codename One's +/// `Invites`, which carries the player's code in the link's payload, follows +/// it through the store to the friend's first launch, and reports the +/// campaign and the referrer to Codename One analytics with every event the +/// friend sends after. What the server adds is the economy: whose code it was, +/// so that the friend gets a welcome gift on joining and the inviter is paid +/// once the friend has played a race -- not for an install that never +/// plays. +/// +/// The rules are what keep it from being farmed: only an account younger +/// than [#CLAIM_DAYS] can be claimed for, once, never by its own inviter or by +/// someone it invited, and an inviter is paid for at most [#MAX_REWARDED] +/// friends. +@Component +public class Referrals { + public static final String JOINED = "joined"; + public static final String PLAYED = "played"; + public static final int FRIEND_COINS = 100; + public static final int INVITER_COINS = 200; + public static final int INVITER_XP = 20; + public static final int MAX_REWARDED = 10; + public static final int CLAIM_DAYS = 7; + private static final int CODE_LENGTH = 10; + private static final int PAGE = 100; + + private final ReferralCodeRepository codes; + private final ReferralRepository referrals; + private final ProfileRepository profiles; + private final Progression progression; + private final GamePushes pushes; + + public Referrals(ReferralCodeRepository codes, ReferralRepository referrals, ProfileRepository profiles, + Progression progression, GamePushes pushes) { + this.codes = codes; + this.referrals = referrals; + this.profiles = profiles; + this.progression = progression; + this.pushes = pushes; + } + + /// The player's code, made the first time they ask for it. + @Transactional(rollbackFor = IOException.class) + public InviteDto invite(String username) throws IOException { + List mine = codes.findByUsername(username); + String code; + if (mine.isEmpty()) { + code = Ids.next().substring(0, CODE_LENGTH); + while (codes.findById(code) != null) { + code = Ids.next().substring(0, CODE_LENGTH); + } + ReferralCode c = new ReferralCode(); + c.setCode(code); + c.setUsername(username); + c.setCreatedAt(Instant.now()); + codes.insert(c); + } else { + code = mine.get(0).getCode(); + } + return new InviteDto(code, (int) referrals.countByInviter(username), + (int) referrals.countByInviterAndRewardedTrue(username), MAX_REWARDED, INVITER_COINS, FRIEND_COINS); + } + + // tag::splat-referrals[] + /// The friend's side: the app found an invite and the player is signed in. + @Transactional(rollbackFor = IOException.class) + public RewardDto claim(String username, InviteClaimDto claim) throws IOException { + String code = claim == null || claim.code() == null ? "" : claim.code().trim(); + ReferralCode owner = code.length() == 0 ? null : codes.findById(code); + if (owner == null) { + throw new ResponseStatusException(404, "That invite is not known"); + } + String inviter = owner.getUsername(); + if (inviter.equals(username)) { + throw new ResponseStatusException(400, "That is your own invite"); + } + if (referrals.findById(username) != null) { + throw new ResponseStatusException(409, "An invite was already counted for you"); + } + Referral back = referrals.findById(inviter); + if (back != null && back.getInviter().equals(username)) { + throw new ResponseStatusException(400, "You invited that player"); + } + Profile p = profiles.findById(username); + if (p == null || p.getCreatedAt().isBefore(Instant.now().minusMillis(CLAIM_DAYS * Clock.DAY))) { + throw new ResponseStatusException(400, "Invites count for new players only"); + } + Referral r = new Referral(); + r.setInvitee(username); + r.setInviter(inviter); + r.setCode(code); + r.setCampaign(clip(claim.campaign(), 40)); + r.setChannel(clip(claim.channel(), 40)); + r.setMatchType(clip(claim.match(), 20)); + r.setState(JOINED); + r.setJoinedAt(Instant.now()); + referrals.insert(r); + Profile from = profiles.findById(inviter); + pushes.friendJoined(inviter, p.getDisplayName()); + return progression.levelReward(username, + "A gift from " + (from == null ? "your friend" : from.getDisplayName()), FRIEND_COINS, 0); + } + // end::splat-referrals[] + + /// Called after every race the player finishes. The first one makes an + /// invited player count, and pays whoever invited them. + @Transactional(rollbackFor = IOException.class) + public void played(String username) throws IOException { + Referral r = referrals.findById(username); + if (r == null || !JOINED.equals(r.getState())) { + return; + } + r.setState(PLAYED); + r.setPlayedAt(Instant.now()); + boolean pay = referrals.countByInviterAndRewardedTrue(r.getInviter()) < MAX_REWARDED; + r.setRewarded(pay); + referrals.save(r); + if (pay) { + Profile friend = profiles.findById(username); + String name = friend == null ? "Your friend" : friend.getDisplayName(); + progression.levelReward(r.getInviter(), name + " played", INVITER_COINS, INVITER_XP); + pushes.friendPlayed(r.getInviter(), name, INVITER_COINS); + } + } + + @Transactional(readOnly = true) + public List friends(String username) throws IOException { + List out = new ArrayList<>(); + for (Referral r : referrals.of(username, new PageRequest(0, PAGE))) { + Profile p = profiles.findById(r.getInvitee()); + if (p != null) { + out.add(new FriendDto(p.getDisplayName(), p.getTrophies(), r.getState(), r.isRewarded(), + r.getJoinedAt().toEpochMilli())); + } + } + return out; + } + + /// The usernames `username` invited, for the friends leaderboard. + @Transactional(readOnly = true) + public List invitees(String username) throws IOException { + List out = new ArrayList<>(); + for (Referral r : referrals.of(username, new PageRequest(0, PAGE))) { + out.add(r.getInvitee()); + } + return out; + } + + private static String clip(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max) : s; + } +} diff --git a/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/SocialEndpoint.java b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/SocialEndpoint.java new file mode 100644 index 00000000000..9fa678da804 --- /dev/null +++ b/scripts/splat/backend/src/main/java/com/codenameone/examples/splat/social/SocialEndpoint.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.social; + +import com.codename1.backend.annotations.Component; +import com.codename1.appkit.server.Caller; +import com.codenameone.examples.splat.api.FriendDto; +import com.codenameone.examples.splat.api.InviteClaimDto; +import com.codenameone.examples.splat.api.InviteDto; +import com.codenameone.examples.splat.api.RewardDto; +import com.codenameone.examples.splat.api.SocialApiServer; +import com.codenameone.examples.splat.api.SpotlightDto; +import java.util.List; + +@Component +public class SocialEndpoint implements SocialApiServer { + private final Referrals referrals; + private final Promotion promotion; + + public SocialEndpoint(Referrals referrals, Promotion promotion) { + this.referrals = referrals; + this.promotion = promotion; + } + + @Override + public InviteDto invite() throws Exception { + return referrals.invite(Caller.name()); + } + + @Override + public RewardDto claim(InviteClaimDto claim) throws Exception { + return referrals.claim(Caller.name(), claim); + } + + @Override + public List friends() throws Exception { + return referrals.friends(Caller.name()); + } + + @Override + public SpotlightDto spotlight() throws Exception { + return promotion.spotlight(); + } +} diff --git a/scripts/splat/backend/src/main/resources/db/migration/V1__game.sql b/scripts/splat/backend/src/main/resources/db/migration/V1__game.sql new file mode 100644 index 00000000000..efb92f54a30 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/V1__game.sql @@ -0,0 +1,77 @@ +-- The tables every database shares. Portable SQL only: no generated keys, no +-- native boolean (a yes/no is a SMALLINT 0/1), times are epoch milliseconds. +-- The replay table differs per database and lives in V2 beside this directory. + +CREATE TABLE profile ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + display_name VARCHAR(40) NOT NULL, + trophies INTEGER NOT NULL, + wins INTEGER NOT NULL, + losses INTEGER NOT NULL, + draws INTEGER NOT NULL, + coins INTEGER NOT NULL, + gems INTEGER NOT NULL, + runner VARCHAR(40) NOT NULL, + owned VARCHAR(1000) NOT NULL, + outfit VARCHAR(40) NOT NULL, + flagged SMALLINT NOT NULL, + muted SMALLINT NOT NULL, + suspended_until BIGINT NOT NULL, + banned SMALLINT NOT NULL, + created_at BIGINT NOT NULL, + last_seen BIGINT NOT NULL, + ad_day BIGINT NOT NULL, + ad_count INTEGER NOT NULL, + left_streak INTEGER NOT NULL +); +CREATE INDEX profile_trophies ON profile (trophies); + +CREATE TABLE flag ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + kind VARCHAR(16) NOT NULL, + reason VARCHAR(255) NOT NULL, + race_id VARCHAR(32) NOT NULL, + reporter VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL, + pending SMALLINT NOT NULL, + resolved_by VARCHAR(190) NOT NULL +); +CREATE INDEX flag_username ON flag (username, created_at); +CREATE INDEX flag_pending ON flag (pending, created_at); + +CREATE TABLE admin_action ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + admin VARCHAR(190) NOT NULL, + username VARCHAR(190) NOT NULL, + action VARCHAR(24) NOT NULL, + reason VARCHAR(255) NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX admin_action_username ON admin_action (username, created_at); + +CREATE TABLE store_purchase ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + store VARCHAR(16) NOT NULL, + sku VARCHAR(64) NOT NULL, + gems INTEGER NOT NULL, + created_at BIGINT NOT NULL +); +CREATE INDEX store_purchase_username ON store_purchase (username); + +CREATE TABLE game_ticket ( + ticket_hash VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + expires_at BIGINT NOT NULL +); + +CREATE TABLE push_device ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + provider VARCHAR(16) NOT NULL, + token VARCHAR(1024) NOT NULL, + platform VARCHAR(32) NOT NULL, + updated_at BIGINT NOT NULL +); +CREATE INDEX push_device_username ON push_device (username); diff --git a/scripts/splat/backend/src/main/resources/db/migration/V4__progress.sql b/scripts/splat/backend/src/main/resources/db/migration/V4__progress.sql new file mode 100644 index 00000000000..f535eee5184 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/V4__progress.sql @@ -0,0 +1,22 @@ +-- Levels, daily quests, chests and achievements. Portable, like V1: counters +-- are INTEGER, a yes/no is a SMALLINT 0/1 and a day is epoch milliseconds. +CREATE TABLE progress ( + username VARCHAR(190) NOT NULL PRIMARY KEY, + xp INTEGER NOT NULL, + tutorial_done SMALLINT NOT NULL, + chests INTEGER NOT NULL, + chests_opened INTEGER NOT NULL, + races INTEGER NOT NULL, + wins INTEGER NOT NULL, + metres INTEGER NOT NULL, + pickups INTEGER NOT NULL, + podiums INTEGER NOT NULL, + streak INTEGER NOT NULL, + best_streak INTEGER NOT NULL, + achievements VARCHAR(500) NOT NULL, + quest_day BIGINT NOT NULL, + quest0 INTEGER NOT NULL, + quest1 INTEGER NOT NULL, + quest2 INTEGER NOT NULL, + claimed INTEGER NOT NULL +); diff --git a/scripts/splat/backend/src/main/resources/db/migration/V6__social.sql b/scripts/splat/backend/src/main/resources/db/migration/V6__social.sql new file mode 100644 index 00000000000..077ccbe7445 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/V6__social.sql @@ -0,0 +1,46 @@ +-- Inviting friends and promoting the game: invite codes, who joined through +-- whom, the pushes operators sent and the home banner. Portable, like V4. + +CREATE TABLE referral_code ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + username VARCHAR(190) NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE referral ( + invitee VARCHAR(190) NOT NULL PRIMARY KEY, + inviter VARCHAR(190) NOT NULL, + code VARCHAR(16) NOT NULL, + campaign VARCHAR(40) NOT NULL, + channel VARCHAR(40) NOT NULL, + match_type VARCHAR(20) NOT NULL, + state VARCHAR(10) NOT NULL, + rewarded SMALLINT NOT NULL, + joined_at BIGINT NOT NULL, + played_at BIGINT NOT NULL +); + +CREATE TABLE broadcast ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + segment VARCHAR(10) NOT NULL, + days INTEGER NOT NULL, + title VARCHAR(60) NOT NULL, + body VARCHAR(200) NOT NULL, + screen VARCHAR(20) NOT NULL, + campaign VARCHAR(40) NOT NULL, + targets INTEGER NOT NULL, + sent_by VARCHAR(190) NOT NULL, + sent_at BIGINT NOT NULL +); + +CREATE TABLE spotlight ( + id VARCHAR(10) NOT NULL PRIMARY KEY, + message VARCHAR(140) NOT NULL, + pack_id VARCHAR(40) NOT NULL, + screen VARCHAR(20) NOT NULL, + updated_by VARCHAR(190) NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX referral_by_inviter ON referral (inviter); +CREATE INDEX referral_code_by_user ON referral_code (username); diff --git a/scripts/splat/backend/src/main/resources/db/migration/V7__engagement.sql b/scripts/splat/backend/src/main/resources/db/migration/V7__engagement.sql new file mode 100644 index 00000000000..257e7928aaf --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/V7__engagement.sql @@ -0,0 +1,27 @@ +-- Engagement pipelines: a query over the players and an action, run every +-- hour, and when each one last acted on each player. Portable, like V6. + +CREATE TABLE pipeline ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + enabled SMALLINT NOT NULL, + conditions VARCHAR(500) NOT NULL, + action VARCHAR(10) NOT NULL, + title VARCHAR(60) NOT NULL, + body VARCHAR(200) NOT NULL, + screen VARCHAR(20) NOT NULL, + amount INTEGER NOT NULL, + cooldown_days INTEGER NOT NULL, + reached INTEGER NOT NULL, + last_run BIGINT NOT NULL, + created_by VARCHAR(190) NOT NULL +); + +CREATE TABLE pipeline_hit ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + pipeline VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + hit_at BIGINT NOT NULL +); + +CREATE INDEX pipeline_hit_by_pipeline ON pipeline_hit (pipeline); diff --git a/scripts/splat/backend/src/main/resources/db/migration/mysql/V2__races.sql b/scripts/splat/backend/src/main/resources/db/migration/mysql/V2__races.sql new file mode 100644 index 00000000000..c7f76e7b3d4 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/mysql/V2__races.sql @@ -0,0 +1,69 @@ +-- A replay is a few kilobytes of text, more than a portable VARCHAR holds, and +-- the large text type is spelled differently here than on the other databases: +-- LONGTEXT, which is what the ORM maps a large String to and validates against. +CREATE TABLE race_record ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + level VARCHAR(160) NOT NULL, + players VARCHAR(1200) NOT NULL, + names VARCHAR(300) NOT NULL, + places VARCHAR(40) NOT NULL, + scores VARCHAR(120) NOT NULL, + trophies VARCHAR(120) NOT NULL, + runners INTEGER NOT NULL, + humans INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL, + rules_version VARCHAR(16) NOT NULL, + replay LONGTEXT NOT NULL +); +CREATE INDEX race_record_ended_at ON race_record (ended_at); + +-- One row per player per race, so a history is an indexed lookup. +CREATE TABLE race_entry ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + race_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + level VARCHAR(160) NOT NULL, + place INTEGER NOT NULL, + runners INTEGER NOT NULL, + score INTEGER NOT NULL, + trophy_change INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL +); +CREATE INDEX race_entry_player ON race_entry (username, ended_at); + +-- A player's best verified run per level per board; the replay proves it. +CREATE TABLE score ( + id VARCHAR(400) NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL, + level VARCHAR(160) NOT NULL, + username VARCHAR(190) NOT NULL, + display_name VARCHAR(40) NOT NULL, + score INTEGER NOT NULL, + distance INTEGER NOT NULL, + coins INTEGER NOT NULL, + stars INTEGER NOT NULL, + seed BIGINT NOT NULL, + created_at BIGINT NOT NULL, + replay LONGTEXT NOT NULL +); +CREATE INDEX score_board ON score (board, level, score); +CREATE INDEX score_player ON score (username); + +CREATE TABLE crew ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + owner VARCHAR(190) NOT NULL, + members INTEGER NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE crew_member ( + id VARCHAR(210) NOT NULL PRIMARY KEY, + code VARCHAR(16) NOT NULL, + username VARCHAR(190) NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX crew_member_code ON crew_member (code, joined_at); +CREATE INDEX crew_member_player ON crew_member (username, joined_at); diff --git a/scripts/splat/backend/src/main/resources/db/migration/mysql/V3__collation.sql b/scripts/splat/backend/src/main/resources/db/migration/mysql/V3__collation.sql new file mode 100644 index 00000000000..2fceef5f902 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/mysql/V3__collation.sql @@ -0,0 +1,30 @@ +-- Makes this database's text case sensitive and NO PAD: the tables the +-- earlier scripts created, and the default every later table inherits. +-- +-- The ORM needs that of a text key: "A" and "a" are two keys, and "token " +-- keeps its trailing space -- what SQLite and PostgreSQL do. MySQL's default, +-- utf8mb4_0900_ai_ci, folds both away, and the server refuses to start on a +-- key column that uses it. +-- +-- The latest version, not the first: a database that already ran the earlier +-- scripts takes a new one only when it sorts after them, and its tables are +-- already there to convert. On a new database this runs after them too, and +-- converts the tables they have just created. +-- +-- MySQL and MariaDB share no name for that collation (utf8mb4_bin, which both +-- have, pads), and this directory serves both. The comments are executable +-- ones, each read by one family only: MySQL runs /*!80000 ...*/ and skips the +-- MariaDB-only /*M! ...*/; MariaDB runs /*M!100400 ...*/ and skips MySQL's 8.0 +-- comments. +ALTER DATABASE CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE admin_action CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE flag CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE game_ticket CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE race_record CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE race_entry CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE score CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE crew CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE crew_member CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE profile CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE push_device CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; +ALTER TABLE store_purchase CONVERT TO CHARACTER SET utf8mb4 COLLATE /*!80000 utf8mb4_0900_bin */ /*M!100400 utf8mb4_nopad_bin */; diff --git a/scripts/splat/backend/src/main/resources/db/migration/mysql/V8__missions.sql b/scripts/splat/backend/src/main/resources/db/migration/mysql/V8__missions.sql new file mode 100644 index 00000000000..c6a0229beb7 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/mysql/V8__missions.sql @@ -0,0 +1,12 @@ +-- Player-built missions. The body (LONGTEXT, what the ORM maps a large String to) is the mission's JSON, a few kilobytes. +CREATE TABLE mission_record ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(40) NOT NULL, + mode VARCHAR(20) NOT NULL, + body LONGTEXT NOT NULL, + created_at BIGINT NOT NULL, + plays INTEGER NOT NULL +); + +CREATE INDEX mission_record_owner ON mission_record (owner, created_at); diff --git a/scripts/splat/backend/src/main/resources/db/migration/postgresql/V2__races.sql b/scripts/splat/backend/src/main/resources/db/migration/postgresql/V2__races.sql new file mode 100644 index 00000000000..baa7972678b --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/postgresql/V2__races.sql @@ -0,0 +1,68 @@ +-- A replay is a few kilobytes of text, more than a portable VARCHAR holds, and +-- the large text type is spelled differently here than on the other databases. +CREATE TABLE race_record ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + level VARCHAR(160) NOT NULL, + players VARCHAR(1200) NOT NULL, + names VARCHAR(300) NOT NULL, + places VARCHAR(40) NOT NULL, + scores VARCHAR(120) NOT NULL, + trophies VARCHAR(120) NOT NULL, + runners INTEGER NOT NULL, + humans INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL, + rules_version VARCHAR(16) NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX race_record_ended_at ON race_record (ended_at); + +-- One row per player per race, so a history is an indexed lookup. +CREATE TABLE race_entry ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + race_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + level VARCHAR(160) NOT NULL, + place INTEGER NOT NULL, + runners INTEGER NOT NULL, + score INTEGER NOT NULL, + trophy_change INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL +); +CREATE INDEX race_entry_player ON race_entry (username, ended_at); + +-- A player's best verified run per level per board; the replay proves it. +CREATE TABLE score ( + id VARCHAR(400) NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL, + level VARCHAR(160) NOT NULL, + username VARCHAR(190) NOT NULL, + display_name VARCHAR(40) NOT NULL, + score INTEGER NOT NULL, + distance INTEGER NOT NULL, + coins INTEGER NOT NULL, + stars INTEGER NOT NULL, + seed BIGINT NOT NULL, + created_at BIGINT NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX score_board ON score (board, level, score); +CREATE INDEX score_player ON score (username); + +CREATE TABLE crew ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + owner VARCHAR(190) NOT NULL, + members INTEGER NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE crew_member ( + id VARCHAR(210) NOT NULL PRIMARY KEY, + code VARCHAR(16) NOT NULL, + username VARCHAR(190) NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX crew_member_code ON crew_member (code, joined_at); +CREATE INDEX crew_member_player ON crew_member (username, joined_at); diff --git a/scripts/splat/backend/src/main/resources/db/migration/postgresql/V3__collation.sql b/scripts/splat/backend/src/main/resources/db/migration/postgresql/V3__collation.sql new file mode 100644 index 00000000000..5bde8ca0fb6 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/postgresql/V3__collation.sql @@ -0,0 +1,2 @@ +-- Text is already case sensitive and NO PAD here; see mysql/V3__collation.sql. +SELECT 1; diff --git a/scripts/splat/backend/src/main/resources/db/migration/postgresql/V8__missions.sql b/scripts/splat/backend/src/main/resources/db/migration/postgresql/V8__missions.sql new file mode 100644 index 00000000000..e66a23014e1 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/postgresql/V8__missions.sql @@ -0,0 +1,12 @@ +-- Player-built missions. The body is the mission's JSON, a few kilobytes. +CREATE TABLE mission_record ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(40) NOT NULL, + mode VARCHAR(20) NOT NULL, + body TEXT NOT NULL, + created_at BIGINT NOT NULL, + plays INTEGER NOT NULL +); + +CREATE INDEX mission_record_owner ON mission_record (owner, created_at); diff --git a/scripts/splat/backend/src/main/resources/db/migration/sqlite/V2__races.sql b/scripts/splat/backend/src/main/resources/db/migration/sqlite/V2__races.sql new file mode 100644 index 00000000000..baa7972678b --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/sqlite/V2__races.sql @@ -0,0 +1,68 @@ +-- A replay is a few kilobytes of text, more than a portable VARCHAR holds, and +-- the large text type is spelled differently here than on the other databases. +CREATE TABLE race_record ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + level VARCHAR(160) NOT NULL, + players VARCHAR(1200) NOT NULL, + names VARCHAR(300) NOT NULL, + places VARCHAR(40) NOT NULL, + scores VARCHAR(120) NOT NULL, + trophies VARCHAR(120) NOT NULL, + runners INTEGER NOT NULL, + humans INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL, + rules_version VARCHAR(16) NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX race_record_ended_at ON race_record (ended_at); + +-- One row per player per race, so a history is an indexed lookup. +CREATE TABLE race_entry ( + id VARCHAR(230) NOT NULL PRIMARY KEY, + race_id VARCHAR(32) NOT NULL, + username VARCHAR(190) NOT NULL, + level VARCHAR(160) NOT NULL, + place INTEGER NOT NULL, + runners INTEGER NOT NULL, + score INTEGER NOT NULL, + trophy_change INTEGER NOT NULL, + ticks INTEGER NOT NULL, + ended_at BIGINT NOT NULL +); +CREATE INDEX race_entry_player ON race_entry (username, ended_at); + +-- A player's best verified run per level per board; the replay proves it. +CREATE TABLE score ( + id VARCHAR(400) NOT NULL PRIMARY KEY, + board VARCHAR(16) NOT NULL, + level VARCHAR(160) NOT NULL, + username VARCHAR(190) NOT NULL, + display_name VARCHAR(40) NOT NULL, + score INTEGER NOT NULL, + distance INTEGER NOT NULL, + coins INTEGER NOT NULL, + stars INTEGER NOT NULL, + seed BIGINT NOT NULL, + created_at BIGINT NOT NULL, + replay TEXT NOT NULL +); +CREATE INDEX score_board ON score (board, level, score); +CREATE INDEX score_player ON score (username); + +CREATE TABLE crew ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + name VARCHAR(40) NOT NULL, + owner VARCHAR(190) NOT NULL, + members INTEGER NOT NULL, + created_at BIGINT NOT NULL +); + +CREATE TABLE crew_member ( + id VARCHAR(210) NOT NULL PRIMARY KEY, + code VARCHAR(16) NOT NULL, + username VARCHAR(190) NOT NULL, + joined_at BIGINT NOT NULL +); +CREATE INDEX crew_member_code ON crew_member (code, joined_at); +CREATE INDEX crew_member_player ON crew_member (username, joined_at); diff --git a/scripts/splat/backend/src/main/resources/db/migration/sqlite/V3__collation.sql b/scripts/splat/backend/src/main/resources/db/migration/sqlite/V3__collation.sql new file mode 100644 index 00000000000..5bde8ca0fb6 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/sqlite/V3__collation.sql @@ -0,0 +1,2 @@ +-- Text is already case sensitive and NO PAD here; see mysql/V3__collation.sql. +SELECT 1; diff --git a/scripts/splat/backend/src/main/resources/db/migration/sqlite/V8__missions.sql b/scripts/splat/backend/src/main/resources/db/migration/sqlite/V8__missions.sql new file mode 100644 index 00000000000..e66a23014e1 --- /dev/null +++ b/scripts/splat/backend/src/main/resources/db/migration/sqlite/V8__missions.sql @@ -0,0 +1,12 @@ +-- Player-built missions. The body is the mission's JSON, a few kilobytes. +CREATE TABLE mission_record ( + code VARCHAR(16) NOT NULL PRIMARY KEY, + owner VARCHAR(190) NOT NULL, + name VARCHAR(40) NOT NULL, + mode VARCHAR(20) NOT NULL, + body TEXT NOT NULL, + created_at BIGINT NOT NULL, + plays INTEGER NOT NULL +); + +CREATE INDEX mission_record_owner ON mission_record (owner, created_at); diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/AccountTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/AccountTest.java new file mode 100644 index 00000000000..f41bf8fb651 --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/AccountTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.splat.account.Accounts; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Opening an account, naming yourself and building a deck. +@BackendTest +class AccountTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + @Test + void aNewPlayerStartsWithTheFreeRunnerAndAPurse() throws Exception { + Map me = Api.json(mvc, Api.body(post("/api/account/register"), + "{\"email\":\"newcomer@test.example\",\"password\":\"" + Api.PASSWORD + + "\",\"displayName\":\"Newcomer\"}")); + String who = (String) me.get("username"); + assertEquals("Newcomer", me.get("displayName")); + assertEquals("semi", me.get("runner")); + assertEquals(100L, Api.number(me, "coins")); + assertEquals(20L, Api.number(me, "gems")); + assertEquals(Boolean.FALSE, me.get("admin")); + + Map again = Api.json(mvc, Api.as(get("/api/me"), who)); + assertEquals(who, again.get("username")); + } + + @Test + void aNameHasToBeReadable() throws Exception { + String who = Api.player(accounts, "renamer"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/me/profile"), + "{\"displayName\":\"x\"}"), who))); + Map me = Api.json(mvc, Api.as(Api.body(post("/api/me/profile"), + "{\"displayName\":\"Sir Renamer\"}"), who)); + assertEquals("Sir Renamer", me.get("displayName")); + } + + @Test + void onlyAnOwnedRunnerCanBePicked() throws Exception { + String who = Api.player(accounts, "picker"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/runner"), + "{\"runner\":\"pump\"}"), who))); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/runner"), + "{\"runner\":\"no-such-runner\"}"), who))); + Api.json(mvc, Api.as(Api.body(post("/api/runner"), "{\"runner\":\"semi\"}"), who)); + assertEquals("semi", Api.json(mvc, Api.as(get("/api/runner"), who)).get("runner")); + } + + @Test + void theApiNeedsAToken() throws Exception { + assertEquals(401, Api.statusOf(mvc, get("/api/me"))); + assertEquals(401, Api.statusOf(mvc, get("/api/runner"))); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/Api.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/Api.java new file mode 100644 index 00000000000..c9eb3a3cade --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/Api.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.Json; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.api.RegisterDto; +import java.util.List; +import java.util.Map; + +import static com.codename1.backend.test.MockMvcResultMatchers.status; +import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.jwt; + +/// The steps most tests take: making a player, calling the API as them, and +/// reading what came back. +public final class Api { + public static final String PASSWORD = "a-long-password"; + + private Api() { + } + + /// A fresh player of the test's own, so no test meets what another left. + public static String player(Accounts accounts, String name) throws Exception { + return accounts.register(new RegisterDto(name + "@test.example", PASSWORD, name)).username(); + } + + public static MockRequestBuilder as(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_PLAYER")); + } + + public static MockRequestBuilder asAdmin(MockRequestBuilder request, String username) { + return request.with(jwt().subject(username).authorities("ROLE_PLAYER", "ROLE_ADMIN")); + } + + public static MockRequestBuilder body(MockRequestBuilder request, String json) { + return request.contentType("application/json").content(json); + } + + public static Map json(MockMvc mvc, MockRequestBuilder request) throws Exception { + return Json.parseObject(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + public static List list(MockMvc mvc, MockRequestBuilder request) throws Exception { + return (List) Json.parse(mvc.perform(request).andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } + + public static int statusOf(MockMvc mvc, MockRequestBuilder request) throws Exception { + return mvc.perform(request).andReturn().getResponse().getStatus(); + } + + public static long number(Map object, String field) { + return ((Number) object.get(field)).longValue(); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/GameSocketTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/GameSocketTest.java new file mode 100644 index 00000000000..ea2327c1062 --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/GameSocketTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.Crypto; +import com.codename1.backend.Json; +import com.codename1.backend.Tcp; +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.LocalServerPort; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.game.GameTickets; +import java.io.IOException; +import com.codenameone.examples.splat.api.GameProtocol; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The game over a real socket: a ticket opens it, a party seats two +/// friends, inputs come back stamped for both, and leaving ends the race. +@BackendTest(webEnvironment = BackendTest.WebEnvironment.RANDOM_PORT) +class GameSocketTest { + private static final int TEXT = 0x1; + private static final int CLOSE = 0x8; + + @LocalServerPort + private int port; + @Autowired + private GameTickets tickets; + @Autowired + private Accounts accounts; + + private int opcode; + private byte[] payload; + + @Test + void twoFriendsMeetInAPartyAndTheServerUsesTheirInput() throws Exception { + String host = Api.player(accounts, "sockethost"); + String guest = Api.player(accounts, "socketguest"); + Tcp a = open("/ws/game?ticket=" + tickets.issue(host).ticket()); + Tcp b = open("/ws/game?ticket=" + tickets.issue(guest).ticket()); + try { + assertEquals("ready", next(a).get("type")); + assertEquals("ready", next(b).get("type")); + writeText(a, "{\"type\":\"host\",\"level\":\"lot7:TEAM_PAINT\"}"); + String code = (String) until(a, "party").get("code"); + writeText(b, "{\"type\":\"join\",\"code\":\"" + code + "\"}"); + Map party = until(b, "party"); + assertEquals(2, ((java.util.List) party.get("names")).size()); + // Members are handles, never account names (emails). + java.util.List handles = (java.util.List) party.get("players"); + String hostHandle = (String) handles.get(0); + String guestHandle = (String) party.get("you"); + assertEquals(handles.get(1), guestHandle); + assertFalse(party.toString().contains(host)); + assertFalse(party.toString().contains(guest)); + // Voice signaling reaches the other member, and only them. + writeText(b, "{\"type\":\"voice\",\"to\":\"" + hostHandle + "\",\"data\":\"offer\"}"); + Map voice = until(a, "voice"); + assertEquals(guestHandle, voice.get("from")); + assertEquals("offer", voice.get("data")); + + writeText(a, "{\"type\":\"go\"}"); + Map startA = until(a, "start"); + Map startB = until(b, "start"); + assertEquals(startA.get("match"), startB.get("match")); + assertEquals(startA.get("seed"), startB.get("seed")); + assertEquals(0L, ((Number) startA.get("you")).longValue()); + assertEquals(1L, ((Number) startB.get("you")).longValue()); + + // After the countdown the host's input is used, and the next + // snapshots say so with its sequence number. + Thread.sleep(GameProtocol.COUNTDOWN_MILLIS + 200); + writeText(a, "{\"type\":\"input\",\"s\":5,\"i\":\"0 100 0 0 2 -1\"}"); + assertEquals(5L, ackOf(a, 5)); + + writeText(b, "{\"type\":\"leave\"}"); + // Leaving detaches the socket from the match: the room it walked + // out of must not stream its snapshots or its end into it, so + // the server answers the next request with no trace of that match. + writeText(a, "{\"type\":\"leave\"}"); + writeText(a, "{\"type\":\"host\"}"); + for (Map m = next(a); !"party".equals(m.get("type")) && !"error".equals(m.get("type")); m = next(a)) { + assertFalse("end".equals(m.get("type")), "the abandoned match ended into the socket"); + } + } finally { + a.close(); + b.close(); + } + } + + @Test + void noTicketNoGame() throws Exception { + String[] paths = {"/ws/game", "/ws/game?ticket=", "/ws/game?ticket=0123456789abcdef"}; + for (String path : paths) { + Tcp stranger = open(path); + try { + readFrame(stranger); + assertEquals(CLOSE, opcode, path); + assertEquals(4401, closeCode()); + } finally { + stranger.close(); + } + } + } + + private Map next(Tcp connection) throws IOException { + readFrame(connection); + return Json.parseObject(text()); + } + + /// Reads messages until one of `type`, skipping the tick stream. + private Map until(Tcp connection, String type) throws IOException { + for (int i = 0; i < 2000; i++) { + Map m = next(connection); + if (type.equals(m.get("type"))) { + return m; + } + } + throw new IOException("no " + type + " message"); + } + + /// Reads snapshots until one acknowledges input `want`. + private long ackOf(Tcp connection, long want) throws IOException { + for (int i = 0; i < 2000; i++) { + Map m = until(connection, "snap"); + long ack = ((Number) m.get("ack")).longValue(); + if (ack >= want) { + return ack; + } + } + throw new IOException("the input was never acknowledged"); + } + + private Tcp open(String target) throws IOException { + Tcp connection = Tcp.connect("127.0.0.1", port, 5000); + byte[] request = ascii("GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n" + + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n"); + connection.write(request, 0, request.length); + StringBuilder head = new StringBuilder(); + // The head ends at the first empty line: CR LF CR LF. + int ending = 0; + while (head.length() < 8192 && ending < 4) { + int c = readByte(connection); + if (c < 0) { + break; + } + head.append((char) c); + ending = c == (ending % 2 == 0 ? '\r' : '\n') ? ending + 1 : (c == '\r' ? 1 : 0); + } + assertTrue(head.toString().startsWith("HTTP/1.1 101"), head.toString()); + return connection; + } + + private String text() throws IOException { + assertEquals(TEXT, opcode); + return new String(payload, "UTF-8"); + } + + private int closeCode() { + return payload.length < 2 ? -1 : ((payload[0] & 0xff) << 8) | (payload[1] & 0xff); + } + + private void readFrame(Tcp connection) throws IOException { + int first = readByte(connection); + if (first < 0) { + throw new IOException("the connection ended with no frame"); + } + opcode = first & 0xf; + int length = readByte(connection) & 0x7f; + if (length == 126) { + length = (readByte(connection) << 8) | readByte(connection); + } + payload = new byte[length]; + int got = 0; + while (got < length) { + int read = connection.read(payload, got, length - got); + if (read <= 0) { + throw new IOException("the connection ended after " + got + " of " + length); + } + got += read; + } + } + + /// A client's frames are masked; a server refuses one that is not. + private static void writeText(Tcp connection, String text) throws IOException { + byte[] body = ascii(text); + byte[] mask = Crypto.randomBytes(4); + byte[] frame = new byte[6 + body.length]; + frame[0] = (byte) (0x80 | TEXT); + frame[1] = (byte) (0x80 | body.length); + System.arraycopy(mask, 0, frame, 2, 4); + for (int iter = 0; iter < body.length; iter++) { + frame[6 + iter] = (byte) (body[iter] ^ mask[iter & 3]); + } + connection.write(frame, 0, frame.length); + } + + private static int readByte(Tcp connection) throws IOException { + byte[] one = new byte[1]; + int read = connection.read(one, 0, 1); + return read <= 0 ? -1 : one[0] & 0xff; + } + + private static byte[] ascii(String value) { + byte[] out = new byte[value.length()]; + for (int iter = 0; iter < out.length; iter++) { + out[iter] = (byte) value.charAt(iter); + } + return out; + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/MissionTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/MissionTest.java new file mode 100644 index 00000000000..6d632c2508d --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/MissionTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.game.Arenas; +import com.codenameone.examples.splat.mission.Missions; +import com.codenameone.examples.splat.rules.Mission; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@BackendTest +class MissionTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Missions missions; + + private static String quoted(String text) { + StringBuilder b = new StringBuilder("\""); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '"' || c == '\\') { + b.append('\\'); + } + b.append(c); + } + return b.append('"').toString(); + } + + private int publish(String who, Mission m) throws Exception { + return Api.statusOf(mvc, Api.as(Api.body(post("/api/missions"), "{\"json\":" + quoted(m.toJson()) + "}"), + who)); + } + + @Test + void aPublishedMissionIsListedFetchedAndPlayable() throws Exception { + String me = Api.player(accounts, "mission-maker"); + Mission m = Mission.blank(); + m.code = "client-picked"; + m.name = "Operation Snack"; + Map made = Api.json(mvc, Api.as(Api.body(post("/api/missions"), + "{\"json\":" + quoted(m.toJson()) + "}"), me)); + String code = (String) made.get("code"); + assertEquals(Missions.CODE_LENGTH, code.length()); + assertNotEquals("client-picked", code, "the server names the mission"); + assertEquals("Operation Snack", made.get("name")); + + List mine = Api.list(mvc, Api.as(get("/api/missions"), me)); + assertEquals(code, ((Map) mine.get(0)).get("code")); + assertEquals(0L, Api.number((Map) mine.get(0), "plays")); + + Map fetched = Api.json(mvc, Api.as(get("/api/missions/" + code), me)); + Mission back = Mission.fromJson((String) fetched.get("json")); + assertEquals(code, back.code); + assertEquals(404, Api.statusOf(mvc, Api.as(get("/api/missions/nosuchone"), me))); + + String ref = Mission.PREFIX + code + ":TEAM_PAINT"; + assertEquals(ref, Arenas.normalize(missions.resolve(ref))); + assertNull(Arenas.normalize(missions.resolve(Mission.PREFIX + "zzzzzzzz:TEAM_PAINT"))); + } + + @Test + void anUnplayableMissionOrGarbageIsRefused() throws Exception { + String me = Api.player(accounts, "mission-breaker"); + Mission m = Mission.blank(); + m.spawns.clear(); + assertEquals(400, publish(me, m), "no spawn points"); + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/missions"), "{\"json\":\"nonsense\"}"), me))); + assertTrue(Api.list(mvc, Api.as(get("/api/missions"), me)).isEmpty()); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/ModerationTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/ModerationTest.java new file mode 100644 index 00000000000..da99b4ab0e6 --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/ModerationTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.account.DemoAccounts; +import com.codenameone.examples.splat.admin.Flags; +import com.codenameone.examples.splat.admin.Moderation; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// God mode: finding a problem player, acting on them, and the record kept. +@BackendTest +class ModerationTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Flags flags; + @Autowired + private Moderation moderation; + + private MockRequestBuilder admin(MockRequestBuilder request) { + return Api.asAdmin(request, DemoAccounts.ADMIN); + } + + @Test + void onlyAdminsSeeTheConsole() throws Exception { + String who = Api.player(accounts, "curious"); + assertEquals(403, Api.statusOf(mvc, Api.as(get("/api/admin/stats"), who))); + assertEquals(403, Api.statusOf(mvc, Api.as(Api.body(post("/api/admin/players/" + who + + "/action"), "{\"action\":\"BAN\",\"reason\":\"me\"}"), who))); + Map stats = Api.json(mvc, admin(get("/api/admin/stats"))); + assertTrue(Api.number(stats, "players") >= 4); + } + + @Test + void aFlaggedPlayerIsFoundSuspendedAndTheRecordSaysWhy() throws Exception { + String who = Api.player(accounts, "griefer"); + flags.auto(who, "Sent 40 invalid plays in one match", null); + List open = Api.list(mvc, admin(get("/api/admin/flags?open=true"))); + Map flag = null; + for (Object o : open) { + if (who.equals(((Map) o).get("username"))) { + flag = (Map) o; + } + } + assertTrue(flag != null, "the automatic flag is in the queue"); + assertEquals(Flags.AUTO, flag.get("kind")); + + List found = Api.list(mvc, admin(get("/api/admin/players?q=griefer"))); + assertEquals(1, found.size()); + assertEquals(Boolean.TRUE, ((Map) found.get(0)).get("flagged")); + + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/players/" + who + + "/action"), "{\"action\":\"SUSPEND\",\"reason\":\"griefing\",\"days\":0}")))); + Map player = Api.json(mvc, admin(Api.body(post("/api/admin/players/" + who + "/action"), + "{\"action\":\"SUSPEND\",\"reason\":\"griefing\",\"days\":3}"))); + assertEquals(Boolean.TRUE, player.get("suspended")); + assertFalse(accounts.isEnabled(who)); + + Api.json(mvc, admin(post("/api/admin/flags/" + flag.get("id") + "/resolve"))); + Map detail = Api.json(mvc, admin(get("/api/admin/players/" + who))); + List actions = (List) detail.get("actions"); + assertTrue(((String) ((Map) actions.get(0)).get("action")).startsWith(Moderation.SUSPEND)); + assertEquals(DemoAccounts.ADMIN, ((Map) actions.get(0)).get("admin")); + assertEquals("griefing", ((Map) actions.get(0)).get("reason")); + assertEquals(Boolean.FALSE, ((Map) ((List) detail.get("flags")).get(0)).get("open")); + + // A suspension ends by itself. + assertTrue(moderation.lift(System.currentTimeMillis() + 4L * 24 * 3600 * 1000) >= 1); + assertTrue(accounts.isEnabled(who)); + } + + @Test + void anAdminCannotActOnThemselves() throws Exception { + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/players/" + + DemoAccounts.ADMIN + "/action"), "{\"action\":\"BAN\",\"reason\":\"oops\"}")))); + } + + @Test + void theDemoTroublemakerIsWaitingInTheQueue() throws Exception { + Map detail = Api.json(mvc, admin(get("/api/admin/players/" + DemoAccounts.TROUBLE))); + assertEquals(Boolean.TRUE, ((Map) detail.get("player")).get("flagged")); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/ShopTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/ShopTest.java new file mode 100644 index 00000000000..2b902d4b232 --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/ShopTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.shop.Shop; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Spending coins and gems, store receipts and rewarded ads. The test profile +/// checks receipts with the simulated store, which accepts any of them. +@BackendTest +class ShopTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + @Test + void anItemCostsWhatTheCatalogSaysAndIsOwnedOnce() throws Exception { + String who = Api.player(accounts, "shopper"); + assertTrue(Api.list(mvc, Api.as(get("/api/shop"), who)).size() >= 6); + // 100 coins buys nothing that costs 500. + assertEquals(402, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"pump\"}"), who))); + // A real-money item is never sold for coins. + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"gems-80\"}"), who))); + assertEquals(402, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"emotes-taunt\"}"), who))); + Api.json(mvc, Api.as(Api.body(post("/api/shop/receipt"), "{\"store\":\"play\",\"sku\":" + + "\"splat.gems_80\",\"transactionId\":\"tx-shopper\",\"receipt\":\"r\"}"), who)); + Map wallet = Api.json(mvc, Api.as(Api.body(post("/api/shop/buy"), + "{\"itemId\":\"emotes-taunt\"}"), who)); + assertEquals(70L, Api.number(wallet, "gems")); + assertTrue(((List) wallet.get("owned")).size() >= 2); + assertEquals(403, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/equip"), + "{\"outfit\":\"paint-glitter\"}"), who))); + } + + // tag::splat-receipt-test[] + @Test + void aReceiptIsRedeemedOnceAndOnlyByItsOwner() throws Exception { + String who = Api.player(accounts, "buyer"); + String thief = Api.player(accounts, "thief"); + String receipt = "{\"store\":\"itunes\",\"sku\":\"splat.gems_80\"," + + "\"transactionId\":\"tx-1001\",\"receipt\":\"opaque\"}"; + Map first = Api.json(mvc, Api.as(Api.body(post("/api/shop/receipt"), receipt), who)); + assertEquals(100L, Api.number(first, "gems")); + // The app retries after a lost answer: the same purchase, granted once. + Map again = Api.json(mvc, Api.as(Api.body(post("/api/shop/receipt"), receipt), who)); + assertEquals(100L, Api.number(again, "gems")); + assertEquals(409, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/receipt"), + receipt), thief))); + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/receipt"), + "{\"store\":\"itunes\",\"sku\":\"nope\",\"transactionId\":\"tx-2\",\"receipt\":\"r\"}"), + who))); + } + // end::splat-receipt-test[] + + @Test + void rewardedAdsAreCappedPerDay() throws Exception { + String who = Api.player(accounts, "watcher"); + long coins = 100; + for (int i = 0; i < Shop.AD_REWARDS_PER_DAY; i++) { + Map w = Api.json(mvc, Api.as(Api.body(post("/api/shop/ad-reward"), "{\"nonce\":\"n" + i + + "\"}"), who)); + coins += Shop.AD_REWARD_COINS; + assertEquals(coins, Api.number(w, "coins")); + } + assertEquals(429, Api.statusOf(mvc, Api.as(Api.body(post("/api/shop/ad-reward"), + "{\"nonce\":\"more\"}"), who))); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/SocialTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/SocialTest.java new file mode 100644 index 00000000000..6cefe4cb302 --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/SocialTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codename1.backend.test.MockRequestBuilder; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.account.DemoAccounts; +import com.codenameone.examples.splat.social.Referrals; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Inviting friends and the operator's Promote tab: who is paid for an +/// invite and when, the claims that are refused, broadcasts and the banner. +@BackendTest +class SocialTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + + private static MockRequestBuilder admin(MockRequestBuilder request) { + return Api.asAdmin(request, DemoAccounts.ADMIN); + } + + private String code(String who) throws Exception { + return (String) Api.json(mvc, Api.as(get("/api/social/invite"), who)).get("code"); + } + + private int claim(String who, String code) throws Exception { + return Api.statusOf(mvc, Api.as(Api.body(post("/api/social/claim"), + "{\"code\":\"" + code + "\",\"campaign\":\"friends\",\"channel\":\"share_sheet\",\"match\":\"direct\"}"), + who)); + } + + private long coins(String who) throws Exception { + return Api.number(Api.json(mvc, Api.as(get("/api/me"), who)), "coins"); + } + + // tag::splat-referral-test[] + @Test + void aFriendIsWelcomedOnJoiningAndTheInviterPaidOnceTheyPlay() throws Exception { + String host = Api.player(accounts, "inviter-one"); + String friend = Api.player(accounts, "invited-one"); + String code = code(host); + assertEquals(code, code(host), "a player keeps one code"); + + long before = coins(friend); + assertEquals(200, claim(friend, code)); + assertEquals(before + Referrals.FRIEND_COINS, coins(friend)); + assertEquals(409, claim(friend, code), "an invite counts once"); + + List friends = Api.list(mvc, Api.as(get("/api/social/friends"), host)); + assertEquals(1, friends.size()); + assertEquals(Referrals.JOINED, ((Map) friends.get(0)).get("state")); + + long hostBefore = coins(host); + Api.json(mvc, Api.as(post("/api/progress/tutorial"), friend)); + assertEquals(hostBefore + Referrals.INVITER_COINS, coins(host), "paid when the friend has played"); + Map mine = Api.json(mvc, Api.as(get("/api/social/invite"), host)); + assertEquals(1L, Api.number(mine, "rewarded")); + assertEquals(Referrals.PLAYED, ((Map) Api.list(mvc, Api.as(get("/api/social/friends"), host)).get(0)) + .get("state")); + + // A second battle pays nothing more. + Api.json(mvc, Api.as(post("/api/progress/tutorial"), friend)); + assertEquals(hostBefore + Referrals.INVITER_COINS, coins(host)); + } + // end::splat-referral-test[] + + @Test + void invitesThatWouldFarmCoinsAreRefused() throws Exception { + String a = Api.player(accounts, "farm-a"); + String b = Api.player(accounts, "farm-b"); + assertEquals(400, claim(a, code(a)), "not your own code"); + assertEquals(404, claim(a, "nosuchcode")); + assertEquals(200, claim(b, code(a))); + assertEquals(400, claim(a, code(b)), "not the player you invited"); + } + + @Test + void anOperatorBroadcastsToASegmentAndSetsTheBanner() throws Exception { + String everyone = "{\"segment\":\"all\",\"days\":0,\"title\":\"\",\"body\":\"\",\"screen\":\"\"}"; + Map size = Api.json(mvc, admin(Api.body(post("/api/admin/promote/audience"), everyone))); + assertTrue(Api.number(size, "targets") >= 4, "the demo accounts at least"); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/promote/broadcast"), everyone))), + "a push needs a title and a message"); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/promote/audience"), + "{\"segment\":\"lapsed\",\"days\":0,\"screen\":\"\"}"))), "lapsed for how long"); + assertEquals(403, Api.statusOf(mvc, Api.as(Api.body(post("/api/admin/promote/broadcast"), everyone), + DemoAccounts.PLAYER))); + + Map sent = Api.json(mvc, admin(Api.body(post("/api/admin/promote/broadcast"), + "{\"segment\":\"new\",\"days\":7,\"title\":\"Double coins\",\"body\":\"All weekend long\"," + + "\"screen\":\"shop\",\"campaign\":\"lot7\"}"))); + assertEquals("lot7", sent.get("campaign")); + Map promote = Api.json(mvc, admin(get("/api/admin/promote"))); + assertTrue(((List) promote.get("broadcasts")).size() >= 1); + + Api.json(mvc, admin(Api.body(post("/api/admin/promote/spotlight"), + "{\"text\":\"The Border War campaign is here\",\"packId\":\"border-war\",\"screen\":\"\"}"))); + Map banner = Api.json(mvc, Api.as(get("/api/social/spotlight"), DemoAccounts.PLAYER)); + assertEquals("The Border War campaign is here", banner.get("text")); + assertEquals("border-war", banner.get("packId")); + } + + @Test + void reportsAndTheQueryToolReadTheGamesOwnRecords() throws Exception { + Map report = Api.json(mvc, admin(get("/api/admin/reports?days=14"))); + assertEquals(14, ((List) report.get("joined")).size()); + assertEquals(14, ((List) report.get("active")).size()); + List funnel = (List) report.get("funnel"); + assertEquals("Joined", ((Map) funnel.get(0)).get("name")); + assertTrue(Api.number((Map) funnel.get(0), "count") >= 4, "the demo accounts at least"); + assertEquals(3, ((List) report.get("retention")).size()); + + Map all = Api.json(mvc, admin(Api.body(post("/api/admin/query"), + "{\"conditions\":[{\"field\":\"trophies\",\"op\":\">=\",\"value\":0}]}"))); + assertTrue(Api.number(all, "count") >= 4); + Map none = Api.json(mvc, admin(Api.body(post("/api/admin/query"), + "{\"conditions\":[{\"field\":\"trophies\",\"op\":\">\",\"value\":1000000}]}"))); + assertEquals(0, Api.number(none, "count")); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/query"), + "{\"conditions\":[{\"field\":\"password\",\"op\":\"=\",\"value\":1}]}")))); + assertEquals(403, Api.statusOf(mvc, Api.as(get("/api/admin/reports?days=7"), DemoAccounts.PLAYER))); + } + + @Test + void aPipelineActsOnceAPlayerPerCoolDown() throws Exception { + assertTrue(Api.list(mvc, admin(get("/api/admin/pipelines"))).size() >= 2, "the two the server starts with"); + assertEquals(400, Api.statusOf(mvc, admin(Api.body(post("/api/admin/pipelines"), + "{\"name\":\"Everyone\",\"enabled\":true,\"conditions\":[],\"action\":\"push\"," + + "\"title\":\"Hi\",\"body\":\"Hello\",\"screen\":\"\",\"cooldownDays\":1}"))), + "a pipeline for everyone is a broadcast"); + long before = Api.number((Map) Api.json(mvc, admin(get("/api/admin/players/" + DemoAccounts.PLAYER))) + .get("player"), "gems"); + Map saved = Api.json(mvc, admin(Api.body(post("/api/admin/pipelines"), + "{\"name\":\"Gift\",\"enabled\":false,\"conditions\":[{\"field\":\"trophies\"," + + "\"op\":\">=\",\"value\":0}],\"action\":\"gems\",\"title\":\"A gift\"," + + "\"body\":\"Five gems\",\"screen\":\"shop\",\"amount\":5,\"cooldownDays\":30}"))); + String id = (String) saved.get("id"); + Map first = Api.json(mvc, admin(post("/api/admin/pipelines/" + id + "/run"))); + long reached = Api.number(first, "reached"); + assertTrue(reached >= 4); + Map player = (Map) Api.json(mvc, admin(get("/api/admin/players/" + DemoAccounts.PLAYER))).get("player"); + assertEquals(before + 5, Api.number(player, "gems")); + Map again = Api.json(mvc, admin(post("/api/admin/pipelines/" + id + "/run"))); + assertEquals(reached, Api.number(again, "reached"), "nobody twice within the cool-down"); + Api.json(mvc, admin(post("/api/admin/pipelines/" + id + "/delete"))); + assertEquals(404, Api.statusOf(mvc, admin(post("/api/admin/pipelines/" + id + "/run")))); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/OnlineMatchTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/OnlineMatchTest.java new file mode 100644 index 00000000000..e8058debc9c --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/OnlineMatchTest.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.splat.Api; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.api.PushDeviceDto; +import com.codenameone.examples.splat.domain.Profile; +import com.codenameone.examples.splat.push.PushDevices; +import com.codename1.appkit.server.push.PushSender; +import com.codename1.appkit.server.push.RecordingPushSender; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Replay; +import com.codenameone.examples.splat.social.Crews; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Whole matches, run on the server's own rooms. The seats have no socket: +/// what goes over the wire is [GameSocket]'s, and these are about what the +/// server decides. +@BackendTest +class OnlineMatchTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rooms rooms; + @Autowired + private Crews crews; + @Autowired + private PushDevices devices; + @Autowired + private PushSender sender; + + /// Opening a party for a crew pushes everyone else in it, and only them. + @Test + void aCrewPartyCallsTheRestOfTheCrew() throws Exception { + String host = Api.player(accounts, "crewhost"); + String mate = Api.player(accounts, "crewmate"); + String stranger = Api.player(accounts, "stranger"); + devices.register(mate, new PushDeviceDto("fcm", "tok-mate", "android")); + devices.register(stranger, new PushDeviceDto("fcm", "tok-stranger", "android")); + String code = crews.create(host, "Night Shift").code(); + crews.join(mate, code); + RecordingPushSender pushes = (RecordingPushSender) sender; + rooms.host(profile(host), null, Arenas.DEFAULT, code); + assertEquals(1, pushes.sentTo("tok-mate").size(), "the crewmate hears about the party"); + assertTrue(pushes.sentTo("tok-stranger").isEmpty(), "nobody outside the crew does"); + // A crew code alone does not let a non-member call that crew. + rooms.host(profile(stranger), null, Arenas.DEFAULT, code); + assertEquals(1, pushes.sentTo("tok-mate").size()); + } + + // tag::splat-match-test[] + @Test + void aPartyMatchIsRecordedAndReplaysToTheSamePaints() throws Exception { + String host = Api.player(accounts, "host"); + String guest = Api.player(accounts, "guest"); + rooms.host(profile(host), null, "cul-de-sac:SNACK_BAR", null); + String code = rooms.partyCode(host); + assertFalse(code.isEmpty()); + rooms.join(profile(guest), null, code); + assertEquals("", rooms.raceOf(host)); + // Only the host starts the match. + rooms.go(guest, null); + assertEquals("", rooms.raceOf(host)); + rooms.go(host, null); + String id = rooms.raceOf(host); + assertFalse(id.isEmpty()); + assertEquals(id, rooms.raceOf(guest)); + + // Past the countdown, the host walks forward with the trigger held. + rooms.tick(System.currentTimeMillis() + GameProtocol.COUNTDOWN_MILLIS + 100); + rooms.input(host, 1, new Input(0, 100, 0, 0, Input.FIRE, -1)); + rooms.fastForward(id); + assertEquals("", rooms.raceOf(host)); + + List mine = Api.list(mvc, Api.as(get("/api/races"), host)); + assertEquals(1, mine.size()); + assertEquals(id, ((Map) mine.get(0)).get("id")); + + // The record is the field, the seed and every input. Played again + // here, with the same rules, every player paints exactly as many. + Map stored = Api.json(mvc, Api.as(get("/api/races/" + id), guest)); + Replay replay = Replay.decode((String) stored.get("replay")); + Match again = replay.play(); + assertTrue(again.over()); + List scores = (List) stored.get("scores"); + for (int i = 0; i < scores.size(); i++) { + assertEquals(((Number) scores.get(i)).intValue(), again.player(i).paints()); + } + } + // end::splat-match-test[] + + /// A player still seated in a match who joins a party has left that + /// match; the join used to be dropped without a word until it ended. + @Test + void joiningAPartyLeavesAMatchStillRunning() throws Exception { + String who = Api.player(accounts, "restless"); + String host = Api.player(accounts, "partyhost"); + rooms.quick(profile(who), null, Arenas.DEFAULT); + String id = rooms.raceOf(who); + assertFalse(id.isEmpty()); + rooms.host(profile(host), null, Arenas.DEFAULT, null); + rooms.join(profile(who), null, rooms.partyCode(host)); + assertEquals("", rooms.raceOf(who), "out of the old match"); + assertEquals(rooms.partyCode(host), rooms.partyCode(who), "and in the party"); + rooms.fastForward(id); + } + + /// Walking out of a match is never a win, even when the team left behind + /// goes on to take it. + @Test + void aLeaverNeverWins() throws Exception { + String host = Api.player(accounts, "stayer"); + String guest = Api.player(accounts, "quitter"); + rooms.host(profile(host), null, Arenas.DEFAULT, null); + rooms.join(profile(guest), null, rooms.partyCode(host)); + rooms.go(host, null); + String id = rooms.raceOf(host); + assertFalse(id.isEmpty()); + int wins = profile(guest).getWins(); + rooms.leave(guest); + assertEquals("", rooms.raceOf(guest)); + rooms.fastForward(id); + assertEquals(wins, profile(guest).getWins()); + } + + @Test + void aQuickMatchFillsWithBotsAndNeverCostsTrophies() throws Exception { + String who = Api.player(accounts, "lonely"); + int before = profile(who).getTrophies(); + rooms.quick(profile(who), null, Arenas.DEFAULT); + String id = rooms.raceOf(who); + assertFalse(id.isEmpty(), "the test profile gets bots at once"); + rooms.fastForward(id); + assertTrue(profile(who).getTrophies() >= before); + Map record = (Map) Api.list(mvc, Api.as(get("/api/races"), who)).get(0); + assertEquals((long) Rooms.QUICK_PLAYERS, Api.number(record, "runners")); + // A bot is not someone to report. + assertEquals(400, Api.statusOf(mvc, Api.as(Api.body(post("/api/reports"), + "{\"raceId\":\"" + id + "\",\"player\":\"1\",\"reason\":\"too good\"}"), who))); + } + + @Test + void anInputFloodIsCutAndOldSequencesAreDropped() { + MatchRoom room = new MatchRoom("r", Arenas.DEFAULT, "lot7", Arenas.mode(Arenas.DEFAULT), 7L, + new Seat[] {new Seat("a@x", "A", "semi", 0, 0, null), new Seat("", "Bot", "pump", 0, 1, null)}, 0L); + int accepted = 0; + for (int i = 1; i <= 100; i++) { + if (room.input(0, i, Input.IDLE)) { + accepted++; + } + } + assertTrue(accepted <= MatchRoom.FLOOD_LIMIT, "accepted " + accepted); + assertFalse(room.input(0, 3, Input.IDLE), "a replayed sequence is dropped"); + assertFalse(room.input(-1, 200, Input.IDLE)); + } + + /// Two inputs that land within one server tick are played on two ticks, + /// and each tick acknowledges only the input it used: the client + /// simulated one tick per input, and a trigger pull in the first is fired. + @Test + void inputsArrivingTogetherArePlayedOneTickEach() { + MatchRoom room = new MatchRoom("r", Arenas.DEFAULT, "lot7", Arenas.mode(Arenas.DEFAULT), 7L, + new Seat[] {new Seat("a@x", "A", "semi", 0, 0, null), new Seat("", "Bot", "pump", 0, 1, null)}, 0L); + assertTrue(room.input(0, 1, new Input(0, 0, 0, 0, Input.FIRE, -1))); + assertTrue(room.input(0, 2, Input.IDLE)); + room.step(); + assertEquals(1, room.seats[0].appliedSeq); + room.step(); + assertEquals(2, room.seats[0].appliedSeq); + } + + @Test + void anAimbotFlickIsCounted() { + MatchRoom room = new MatchRoom("r", Arenas.DEFAULT, "lot7", Arenas.mode(Arenas.DEFAULT), 7L, + new Seat[] {new Seat("a@x", "A", "semi", 0, 0, null), new Seat("", "Bot", "pump", 0, 1, null)}, 0L); + room.input(0, 1, new Input(0, 0, 0, 0, 0, -1)); + room.step(); + room.input(0, 2, new Input(0, 0, 2048, 0, 0, -1)); + assertEquals(1, room.seats[0].snaps); + } + + @Test + void trophiesBalanceAndAloneNeverGoNegative() { + assertEquals(0, Results.trophies(0, true, 8)); + assertEquals(Results.WIN_TROPHIES, Results.trophies(0, false, 8)); + assertEquals(-Results.LOSS_TROPHIES, Results.trophies(1, false, 8)); + assertTrue(Results.trophies(1, false, 1) >= 0); + } + + private Profile profile(String username) { + return accounts.profile(username); + } +} diff --git a/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/ProgressTest.java b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/ProgressTest.java new file mode 100644 index 00000000000..e1086a1eb80 --- /dev/null +++ b/scripts/splat/backend/src/test/java/com/codenameone/examples/splat/game/ProgressTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.game; + +import com.codename1.backend.annotations.Autowired; +import com.codename1.backend.test.BackendTest; +import com.codename1.backend.test.MockMvc; +import com.codenameone.examples.splat.Api; +import com.codenameone.examples.splat.account.Accounts; +import com.codenameone.examples.splat.domain.Profile; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static com.codename1.backend.test.MockMvcRequestBuilders.get; +import static com.codename1.backend.test.MockMvcRequestBuilders.post; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Levels, quests, chests and achievements, all granted by the server. +@BackendTest +class ProgressTest { + @Autowired + private MockMvc mvc; + @Autowired + private Accounts accounts; + @Autowired + private Rooms rooms; + + @Test + void aNewPlayerStartsAtLevelOneWithThreeDistinctQuests() throws Exception { + String who = Api.player(accounts, "rookie"); + Map p = Api.json(mvc, Api.as(get("/api/progress"), who)); + assertEquals(1L, Api.number(p, "level")); + assertEquals(100L, Api.number(p, "levelEnd")); + List quests = (List) p.get("quests"); + assertEquals(3, quests.size()); + assertTrue(!((Map) quests.get(0)).get("id").equals(((Map) quests.get(1)).get("id"))); + assertTrue(!((Map) quests.get(1)).get("id").equals(((Map) quests.get(2)).get("id"))); + assertEquals(409, Api.statusOf(mvc, Api.as(Api.body(post("/api/progress/quest"), + "{\"questId\":\"" + ((Map) quests.get(0)).get("id") + "\"}"), who))); + assertEquals(404, Api.statusOf(mvc, Api.as(Api.body(post("/api/progress/quest"), + "{\"questId\":\"nope\"}"), who))); + assertEquals(409, Api.statusOf(mvc, Api.as(post("/api/progress/chest"), who))); + } + + @Test + void theTutorialPaysOnceAndLevelsUp() throws Exception { + String who = Api.player(accounts, "trainee"); + long coins = accounts.profile(who).getCoins(); + Map r = Api.json(mvc, Api.as(post("/api/progress/tutorial"), who)); + assertTrue(Boolean.TRUE.equals(r.get("levelUp")) || "true".equals(String.valueOf(r.get("levelUp")))); + assertEquals("Waiver Signed", ((List) r.get("unlocked")).get(0)); + Map again = Api.json(mvc, Api.as(post("/api/progress/tutorial"), who)); + assertEquals(0L, Api.number(again, "coins")); + Profile after = accounts.profile(who); + assertEquals(coins + Api.number(r, "coins"), after.getCoins()); + // The tutorial chest and the level-up chest. + Map p = Api.json(mvc, Api.as(get("/api/progress"), who)); + assertEquals(2L, Api.number(p, "chests")); + Map chest = Api.json(mvc, Api.as(post("/api/progress/chest"), who)); + assertTrue(Api.number(chest, "coins") > 0); + assertEquals(1L, Api.number(Api.json(mvc, Api.as(get("/api/progress"), who)), "chests")); + } + + @Test + void aMatchCountsTowardsQuestsAndExperience() throws Exception { + String who = Api.player(accounts, "grinder"); + rooms.quick(accounts.profile(who), null, Arenas.DEFAULT); + rooms.fastForward(rooms.raceOf(who)); + Map p = Api.json(mvc, Api.as(get("/api/progress"), who)); + assertTrue(Api.number(p, "xp") >= 15); + Map veteran = null; + for (Object a : (List) p.get("achievements")) { + if ("veteran".equals(((Map) a).get("id"))) { + veteran = (Map) a; + } + } + assertEquals(1L, Api.number(veteran, "progress")); + } +} diff --git a/scripts/splat/check-template.sh b/scripts/splat/check-template.sh new file mode 100755 index 00000000000..1cf0f123284 --- /dev/null +++ b/scripts/splat/check-template.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Builds what the Initializr generates from this project. +# +# check-template.sh [work-directory] +# +# The Initializr offers this application as a template, from resources that +# scripts/sync-initializr-template.py derives from it. That the resources match +# is one question; this is the other: whether a project generated from them, +# under another name and in another package, still builds. The real generator +# makes the project, and it is then built against this checkout: +# +# the server's tests pass against the contract module +# the app builds from common, without platform modules and with them, and +# the client half of the contract is generated into it +# +# Needs this checkout's artifacts installed (core, javase, css-compiler, the +# Maven plugin, backend, backend-test), a JDK 17 or newer as JAVA_HOME, python3 +# and unzip. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +MVN="${MVN:-mvn}" +WORK="${1:-$HERE/target/template-check}" + +log() { echo "[splat-template] $*" >&2; } + +python3 "$ROOT/scripts/sync-initializr-template.py" splat + +# The version this checkout installs, which the generated poms are pointed at: +# the release the Initializr generates against does not have this project's +# build support until one that follows this change. +VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$ROOT/maven/pom.xml" | head -n 1)" +[ -n "$VERSION" ] || { log "could not read the version from maven/pom.xml"; exit 1; } + +rm -rf "$WORK" +mkdir -p "$WORK" +log "generating the Initializr's downloads" +python3 "$ROOT/scripts/tests/generate-initializr-fixtures.py" "$WORK/fixtures" > "$WORK/fixtures.log" 2>&1 \ + || { tail -n 60 "$WORK/fixtures.log" >&2; log "the generator failed"; exit 1; } + +# extract -- a generated project, pointed at this checkout's version +extract() { + local dir="$WORK/$1" + mkdir -p "$dir" + unzip -q "$WORK/fixtures/SPLAT-$1-INTELLIJ.zip" -d "$dir" + python3 - "$dir/pom.xml" "$VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read() +for tag in ('cn1.plugin.version', 'cn1.version'): + text, count = re.subn(r'<%s>[^<]*' % (tag, tag), '<%s>%s' % (tag, version, tag), text) + assert count == 1, (tag, count) +open(path, 'w', encoding='utf-8').write(text) +PY + echo "$dir" +} + +# The fixtures are generated as com.example.probe.LauncherProbe. +PACKAGE=com/example/probe + +minimal="$(extract APP_WITH_BACKEND)" +log "running the generated server's tests" +(cd "$minimal" && "$MVN" -B -q -Dcodename1.platform=backend -pl rules,shared,backend -am test) +ls "$minimal"/backend/target/surefire-reports/TEST-*.xml >/dev/null 2>&1 \ + || { log "the generated server's tests did not run"; exit 1; } +[ -f "$minimal/backend/target/generated-sources/cn1-annotations/$PACKAGE/api/AccountApiServer.java" ] \ + || { log "the server's half of the contract was not generated"; exit 1; } + +for layout in APP_WITH_BACKEND FULL; do + project="$WORK/$layout" + [ -d "$project" ] || project="$(extract "$layout")" + log "building the generated app ($layout)" + (cd "$project" && "$MVN" -B -q -Dcodename1.platform=javase -Djava.awt.headless=true -DskipTests package) + for built in "classes/theme.res" "classes/$PACKAGE/api/AccountApiImpl.class" "classes/cn1app/RestClientBootstrap.class"; do + [ -f "$project/common/target/$built" ] \ + || { log "$layout: common/target/$built was not built"; exit 1; } + done +done +log "passed" diff --git a/scripts/splat/common/codenameone_settings.properties b/scripts/splat/common/codenameone_settings.properties new file mode 100644 index 00000000000..d351f069dba --- /dev/null +++ b/scripts/splat/common/codenameone_settings.properties @@ -0,0 +1,51 @@ +codename1.android.keystore= +codename1.android.keystoreAlias= +codename1.android.keystorePassword= +codename1.arg.and.themeMode=modern +# A server started on this machine is reached over plain HTTP, which Android +# refuses by default. Remove this once the game talks to a server with a +# certificate. +codename1.arg.android.xapplication_attr=android\:usesCleartextTraffic\="true" +# A game keeps its own title and back arrow on the desktop; "native" would +# hide the Toolbar and move Back into the OS menu bar. +codename1.arg.desktop.titleBar=toolbar +codename1.arg.ios.newStorageLocation=true +# Push notifications: challenges, chest reminders, and flags for an admin. +codename1.arg.ios.includePush=true +# splat://tour starts the self-driving walkthrough (see Tour.java). +codename1.arg.ios.urlScheme=splat +codename1.arg.android.xintent_filter= +# Android push is off until the game has its Firebase file: a build that +# uses PushClient without google-services.json stops with an error. Once +# native/android/google-services.json is in place, delete this line. +codename1.arg.android.messagingService=none +# The shop sells through Google Play, and an Android build that uses in-app +# purchase needs the app's licensing key. This is a placeholder that builds +# but verifies nothing: paste the Base64 RSA public key from the Play +# Console's "Monetization setup" before release. +codename1.arg.android.licenseKey=CN1TESTPLACEHOLDERKEYNOTFORPRODUCTIONxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxIDAQAB +# Runs are played in portrait. +codename1.arg.android.screenOrientation=portrait +codename1.arg.ios.interface_orientation=UIInterfaceOrientationPortrait +codename1.arg.ios.themeMode=modern +codename1.arg.java.version=17 +codename1.cssTheme=true +codename1.displayName=Splat +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.examples.splat +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.ios.release.provision= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=Splat +codename1.packageName=com.codenameone.examples.splat +codename1.secondaryTitle=Splat +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/splat/common/icon.png b/scripts/splat/common/icon.png new file mode 100644 index 00000000000..7bd9aff943f Binary files /dev/null and b/scripts/splat/common/icon.png differ diff --git a/scripts/splat/common/pom.xml b/scripts/splat/common/pom.xml new file mode 100644 index 00000000000..98e6e71733c --- /dev/null +++ b/scripts/splat/common/pom.xml @@ -0,0 +1,1388 @@ + + + 4.0.0 + + com.codenameone.examples.splat + splat + 1.0-SNAPSHOT + + com.codenameone.examples.splat + splat-common + 1.0-SNAPSHOT + jar + + + + + com.codenameone + codenameone-core + provided + + + + com.codenameone + cn1-app-kit-lib + ${cn1.version} + pom + + + + ${project.groupId} + splat-shared + ${project.version} + + + com.codenameone.examples.splat + splat-rules + ${project.version} + + + + + com.codenameone + codenameone-javase + test + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + + + + + install-codenameone + ${user.home}/.codenameone/guibuilder.jar + + + + org.apache.maven.plugins + maven-antrun-plugin + + + + validate + + run + + + + + + + + + + + + + + + + + + + + + + + + + + + android-compat + + + ${basedir}/src/main/android + + + + + com.codenameone + codenameone-android-compat + provided + + + + + + kotlin + + + + ${basedir}/src/main/kotlin + + + + 2.2.10 + true + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + + + org.jetbrains + annotations + 13.0 + + + com.codenameone + java-runtime + provided + + + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + process-resources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + ${project.basedir}/src/main/android/kotlin + ${project.basedir}/src/main/android/java + ${project.build.directory}/generated-sources/android + + + -no-reflect + -no-jdk + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + -no-reflect + -no-jdk + + + + + + + + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + + + cn1-javase-natives + + + ${basedir}/../javase/pom.xml + + + + + + com.codenameone + codenameone-maven-plugin + + + compile-javase-natives + process-classes + + compile-javase-natives + + + javase + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + org.apache.maven.surefire + surefire-junit-platform + 3.0.0-M5 + + + + ${skipTests} + + ${project.build.directory}/cn1-javase/classes + + + + + + + + + cn1-host-javase + + + codename1.platform + javase + + + ${basedir}/../javase/pom.xml + + + + javase + javase + none + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javase + validate + + hosted-platform + + + javase + + + + build-javase + package + + build + + + javase + + + + + + + + + + cn1-host-android + + + codename1.platform + android + + + ${basedir}/../android/pom.xml + + + + android + android + android-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-android + validate + + hosted-platform + + + android + + + + build-android + package + + build + + + android + + + + + + + + + + cn1-host-ios + + + codename1.platform + ios + + + ${basedir}/../ios/pom.xml + + + + ios + ios + ios-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-ios + validate + + hosted-platform + + + ios + + + + build-ios + package + + build + + + ios + + + + + + + + + + cn1-host-javascript + + + codename1.platform + javascript + + + ${basedir}/../javascript/pom.xml + + + + javascript + javascript + local-javascript + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-javascript + validate + + hosted-platform + + + javascript + + + + build-javascript + package + + build + + + javascript + + + + + + + + + + cn1-host-win + + + codename1.platform + win + + + ${basedir}/../win/pom.xml + + + + win + win + windows-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-win + validate + + hosted-platform + + + win + + + + build-windows + package + + build + + + win + + + + + + + + + + cn1-host-linux + + + codename1.platform + linux + + + ${basedir}/../linux/pom.xml + + + + linux + linux + linux-device + + + + + com.codenameone + codenameone-maven-plugin + + + hosted-linux + validate + + hosted-platform + + + linux + + + + build-linux + package + + build + + + linux + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + idea-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator-idea + verify + + java + + + ${cn1.hosted.javase.skip} + + ${project.build.directory}/cn1-javase/classes + + + ${codename1.mainClass} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + + + + + debug-simulator + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + debug-eclipse + + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-in-simulator + verify + + exec + + + ${cn1.hosted.javase.skip} + ${basedir} + java + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + @${cn1.hosted.classpathArgFile} + ${exec.mainClass} + ${codename1.mainClass} + + + + + + + + + + + run-desktop + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + ${cn1.hosted.javase.skip} + ${codename1.packageName}.${codename1.mainName}Stub + + + ${project.build.directory}/cn1-javase/classes + ${project.build.directory}/cn1-javase/desktop + ${basedir}/src/desktop/resources + ${basedir}/../javase/src/desktop/resources + + + + + + + + + + + executable-jar + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + javase + + + + javase-executable-jar + package + + javase-executable-jar + + + javase + + + + + + + + + + test + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + runtime + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + + + + com.codenameone + codenameone-maven-plugin + + + cn1-tests + test + + test + + + javase + + + + + + + + + + run-android + + + + com.codenameone + codenameone-maven-plugin + + + + run-android-hosted + validate + + hosted-platform + + + android + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + ${cn1.hosted.android.skip} + + Running adb install + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + + + ios-debug + + + iphone + + + ios + + + + + ios-release + + + iphone + true + + + ios + true + + + + + javascript + + javascript + javascript + + + + + android + + android + android + + + + + win + + windows + win + + + + + windows + + desktop_windows + javase + + + + + mac + + desktop_macosx + javase + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + + + transcode-svg + generate-sources + + transcode-svg + + + + + compile-android-res + generate-sources + + compile-android-res + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + + remap-android + bytecode-compliance + css + + process-annotations + + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + + diff --git a/scripts/splat/common/src/desktop/resources/NativeTheme.res b/scripts/splat/common/src/desktop/resources/NativeTheme.res new file mode 100644 index 00000000000..83e067b6969 Binary files /dev/null and b/scripts/splat/common/src/desktop/resources/NativeTheme.res differ diff --git a/scripts/splat/common/src/main/css/fonts/OFL.txt b/scripts/splat/common/src/main/css/fonts/OFL.txt new file mode 100644 index 00000000000..6dd809659a8 --- /dev/null +++ b/scripts/splat/common/src/main/css/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/scripts/splat/common/src/main/css/fonts/Rajdhani-Bold.ttf b/scripts/splat/common/src/main/css/fonts/Rajdhani-Bold.ttf new file mode 100644 index 00000000000..47af15713b4 Binary files /dev/null and b/scripts/splat/common/src/main/css/fonts/Rajdhani-Bold.ttf differ diff --git a/scripts/splat/common/src/main/css/fonts/Rajdhani-Medium.ttf b/scripts/splat/common/src/main/css/fonts/Rajdhani-Medium.ttf new file mode 100644 index 00000000000..a6960c6d7f5 Binary files /dev/null and b/scripts/splat/common/src/main/css/fonts/Rajdhani-Medium.ttf differ diff --git a/scripts/splat/common/src/main/css/theme.css b/scripts/splat/common/src/main/css/theme.css new file mode 100644 index 00000000000..6451e6889df --- /dev/null +++ b/scripts/splat/common/src/main/css/theme.css @@ -0,0 +1,2403 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +/* + * The whole look of Splat. + * + * Every screen is built from ordinary Codename One widgets -- Button, + * TextField, Label, Tabs, Switch -- and this file makes them read like the + * menus of a military shooter: near-black and gunmetal, square edges with a + * hairline, Rajdhani in capitals and one amber accent for whatever you should + * press next. Friendly is blue and hostile red everywhere. Change the palette + * below to re-skin the game; change a rule to re-shape one widget everywhere + * it appears. + * + * The field itself is not styled here: play/ArenaView.java draws it with + * com.codename1.gaming, and play/Hud.java paints the HUD over it in the + * colours of play/Type.java. + */ +@font-face { + font-family: "Rajdhani Bold"; + src: url("fonts/Rajdhani-Bold.ttf"); +} +@font-face { + font-family: "Rajdhani Medium"; + src: url("fonts/Rajdhani-Medium.ttf"); +} +#Constants { + includeNativeBool: true; + defaultSourceDPIInt: "0"; + endsWith3PointsBool: true; + transitionSpeedInt: "250"; + /* Alerts fade in on the game's own dialog, with carved buttons, instead + of the native theme's pop-in white card. */ + dialogTransitionIn: "fade"; + dialogTransitionOut: "fade"; + dlgCommandGridBool: false; + dlgButtonCommandUIID: "DialogAction"; + /* Modals dim the game to near-black, not the default grey. */ + tintColor: "d8050607"; + /* Lists scroll by touch; the bar is noise on a phone. */ + scrollVisibleBool: false; + /* The admin console's tabs are the desk's own flat cells (DeskTab). The + native selection capsule drew its glass pill around them as well, two + highlights colliding on one tab. */ + tabsSelectionCapsuleBool: false; + tabsAnimatedIndicatorBool: false; + /* tag::splat-palette[] */ + --bg: #0b0d10; + --surface: #161a1f; + --surface-light: #20262d; + --edge: #2e353d; + --edge-light: #4f5862; + --coral: #f2b632; + --coral-light: #ffd166; + --coral-dark: #a8770f; + --volt: #f2b632; + --volt-dark: #a8770f; + --cyan: #4aa8ff; + --violet: #5c6670; + --violet-glow: #aab3bd; + --ice: #e8ecef; + --ink: #0b0d10; + --text: #f2f4f5; + --text-muted: #8a949e; + --danger: #f0443a; + /* end::splat-palette[] */ +} + +Form, GameForm, RunScreen { + background: linear-gradient(to bottom, #171b20, var(--bg)); + color: var(--text); +} + +RunScreen { + padding: 0; +} + +GameToolbar { + background-color: var(--bg); + border-bottom: 0.2mm solid var(--edge); + padding: 1mm; +} + +GameTitle { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 4mm; + text-align: center; +} + +Container { + background-color: transparent; +} + +Label { + color: var(--text); + font-family: "Rajdhani Medium"; + font-size: 3mm; +} + +Logo { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 9mm; + text-align: center; + margin: 4mm 0 0 0; +} + +/* A dark pill, so the line reads over the skyline and the sun. */ +Tagline { + background-color: rgba(11, 14, 29, 0.78); + color: var(--ice); + font-family: "Rajdhani Bold"; + font-size: 2.8mm; + text-align: center; + border-radius: 0; + padding: 0.8mm 3mm; + margin: 0 0 4mm 0; +} + +SectionTitle { + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 2.8mm; + margin: 3.5mm 2mm 1mm 2.5mm; +} + +/* tag::splat-primary-button[] */ +/* The call to action: a glossy coral pill, sunset orange at the top, that + darkens when touched. */ +PrimaryButton { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + border: 0.3mm solid var(--coral-dark); + border-radius: 0; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3.8mm; + text-align: center; + padding: 2.6mm; + /* The same 2mm inset as the section titles and panels above it. */ + margin: 1.2mm 2mm; +} + +PrimaryButton.pressed { + background: linear-gradient(to bottom, var(--coral), var(--coral-dark)); + color: var(--ice); +} + +PrimaryButton.disabled { + background-color: var(--surface-light); + color: var(--text-muted); +} +/* end::splat-primary-button[] */ + +GhostButton, PriceButton, GemButton, ReplayButton { + background-color: var(--surface); + border: 0.35mm solid var(--edge-light); + border-radius: 0; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 1.9mm; + margin: 1mm 2mm; +} + +GhostButton.pressed, PriceButton.pressed, GemButton.pressed, ReplayButton.pressed { + background-color: var(--edge); +} + +GemButton, GemButton.selected { + background-color: #f2b632; + border: 0.35mm solid #f2b632; + color: #0c0e10; +} + +/* the store is offline: say so in grey, never amber text on amber */ +GemButton.disabled { + background-color: #1c2127; + border: 0.35mm solid #4f5862; + color: #8a939c; +} + +DangerButton { + background-color: transparent; + border: 0.35mm solid var(--danger); + border-radius: 0; + color: var(--danger); + font-family: "Rajdhani Bold"; + text-align: center; + padding: 1.8mm; + margin: 1mm 2mm; +} + +AdminButton { + background-color: var(--surface); + border: 0.4mm solid var(--volt); + border-radius: 0; + color: var(--volt); + font-family: "Rajdhani Bold"; + text-align: center; + padding: 1.8mm; + margin: 2mm; +} + +LinkButton { + background-color: transparent; + color: var(--text-muted); + text-align: center; + font-size: 2.5mm; + padding: 1mm; +} + +/* A glass card that groups fields and buttons. */ +Panel { + background-color: var(--surface); + border: 0.25mm solid var(--edge); + border-radius: 0; + padding: 2.5mm; + margin: 1.5mm 2mm; +} + +GameField { + background-color: var(--bg); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + color: var(--text); + font-size: 3.2mm; + padding: 2.2mm; + margin: 1mm 0; +} + +TextHint { + color: #5f6a95; +} + +/* tag::splat-name-field-css[] */ +/* The runner-name entry is a volt-green plate: the name is the one thing a + player types that everyone else sees. */ +NameField { + background: linear-gradient(to bottom, #ffd166, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 0; + color: var(--ink); + font-family: "Rajdhani Bold"; + font-size: 5mm; + /* Left, like every other field: a centred native editor put the caret + at one edge and the text at the other while typing. */ + text-align: left; + padding: 2.5mm 3mm; + margin: 1mm 0; +} + +/* end::splat-name-field-css[] */ + +WalletBar { + background-color: var(--surface); + padding: 1.5mm; +} + +Trophies, Coins, Gems { + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + text-align: center; +} + +Trophies { + color: var(--volt); +} + +Coins { + color: #ffc93a; +} + +Gems { + color: var(--cyan); +} + +/* tag::splat-card-slot[] */ +CardSlot, CardSlotSelected { + background-color: var(--surface); + border: 0.3mm solid var(--edge); + border-radius: 0; + color: var(--violet-glow); + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + text-align: center; + padding: 1.2mm; + margin: 0.8mm; +} + +CardSlotSelected { + border: 0.6mm solid var(--volt); + background-color: var(--surface-light); +} + +CardSlot.disabled { + background-color: var(--bg); + color: #4a5280; +} +/* end::splat-card-slot[] */ + +ReplayBar { + background-color: var(--surface); + padding: 0.8mm; +} + +PlayerName { + color: var(--cyan); +} + +PlayerName { + color: #8db8ff; +} + +RunClock { + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 4mm; + text-align: center; +} + +/* tag::splat-elixir-css[] */ + +/* end::splat-elixir-css[] */ + +/* Quick chat in a race: glassy chips with a cyan rim, inset from the edges. */ +EmoteButton { + background-color: rgba(21, 26, 51, 0.85); + border: 0.3mm solid var(--cyan); + border-radius: 0; + color: var(--ice); + font-family: "Rajdhani Bold"; + font-size: 2.4mm; + text-align: center; + padding: 0.9mm 0.5mm; + margin: 0.5mm 0.8mm 1.5mm 0.8mm; +} + +ChallengeCode { + background-color: var(--ice); + border-radius: 0; + color: var(--ink); + font-family: "Rajdhani Bold"; + font-size: 7mm; + padding: 2mm 4mm; +} + +ChallengeUrl, Empty { + color: var(--text-muted); + font-size: 2.6mm; + text-align: center; + padding: 1mm 2mm; + margin: 0; +} + +Spotlight { + background-color: var(--surface); + border: 0.4mm solid var(--volt); + border-radius: 0; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 2.4mm; + text-align: left; + padding: 1mm 2mm; + margin: 0 4mm 1.5mm 4mm; +} + +Spotlight.pressed { + background-color: var(--bg); +} + +SpotlightIcon { + color: var(--volt); + padding: 0 1.5mm 0 0; +} + +SpotlightText { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: left; +} + +InviteCode { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 5mm; + text-align: center; + margin: 2mm; +} + +Warning { + color: #ffb74d; + font-size: 2.8mm; + margin: 1mm 2mm; +} + +ShopItem, LeaderRow, LeaderTop, SettingRow { + background-color: var(--surface); + border-radius: 0; + padding: 2mm; + margin: 1mm 2mm; +} + +LeaderTop { + border: 0.4mm solid var(--volt); +} + +ShopItemName, LeaderName, SettingLabel { + color: var(--text); + font-size: 3mm; +} + +LeaderRank { + color: var(--text-muted); + font-family: "Rajdhani Bold"; + padding: 0 2mm 0 0; +} + +Owned { + color: #7be38a; + font-family: "Rajdhani Bold"; +} + +HistoryRow, AdminRow, FlagReport, FlagAuto { + background-color: var(--surface); + border-radius: 0; + padding: 1.5mm; + margin: 0.8mm 2mm; +} + +FlagReport { + border: 0.4mm solid #ffb74d; +} + +FlagAuto { + border: 0.4mm solid var(--coral); +} + +HistoryWin { + color: var(--volt); + font-family: "Rajdhani Bold"; +} + +HistoryLoss { + color: #ff9a92; + font-family: "Rajdhani Bold"; +} + +HistoryDraw { + color: var(--text-muted); + font-family: "Rajdhani Bold"; +} + +MultiLine1 { + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; +} + +MultiLine2, MultiLine3 { + color: var(--text-muted); + font-size: 2.5mm; +} + +AdminStat { + background-color: var(--surface); + border: 0.4mm solid var(--edge); + border-radius: 0; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + padding: 2.5mm; + margin: 1mm; +} + +AdminDetail { + color: var(--text); + font-size: 2.8mm; +} + +AdminTabs { + background-color: var(--bg); +} + +Tab { + background-color: var(--surface); + color: var(--text-muted); + font-size: 2.6mm; + padding: 1.5mm; +} + +Tab.selected { + color: var(--volt); + border-bottom: 0.6mm solid var(--volt); +} + +Dialog { + background-color: var(--surface); + border: 0.5mm solid var(--coral); + border-radius: 0; + padding: 2mm; +} + +DialogBody, DialogTitle { + color: var(--text); +} + +DialogTitle { + color: var(--volt); + font-family: "Rajdhani Bold"; +} + +AdDialog { + background-color: #000000; + color: #ffffff; +} + +Slider { + background-color: var(--edge); + border-radius: 0; + padding: 0.8mm; +} + +SliderFull { + background-color: var(--volt); + border-radius: 0; +} + +/* Text sits on the panel or backdrop under it: the native theme gives a + label an opaque ground, which a game screen must not have. */ +Label, SpanLabel, SpanLabelText, Logo, Tagline, SectionTitle, Trophies, Coins, Gems, PlayerName, RunClock, ChallengeUrl, Empty, Warning, ShopItemName, LeaderName, SettingLabel, LeaderRank, Owned, HistoryWin, HistoryLoss, HistoryDraw, MultiLine1, MultiLine2, MultiLine3, AdminDetail, GameTitle, Title, TextHint, ForgeLabel, ForgeHelp, ForgeHelpText, ForgeByline, ForgeValue, ForgeChoiceArrow, LobbyMemberName, LobbyHostTag, LobbySay, InviteCode, SpotlightText, SpotlightIcon, Container { + background-color: transparent; +} + +StatusBar, StatusBarSideMenu { + background-color: var(--surface); +} + +TabsContainer { + background-color: var(--surface); + border: none; + padding: 0; + margin: 0; +} + +Tab, Tab.selected, Tab.pressed { + background-color: var(--surface); + border: none; + border-radius: 0; + margin: 0; +} + +Tab.selected { + border-bottom: 0.6mm solid var(--volt); +} + +ContentPane, Toolbar, TitleArea { + background-color: transparent; +} + +FlagReport, FlagAuto { + color: var(--text); +} + +/* tag::splat-progress-css[] */ +/* Home, progress and celebrations. Every piece of chrome around the art is + one of these; the art itself (cards, heroes) comes from ui/CardArt.java. */ +HomeHeader { + background-color: var(--surface); + border-bottom: 0.25mm solid var(--edge); +} + +PlayerNameBig { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3.6mm; + padding: 0 1.5mm; +} + +LevelBadge, LevelBadgeBig { + background-color: var(--violet); + border: 0.3mm solid var(--violet-glow); + border-radius: 0; + color: #ffffff; + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 1.8mm; + margin: 0 1.5mm; +} + +LevelBadgeBig { + background: linear-gradient(to bottom, #ffd166, var(--volt)); + color: var(--ink); + border: 0.4mm solid var(--volt-dark); +} + +XpText, Muted { + background-color: transparent; + color: var(--text-muted); + font-size: 2.4mm; + padding: 0 1.5mm; +} + +Muted { + background-color: transparent; + text-align: center; +} + +HeroStage { + background-color: transparent; + padding: 1mm 0 0 0; +} + +LeagueName { + background-color: transparent; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 3.4mm; + margin: 1.6mm 0 0 0; + text-align: center; +} + +GoButton { + background: linear-gradient(to bottom, #ffd166, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 0; + color: var(--ink); + font-family: "Rajdhani Bold"; + font-size: 7mm; + text-align: center; + padding: 3.5mm; + margin: 2mm 4mm; +} + +GoButton.pressed { + background: linear-gradient(to bottom, var(--volt), var(--volt-dark)); + color: var(--ink); +} + +CrateSlot, CrateSlotEmpty { + background-color: var(--surface); + border: 0.35mm solid var(--cyan); + border-radius: 0; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + text-align: center; + padding: 1.2mm 2.5mm; + margin: 0 1.2mm 1.5mm 1.2mm; +} + +CrateSlot.pressed { + background-color: var(--edge); +} + +CrateSlotEmpty { + background-color: var(--bg); + border: 0.3mm solid var(--edge); + color: var(--edge-light); +} + +QuestCard { + background-color: var(--surface); + border: 0.4mm solid var(--edge); + border-radius: 0; + padding: 1.5mm 2mm; + margin: 0.8mm 0; +} + +QuestTitle { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; +} + +QuestReward { + color: #ffc93a; +} + +ClaimButton { + background: linear-gradient(to bottom, #ffd166, var(--volt)); + border: 0.3mm solid var(--volt-dark); + border-radius: 0; + color: var(--ink); + font-family: "Rajdhani Bold"; + font-size: 2.8mm; + text-align: center; + padding: 1.2mm 2.8mm; + margin: 0 0 0 1.5mm; +} + +ClaimButton.pressed { + background-color: var(--volt-dark); +} + +Claimed { + color: var(--volt); +} + +/* The runner in play: CardAction's box, so its card ends level with the + others' buttons. */ +RunnerChip { + background-color: rgba(212, 255, 58, 0.12); + border: 0.3mm solid var(--volt); + border-radius: 0; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 2.5mm; + text-align: center; + padding: 1.4mm 0.5mm; + margin: 2mm 0.8mm 0 0.8mm; +} + +RunnerStageName { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 5mm; + text-align: center; + padding: 0 0 3mm 0; + margin: 0; +} + +NavTile { + background: linear-gradient(to bottom, var(--edge-light), var(--edge)); + border: 0.4mm solid var(--edge); + border-radius: 0; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + text-align: center; + padding: 1.5mm 0.5mm; + margin: 0.8mm; +} + +NavTile.pressed { + background-color: var(--edge); +} + +RewardDialog { + background: linear-gradient(to bottom, #20262d, var(--bg)); + border: 0.8mm solid var(--volt); + border-radius: 0; + padding: 2mm 3mm; +} + +RewardCard { + background: linear-gradient(to bottom, #20262d, var(--bg)); + border: 0.8mm solid var(--volt); + border-radius: 0; + padding: 2mm 3mm; + margin: 2mm; +} + +RewardBody { + background-color: transparent; + padding: 0; +} + +RewardTitle, LevelUpTitle { + background-color: transparent; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 4mm; + text-align: center; +} + +LevelUpTitle { + background-color: transparent; + color: #ffffff; + font-size: 4.4mm; +} + +RewardHero { + background-color: transparent; + padding: 0; + margin: 0; +} + +RewardCoins, RewardGems, RewardXp, RewardChest { + background-color: #0e1224; + border-radius: 0; + font-family: "Rajdhani Bold"; + font-size: 2.8mm; + padding: 0.8mm 1.6mm; + margin: 0.6mm; +} + +RewardCoins { color: #ffc93a; } +RewardGems { color: var(--cyan); } +RewardXp { color: var(--volt); } +RewardChest { color: var(--volt); } + +RewardAchievement { + background-color: transparent; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 3mm; +} + +AchievementRow, AchievementLocked { + background-color: var(--surface); + border: 0.4mm solid var(--volt); + border-radius: 0; + padding: 1.5mm; + margin: 0.8mm 0; +} + +AchievementLocked { + border: 0.4mm solid var(--edge); +} + +CoachBubble { + background-color: var(--surface-light); + border: 0.35mm solid var(--cyan); + border-radius: 0; + padding: 2mm; + margin: 2mm; +} + +CoachName { + background-color: transparent; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 2.6mm; +} + +CoachText { + background-color: transparent; + color: var(--text); + font-size: 3.2mm; +} + +CoachHint { + background-color: transparent; + color: var(--text-muted); + font-size: 2.4mm; +} + +TutorialSkip { + background-color: transparent; + color: #ffffff; + font-size: 2.6mm; + padding: 1mm 2mm; +} + +SignInHero { + background-color: transparent; + padding: 0; + margin: 0; + text-align: center; +} + +/* end::splat-progress-css[] */ + +/* The toolbar's back arrow: a bare gold glyph, no native button chrome. */ +BackCommand, BackCommand.pressed, TitleCommand, TitleCommand.pressed { + background-color: transparent; + border: none; + color: var(--volt); + padding: 1mm; + margin: 0; +} + +BackCommand.pressed, TitleCommand.pressed { + color: var(--coral-light); +} + +DialogCommandArea { + background-color: transparent; + padding: 1.5mm 0 0 0; +} + +DialogAction { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + border: 0.5mm solid var(--coral-dark); + border-radius: 0; + color: var(--ink); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 1.5mm 4mm; + margin: 0 1mm; +} + +DialogAction.pressed { + background-color: var(--coral-dark); + color: var(--ice); +} + +/* The Forge: the pack browser and the editors. Rows are stone panels, labels + sit left and values right, and the small steppers are carved like the big + buttons so the editor feels like part of the game. */ +ForgeRow { + background-color: var(--edge); + border-radius: 0; + padding: 1.5mm 2mm; + margin: 0.8mm 2mm; +} + +ForgeLabel { + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: left; + padding: 0.5mm 2mm 0.5mm 0.5mm; + margin: 0; +} + +ForgeValue { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 0.5mm 1.5mm; + margin: 0; +} + +ForgeField { + background-color: var(--bg); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + color: var(--text); + font-size: 3mm; + text-align: left; + padding: 1.2mm; + margin: 0.5mm 0; +} + +ForgeChoice { + background-color: transparent; + color: var(--text); + font-size: 3mm; + text-align: left; + padding: 0; + margin: 0; +} + +ForgeChoiceArrow { + background-color: transparent; + color: var(--text); + padding: 0 0 0 1mm; + margin: 0; +} + +ForgeStep { + background-color: rgba(46, 230, 255, 0.12); + border: 0.35mm solid var(--cyan); + border-radius: 0; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 1mm 2mm; + margin: 0 0.5mm; +} + +ForgeStep.pressed { + background-color: var(--cyan); + color: var(--ink); +} + +ForgeCheck { + color: var(--text); + font-size: 3mm; + text-align: left; + padding: 1mm; + margin: 0.5mm 2mm; +} + +ForgeHelp, ForgeHelpText, ForgeByline { + color: var(--text-muted); + font-size: 2.6mm; + text-align: left; + padding: 0.5mm 2mm; + margin: 0; +} + +ForgeProblem { + color: var(--coral); + font-size: 2.8mm; + text-align: left; + padding: 0.5mm 2mm; + margin: 0; +} + +ForgeOfficial { + background-color: transparent; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 3.4mm; + padding: 0.3mm 0; + margin: 0; +} + +ForgeTab, ForgeTabOn { + background-color: var(--edge); + border-radius: 0; + color: var(--edge-light); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 1.5mm; + margin: 0.5mm; +} + +ForgeTabOn { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + color: var(--ink); +} + +/* The arena editor's tools: an icon over a short word, small enough that + five of them share a phone's width without "To...". */ +ForgeTool, ForgeToolOn { + background-color: var(--edge); + border-radius: 0; + color: var(--edge-light); + font-family: "Rajdhani Bold"; + font-size: 2.4mm; + text-align: center; + padding: 1mm 0.5mm; + margin: 0.5mm; +} + +ForgeToolOn { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + color: var(--ink); +} + +/* tag::splat-desk-css[] */ +/* The admin console. It is a tool for the people running the game, not part + of the game, so it looks like a desktop application: a light page, a side + bar, plain fields in one aligned column, flat buttons and charts. Nothing + here is wood or stone. admin/Desk.java builds every widget with these. */ +DeskForm, DeskPage, DeskTabs { + background-color: #f4f5f7; + padding: 0; + margin: 0; +} + +DeskPage { + padding: 2mm 3mm; +} + +DeskToolbar { + background-color: #1f2633; + padding: 1mm 2mm; +} + +DeskTitle { + background-color: transparent; + color: #ffffff; + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + text-align: left; +} + +DeskSide { + background-color: #e8eaee; + border-right: 0.2mm solid #d3d7de; + padding: 1mm 0; +} + +DeskTab, DeskTab.selected, DeskTab.pressed { + background-color: transparent; + color: #3c4454; + font-family: "Rajdhani Medium"; + font-size: 2.7mm; + text-align: left; + border: none; + border-radius: 0; + padding: 1.4mm 2.5mm; + margin: 0.3mm 1mm; +} + +DeskTab.selected { + background-color: #ffffff; + color: #1d4ed8; + font-family: "Rajdhani Bold"; +} + +DeskSection { + background-color: transparent; + color: #1a1f25; + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + padding: 0; + margin: 3mm 0 1.2mm 0; +} + +DeskPanel, DeskCard, DeskChartBox { + background-color: #ffffff; + border: 0.2mm solid #d9dde3; + border-radius: 0; + padding: 2mm 2.5mm; + margin: 0.8mm; +} + +DeskPanelTitle { + background-color: transparent; + color: #1a1f25; + font-family: "Rajdhani Bold"; + font-size: 2.9mm; + padding: 0 0 0.6mm 0; + margin: 0; +} + +DeskCardValue { + background-color: transparent; + color: #1a1f25; + font-family: "Rajdhani Bold"; + font-size: 5mm; + padding: 0; + margin: 0; +} + +DeskCardName { + background-color: transparent; + color: #4a5263; + font-size: 2.6mm; + padding: 0; + margin: 0; +} + +DeskCardDetail, DeskHelp { + background-color: transparent; + color: #6b7280; + font-size: 2.4mm; + padding: 0; + margin: 0; +} + +DeskHelpBox { + background-color: transparent; + padding: 0.8mm 0; + margin: 0; +} + +DeskFieldRow { + background-color: transparent; + padding: 0.6mm 0; + margin: 0; +} + +DeskLabel { + background-color: transparent; + color: #4a5263; + font-size: 2.7mm; + text-align: right; + padding: 1.2mm 2.5mm 1.2mm 0; + margin: 0; +} + +DeskValue, DeskValueAlert { + background-color: transparent; + color: #1a1f25; + font-size: 2.7mm; + padding: 1.2mm 0; + margin: 0; +} + +DeskValueAlert { + color: #b42318; +} + +DeskInput { + background-color: #ffffff; + border: 0.2mm solid #c4c9d2; + border-radius: 0; + color: #1a1f25; + font-size: 2.7mm; + text-align: left; + padding: 1mm 1.4mm; + margin: 0 0.6mm 0 0; +} + +DeskConditionRow { + background-color: transparent; + padding: 0.4mm 0; + margin: 0; +} + +DeskActions { + background-color: transparent; + padding: 1mm 0 0 0; + margin: 0; +} + +DeskButton, DeskPrimary, DeskDanger { + background-color: #ffffff; + border: 0.2mm solid #c4c9d2; + border-radius: 0; + color: #1a1f25; + font-family: "Rajdhani Medium"; + font-size: 2.7mm; + text-align: center; + padding: 1mm 2.5mm; + margin: 0 0 0 1mm; +} + +DeskButton.pressed { + background-color: #e8eaee; +} + +DeskPrimary { + background-color: #2563eb; + border: 0.2mm solid #1d4ed8; + color: #ffffff; + font-family: "Rajdhani Bold"; +} + +DeskPrimary.pressed { + background-color: #1d4ed8; +} + +DeskDanger { + color: #b42318; + border: 0.2mm solid #e4a39c; +} + +DeskDanger.pressed { + background-color: #fdecea; +} + +DeskLink { + background-color: transparent; + color: #1d4ed8; + font-size: 2.6mm; + text-align: left; + padding: 1mm; + margin: 0; +} + +DeskRow, DeskRowWarn, DeskRowAlert { + background-color: #ffffff; + border: 0.2mm solid #d9dde3; + border-radius: 0; + padding: 1.4mm 2mm; + margin: 0.5mm 0; +} + +DeskRowWarn { + border-left: 1mm solid #f59e0b; +} + +DeskRowAlert { + border-left: 1mm solid #dc2626; +} + +DeskRowTitle { + background-color: transparent; + color: #1a1f25; + font-family: "Rajdhani Bold"; + font-size: 2.8mm; +} + +DeskRowDetail { + background-color: transparent; + color: #6b7280; + font-size: 2.5mm; +} + +DeskTableHeader, DeskTableRow { + background-color: #ffffff; + border-bottom: 0.2mm solid #e5e7eb; + padding: 0 1mm; + margin: 0; +} + +DeskTableHeader { + background-color: #eef0f3; +} + +DeskTableHead, DeskCell { + background-color: transparent; + color: #1a1f25; + font-size: 2.6mm; + text-align: left; + padding: 1mm; + margin: 0; +} + +DeskTableHead { + color: #4a5263; + font-family: "Rajdhani Bold"; +} + +DeskEmpty { + background-color: transparent; + color: #6b7280; + font-size: 2.7mm; + padding: 2mm 0; +} + +DeskBadge, DeskBadgeOn { + background-color: #e5e7eb; + border-radius: 0; + color: #4a5263; + font-family: "Rajdhani Bold"; + font-size: 2.3mm; + padding: 0.4mm 1.5mm; + margin: 0; +} + +DeskBadgeOn { + background-color: #dcfce7; + color: #166534; +} + +/* The charts read their colours from these: the page, the grid lines and + the three series. */ +DeskChart { + background-color: #ffffff; + color: #4a5263; + padding: 0; + margin: 0; +} + +DeskChartGrid { + color: #e2e5ea; +} + +DeskChartA { + color: #2563eb; +} + +DeskChartB { + color: #16a34a; +} + +DeskChartC { + color: #f59e0b; +} +/* end::splat-desk-css[] */ + +/* ---- Run HUD: drawn over the 3D view, so every piece keeps its own + translucent backing and stays readable against bright scenery. ---- */ +EmoteBar { + background-color: transparent; + padding: 0 2mm 3mm 2mm; + margin: 0; +} +HudBar { + background: linear-gradient(to bottom, rgba(5, 7, 18, 0.75), rgba(5, 7, 18, 0)); + padding: 1.5mm 2mm 4mm 2mm; +} +HudDistance, HudScore, HudCoins, HudPlace, HudPower { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + padding: 0.3mm 1.5mm; + margin: 0; +} +HudDistance { + font-family: "Rajdhani Bold"; + font-size: 8mm; + text-align: center; +} +HudScore { + background-color: rgba(10, 14, 32, 0.72); + border: 0.25mm solid #3a424b; + border-radius: 0; + color: var(--ice); + text-align: center; + padding: 0.4mm 2.2mm; +} +HudStreet { + background-color: #0f6b45; + border: 0.25mm solid #ffffff; + border-radius: 0; + color: #ffffff; + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + text-align: center; + padding: 0.4mm 1.8mm; + margin-top: 0.8mm; +} +HudCoins { + background-color: rgba(10, 14, 32, 0.8); + border: 0.3mm solid #ffc93a; + border-radius: 0; + color: #ffc93a; + font-size: 3.6mm; + padding: 0.8mm 2.2mm; + margin: 0.5mm 0; +} +HudPower { + color: var(--violet-glow); + text-align: right; +} +HudPlace { + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 4.6mm; + text-align: right; +} +HudCountdown { + background-color: transparent; + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 24mm; + text-align: center; +} +HudButton { + background-color: rgba(10, 14, 32, 0.8); + border: 0.3mm solid var(--edge-light); + color: var(--text); + font-size: 3mm; + border-radius: 0; + padding: 1.6mm; + margin: 1mm; +} +HudButton.pressed { + background-color: var(--edge); +} +ChatLog { + background-color: rgba(10, 14, 28, 0.45); + padding: 1mm; + margin: 1mm; +} +ChatLine { + background-color: transparent; + color: var(--text); + font-size: 2.8mm; + padding: 0; + margin: 0; +} + +ChatFrom { + background-color: transparent; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + padding: 0; + margin: 0; +} + +LobbyMember { + background-color: rgba(255, 255, 255, 0.04); + border-radius: 0; + padding: 1mm 2mm; + margin: 0.6mm 2mm; +} + +LobbyMemberName { + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; + padding: 0; + margin: 0; +} + +LobbyHostTag { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + padding: 0.3mm 1mm; + margin: 0; +} + +LobbySay { + padding: 0; + margin: 0.6mm 2mm; +} + +ChatBubble { + background-color: #1e2350; + border: 1px solid #3a4290; + border-radius: 0; + padding: 1mm 2mm; + margin: 0.6mm 2mm; +} + +/* ---- Results, score boards and the track editor ---- */ +ResultLine { + color: var(--text); + font-size: 3.2mm; + padding: 0.8mm 2mm; + margin: 0; +} +DialogButton { + background: linear-gradient(to bottom, var(--coral-light), var(--coral)); + border: 0.3mm solid var(--coral-dark); + color: #ffffff; + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + text-align: center; + border-radius: 0; + padding: 1.5mm 1mm; + margin: 1mm; +} +BoardTab { + background-color: var(--surface); + color: var(--edge-light); + font-size: 2.9mm; + border-radius: 0; + padding: 1.2mm 2.2mm; + margin: 0.6mm; +} +BoardTab.selected, BoardTab.pressed { + background-color: var(--cyan); + color: var(--ink); +} +Timeline { + background-color: var(--bg); + padding: 0; + margin: 1mm 0; +} + +/* ---- Home lobby ---- */ + +TopBar { + background: linear-gradient(to bottom, rgba(5, 7, 18, 0.85), rgba(5, 7, 18, 0)); + padding: 2mm 2.5mm 3mm 2.5mm; +} + +CurrencyPill { + background-color: rgba(10, 14, 32, 0.78); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + padding: 0.4mm 0.6mm 0.4mm 1.4mm; + margin: 0 0.8mm; +} + +Trophies, Coins, Gems { + font-size: 2.8mm; + padding: 0.4mm 0.8mm; + margin: 0; + background-color: transparent; +} + +PillPlus { + background-color: var(--volt); + border-radius: 0; + color: var(--ink); + padding: 0.2mm; + margin: 0 0.2mm 0 0.6mm; +} + +IconButton { + background-color: rgba(10, 14, 32, 0.78); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + color: var(--text); + padding: 1mm; + margin: 0 0 0 0.8mm; +} + +Rail { + background-color: transparent; + padding: 1mm 1.5mm; +} + +RailTile { + background: linear-gradient(to bottom, rgba(46, 54, 104, 0.9), rgba(16, 20, 44, 0.9)); + border: 0.35mm solid var(--edge-light); + border-radius: 0; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 1.8mm; + text-align: center; + padding: 0.8mm 1.1mm 0.6mm 1.1mm; + margin: 0.5mm 0; +} + +RailTile.pressed { + background-color: var(--violet); + border-color: var(--cyan); +} + +HomeBottom { + background: linear-gradient(to bottom, rgba(5, 7, 18, 0), rgba(5, 7, 18, 0.92)); + padding: 0; +} + +RaceChip { + background-color: rgba(18, 22, 50, 0.9); + border: 0.35mm solid var(--cyan); + border-radius: 0; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 2mm; + margin: 0 4mm 2mm 4mm; +} + +RaceChip.pressed { + background-color: var(--cyan); + color: var(--ink); +} + +NavBar { + background-color: rgba(6, 8, 20, 0.96); + border-top: 0.3mm solid var(--edge); + padding: 1mm 0 0.6mm 0; +} + +NavItem, NavItemOn { + background-color: transparent; + color: var(--text-muted); + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + text-align: center; + padding: 1mm 0.5mm; + margin: 0; +} + +NavItemOn { + color: var(--volt); +} + +/* ---- End of a run ---- */ + +/* The veil a result or reward card sits on, over the scene where the run + ended. opacity sets the background's transparency only. */ +RunDim { + background-color: rgba(6, 8, 24, 0.6); + padding: 6mm 3mm; + margin: 0; +} + +ResultCard { + background: linear-gradient(to bottom, #1c2127, #0b0d10); + border: 0.4mm solid var(--violet-glow); + border-radius: 0; + padding: 4mm 3mm 3mm 3mm; + margin: 2mm; +} + +ResultTitle { + color: var(--volt); + font-family: "Rajdhani Bold"; + font-size: 5.5mm; + text-align: center; + background-color: transparent; +} + +ResultScore { + color: #ffffff; + font-family: "Rajdhani Bold"; + font-size: 9mm; + text-align: center; + background-color: transparent; + padding: 1mm 0 0 0; +} + +ResultCaption { + color: var(--text-muted); + font-family: "Rajdhani Bold"; + font-size: 2.4mm; + text-align: center; + background-color: transparent; +} + +StatChip { + background-color: rgba(46, 230, 255, 0.12); + border: 0.3mm solid var(--cyan); + border-radius: 0; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + padding: 0.8mm 1.6mm; + margin: 1.5mm 0.8mm; +} + +/* ---- Level cards ---- */ + +LevelCard { + background: linear-gradient(to bottom, #1c2127, #121633); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + padding: 2mm; + margin: 1.5mm 3mm; +} + +LevelThumb { + background-color: transparent; + padding: 0 2.5mm 0 0; + margin: 0; +} + +CityIcon { + background-color: #1f2a5e; + border-radius: 0; + color: var(--volt); + text-align: center; + padding: 2mm; + margin: 0 2mm 0 0; +} + +CityCard { + background: linear-gradient(to bottom, #1c2127, #0b0d10); + border: 0.4mm solid var(--violet-glow); + border-radius: 0; + padding: 3mm; + margin: 2mm; +} + +CityStatus { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; + text-align: center; + padding: 0 0 2mm 0; + margin: 0; +} + +MapCredit { + background-color: rgba(255, 255, 255, 0.8); + color: #333333; + font-family: "Rajdhani Medium"; + font-size: 1.7mm; + padding: 0.3mm 1mm; + margin: 0; +} + +CityMinimap { + background-color: rgba(10, 14, 32, 0.72); + border: 0.25mm solid #3a424b; + border-radius: 0; + padding: 0; + margin: 1mm 0 0 0; +} + +LevelLength { + background-color: transparent; + color: var(--text-muted); + font-family: "Rajdhani Medium"; + font-size: 2.4mm; + padding: 0.4mm 0 0 0.6mm; + margin: 0; +} + +LevelName { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + padding: 0.5mm 0; +} + +TagChip { + background-color: rgba(181, 108, 255, 0.2); + border: 0.3mm solid var(--violet-glow); + border-radius: 0; + color: var(--violet-glow); + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + padding: 0.4mm 1.6mm; + margin: 0.5mm 1mm 0.5mm 0; +} + +PlayRound { + background: linear-gradient(to bottom, #ffd166, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 0; + color: var(--ink); + padding: 2.4mm; + margin: 0 0 0 1mm; +} + +PlayRound.pressed { + background-color: var(--volt-dark); +} + +CardAction { + background-color: rgba(46, 230, 255, 0.08); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + color: var(--cyan); + font-family: "Rajdhani Bold"; + font-size: 2.5mm; + text-align: center; + padding: 1.4mm 0.5mm; + margin: 2mm 0.8mm 0 0.8mm; +} + +CardAction.pressed { + background-color: var(--edge); +} + +CardGo { + background: linear-gradient(to bottom, #ffd166, var(--volt)); + border: 0.4mm solid var(--volt-dark); + border-radius: 0; + color: var(--ink); + font-family: "Rajdhani Bold"; + font-size: 4.6mm; + text-align: center; + padding: 2.6mm; + margin: 2mm 1mm 1.5mm 1mm; +} + +CardGo.pressed { + background-color: var(--volt-dark); +} + +/* Text that sits on a card or a dialog shows what is under it. */ +Claimed, DialogBody, DialogTitle, ForgeCheck, ForgeProblem, QuestReward, ResultLine { + background-color: transparent; +} + +/* ---- Shop cards ---- */ + +ShopArt { + background-color: transparent; + padding: 0 2.5mm 0 0; + margin: 0; +} + +ShopItemKind { + background-color: transparent; + color: #f2b632; + font-family: "Rajdhani Bold"; + font-size: 2.1mm; + padding: 0.3mm 0; +} + +/* The name on the home screen's top bar. */ +PlayerName { + background-color: transparent; + color: var(--text); + font-family: "Rajdhani Bold"; + font-size: 3mm; + padding: 0.4mm 1mm 0.4mm 1.5mm; +} + +LevelBadge { + font-size: 2.2mm; + padding: 0.3mm 1.4mm; + margin: 0.4mm 0; +} + +/* The sign-in's server switch: a developer setting, kept small and quiet. */ +/* The invite code, on a card of its own with a caption over it. */ +InviteCard { + background: linear-gradient(to bottom, var(--surface-light), var(--surface)); + border: 0.3mm solid var(--edge-light); + border-radius: 0; + padding: 2mm; + margin: 1.5mm 0; +} + +InviteCaption { + background-color: transparent; + color: var(--text-muted); + font-family: "Rajdhani Bold"; + font-size: 2mm; + padding: 0; + margin: 0; +} + +/* A MultiButton's emblem (the stars on a score row): no plate behind it. */ +Emblem { + background-color: transparent; + padding: 0 0 0 1mm; + margin: 0; +} + +/* ---------------------------------------------------------------- Splat match */ + +MatchScreen { + background-color: #000000; + padding: 0; + margin: 0; +} + +MissionCard { + background: linear-gradient(to bottom, #1c2127, #0b0d10); + border: 0.3mm solid var(--edge); + border-radius: 0; + padding: 3mm; + margin: 1.5mm 2mm 1.5mm 2mm; +} + +MissionTitle { + color: var(--coral); + font-family: "Rajdhani Bold"; + font-size: 3.4mm; + background-color: transparent; +} + +MissionCaption { + color: var(--text-muted); + font-family: "Rajdhani Bold"; + font-size: 2.4mm; + background-color: transparent; +} + +MissionBriefing { + color: var(--text); + font-family: "Rajdhani Medium"; + font-size: 2.6mm; + background-color: transparent; + padding: 1mm 0 2mm 0; +} + +ScoreRowTangerine { + color: #ff7a1f; + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + background-color: transparent; + padding: 0.6mm 1mm 0.6mm 1mm; +} + +ScoreRowTeal { + color: #1fc6ff; + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + background-color: transparent; + padding: 0.6mm 1mm 0.6mm 1mm; +} + +/* ---------------------------------------------------------------- main menu + The front end of a military shooter: the operator stage (a 3D view) under + everything, a shade darkening the left third so the menu reads, and square + capitalised orders with an amber bar on the one that is primed. */ +MenuForm { + background-color: #0d1014; + padding: 0; +} + +MenuShade { + background: linear-gradient(to right, rgba(5, 6, 8, 0.92), rgba(5, 6, 8, 0.0)); + padding: 0; + margin: 0; +} + +MenuTop { + padding: 3mm 4mm 1mm 4mm; + background-color: transparent; +} + +MenuLogo { + font-family: "Rajdhani Bold"; + font-size: 7mm; + color: var(--coral); + padding: 0; + margin: 0; + background-color: transparent; +} + +MenuLogoSub { + font-family: "Rajdhani Medium"; + font-size: 2.2mm; + color: var(--text-muted); + padding: 0; + margin: 0; + background-color: transparent; +} + +RankCard { + background-color: rgba(11, 13, 16, 0.75); + border: 1px solid #2e353d; + padding: 1.5mm 2mm; + margin: 0; +} + +MenuColumn { + padding: 2mm 0 2mm 4mm; + background-color: transparent; +} + +MenuItem, MenuItemPrimed { + background-color: transparent; + border: none; + border-left: 0.8mm solid transparent; + padding: 1.4mm 6mm 1.4mm 2.5mm; + margin: 0.4mm 0; +} + +MenuItem.pressed { + background-color: rgba(242, 182, 50, 0.16); + border-left: 0.8mm solid #f2b632; +} + +MenuItemPrimed { + background: linear-gradient(to right, rgba(242, 182, 50, 0.30), rgba(242, 182, 50, 0.0)); + border-left: 0.8mm solid #f2b632; +} + +MenuItemText, MenuItemTextPrimed { + font-family: "Rajdhani Bold"; + font-size: 4.2mm; + color: #f2f4f5; + padding: 0; + margin: 0; + background-color: transparent; +} + +MenuItemTextPrimed { + color: #ffd166; +} + +MenuBottom { + padding: 1mm 4mm 5mm 4mm; + background-color: transparent; +} + +MenuItemSub { + font-family: "Rajdhani Medium"; + font-size: 2.1mm; + color: var(--text-muted); + padding: 0; + margin: 0; + background-color: transparent; +} + +/* ---------------------------------------------------------------- operations */ +OpsForm { + background: linear-gradient(to bottom, #15191e, #0b0d10); +} + +OpsList { + padding: 2mm 0 2mm 3mm; + background-color: transparent; +} + +OpsRow, OpsRowOn { + font-family: "Rajdhani Bold"; + font-size: 3mm; + color: #c4cbd2; + background-color: rgba(22, 26, 31, 0.85); + border: none; + border-left: 0.8mm solid #2e353d; + padding: 1.6mm 2.5mm; + margin: 0.5mm 2mm 0.5mm 0; + text-align: left; +} + +OpsRowOn { + color: #ffd166; + background: linear-gradient(to right, rgba(242, 182, 50, 0.28), rgba(22, 26, 31, 0.85)); + border-left: 0.8mm solid #f2b632; +} + +OpsDetail { + padding: 2mm 3mm 3mm 3mm; + background-color: transparent; +} + +OpsKicker { + background-color: transparent; + border: none; + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + color: #f0443a; + padding: 0; + margin: 0; +} + +OpsTitle { + background-color: transparent; + border: none; + font-family: "Rajdhani Bold"; + font-size: 5mm; + color: #f2b632; + padding: 0; + margin: 0; +} + +OpsCaption { + background-color: transparent; + border: none; + font-family: "Rajdhani Medium"; + font-size: 2.4mm; + color: #8a949e; + padding: 0 0 1.5mm 0; + margin: 0; +} + +OpsBriefing { + background-color: transparent; + border: none; + font-family: "Rajdhani Medium"; + font-size: 3mm; + color: #e8ecef; + padding: 2mm 0; + margin: 0; +} + +DeployButton { + font-family: "Rajdhani Bold"; + font-size: 4.6mm; + color: #0b0d10; + background-color: #f2b632; + border: none; + padding: 2.2mm; + margin: 1mm 0; + text-align: center; +} + +DeployButton.pressed { + background-color: #ffd166; +} + +OpsAction { + font-family: "Rajdhani Bold"; + font-size: 3mm; + color: #f2f4f5; + background-color: rgba(28, 33, 39, 0.9); + border: 1px solid #4f5862; + padding: 1.8mm; + margin: 0.5mm; + text-align: center; +} + +OpsAction.pressed { + border: 1px solid #f2b632; + color: #ffd166; +} + +OpsRowTitle { + font-family: "Rajdhani Bold"; + font-size: 3.2mm; + color: #f2f4f5; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + text-align: left; +} + +OpsRowSub { + font-family: "Rajdhani Medium"; + font-size: 2.1mm; + color: #8a949e; + background-color: transparent; + padding: 0; + margin: 0; +} + +/* ---------------------------------------------------------------- operator */ +BackLink { + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + color: #8a949e; + background-color: transparent; + border: none; + padding: 0 0 1mm 0; + margin: 0; +} + +LoadoutEquipped { + font-family: "Rajdhani Bold"; + font-size: 2.1mm; + color: #f2b632; + padding: 0; + margin: 0; + background-color: transparent; +} + +LoadoutBlurb { + font-family: "Rajdhani Medium"; + font-size: 2.6mm; + color: #c4cbd2; + padding: 1mm 0; + margin: 0; + background-color: transparent; +} + +LoadoutStat { + padding: 0.4mm 0; + margin: 0; + background-color: transparent; +} + +LoadoutStatName { + font-family: "Rajdhani Bold"; + font-size: 2.2mm; + color: #8a949e; + padding: 0; + margin: 0; + background-color: transparent; +} + +/* ---------------------------------------------------------------- lobby */ +RosterHeadFriendly, RosterHeadHostile { + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + color: #4aa8ff; + padding: 1mm 1.5mm; + margin: 0 1mm; + background-color: transparent; +} + +RosterHeadHostile { + color: #f0443a; +} + +RosterFriendly, RosterHostile, RosterOpen { + background-color: rgba(22, 26, 31, 0.9); + border: none; + border-left: 0.8mm solid #4aa8ff; + padding: 1.4mm 2mm; + margin: 0.4mm 1mm; +} + +RosterHostile { + border-left: 0.8mm solid #f0443a; +} + +RosterOpen { + border-left: 0.8mm solid #2e353d; +} + +/* ---------------------------------------------------------------- match */ +CommsColumn { + padding: 30mm 0 0 2.5mm; + background-color: transparent; +} + +EmoteBar { + padding: 0; + margin: 0; + background-color: transparent; +} + +EmoteButton { + font-family: "Rajdhani Bold"; + font-size: 2.3mm; + color: #e8ecef; + background-color: rgba(11, 13, 16, 0.6); + border: none; + border-left: 0.6mm solid #4aa8ff; + padding: 0.8mm 2mm; + margin: 0.3mm 0; + text-align: left; +} + +EmoteButton.pressed { + background-color: rgba(74, 168, 255, 0.35); +} + +ResultTitleLoss { + font-family: "Rajdhani Bold"; + font-size: 9mm; + color: #f0443a; + padding: 0; + margin: 0; + text-align: center; + background-color: transparent; +} + +ResultScore { + font-family: "Rajdhani Bold"; + font-size: 9mm; + color: #f2f4f5; + padding: 1mm 0 2mm 0; + margin: 0; + text-align: center; + background-color: transparent; +} + +ScoreHead, ScoreRowFriendly, ScoreRowHostile, ScoreRowMe { + padding: 0.8mm 1.5mm; + margin: 0.2mm 0; + background-color: rgba(22, 26, 31, 0.9); + border: none; +} + +ScoreHead { + background-color: transparent; +} + +ScoreRowFriendly { + border-left: 0.8mm solid #4aa8ff; + background-color: rgba(74, 168, 255, 0.12); +} + +ScoreRowHostile { + border-left: 0.8mm solid #f0443a; + background-color: rgba(240, 68, 58, 0.12); +} + +ScoreRowMe { + border-left: 0.8mm solid #f2b632; + background-color: rgba(242, 182, 50, 0.22); +} + +ScoreCellName, ScoreCell { + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + color: #f2f4f5; + padding: 0; + margin: 0; + background-color: transparent; +} + +ScoreCell { + text-align: right; +} + +RewardKicker { + font-family: "Rajdhani Bold"; + font-size: 4mm; + color: #f2b632; + padding: 1mm; + margin: 0; + background-color: transparent; +} + +SectionRule { + background-color: transparent; + border: none; + border-bottom: 0.3mm solid #2e353d; + padding: 0; + margin: 1mm 4mm 0 4mm; + font-size: 0.5mm; +} + +/* Mission editor */ +EditorField { + font-family: "Rajdhani Medium"; + font-size: 3.2mm; + color: #f2f4f5; + background-color: #12161a; + border: 1px solid #4f5862; + padding: 1.8mm; + margin: 0.8mm; +} + +EditorChip, EditorChipOn { + font-family: "Rajdhani Bold"; + font-size: 2.8mm; + color: #c9d1d8; + background-color: rgba(28, 33, 39, 0.9); + border: 1px solid #4f5862; + padding: 1.4mm 2mm; + margin: 0.5mm; + text-align: center; +} + +EditorChipOn { + color: #0c0e10; + background-color: #f2b632; + border: 1px solid #f2b632; +} + +EditorStatus { + font-family: "Rajdhani Bold"; + font-size: 2.6mm; + color: #f2b632; + background-color: transparent; + padding: 1mm 1.5mm; + margin: 0; +} + +MissionRow { + background-color: rgba(28, 33, 39, 0.9); + border: 1px solid #2c343c; + padding: 2mm; + margin: 0.6mm 1mm; +} + +MissionRowTitle { + font-family: "Rajdhani Bold"; + font-size: 3.4mm; + color: #f2f4f5; + background-color: transparent; + padding: 0; + margin: 0; +} + +MissionRowSub { + font-family: "Rajdhani Medium"; + font-size: 2.6mm; + color: #8a949e; + background-color: transparent; + padding: 0; + margin: 0; +} + +/* The desktop editor's tool panel, at the side of the field. */ +EditorPanel { + background-color: #0c0f12; + border-left: 1px solid #2a3138; + padding: 2mm; + margin: 0; +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/AppConfig.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/AppConfig.java new file mode 100644 index 00000000000..97b543a24ff --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/AppConfig.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.io.Preferences; +import com.codename1.ui.CN; + +/// The few things about the app that are settings and not code. +public final class AppConfig { + /// Where the server is when nothing says otherwise: a server started on + /// this machine with `cn1:backend`. Change this to your deployment's address + /// before you ship; until then the settings screen can point a build at + /// another server, which is how a phone reaches a laptop on the same network. + public static final String DEFAULT_SERVER = "http://localhost:8080"; + /// The id the server knows this app by. The same constant is in the + /// server's `SecurityConfig`. + public static final String CLIENT_ID = "splat-app"; + /// The address the server hands the authorization code back to. The app + /// reads the code out of the redirect itself and never opens this address, + /// so it only has to be one the server has on its list. + public static final String REDIRECT_URI = "http://127.0.0.1/callback"; + /// The path the server hands the code back on when the app runs in a + /// browser. The same constant is in the server's `SecurityConfig`. + public static final String WEB_REDIRECT_PATH = "/signin/code"; + // tag::splat-push-key[] + /// The app's push key, from the Push section of the Codename One console. + /// Empty, the app registers for no notifications: everything still works, + /// and a phone in a pocket simply hears nothing until it is opened. + public static final String PUSH_APP_KEY = ""; + // end::splat-push-key[] + /// Where the game's analytics are read: engagement, retention, the invite + /// funnel and revenue per campaign, all from what `Telemetry` and + /// `Friends` report. The console's Promote tab opens it. + public static final String ANALYTICS_CONSOLE = "https://cloud.codenameone.com/console/analytics"; + + // tag::splat-monetization-ids[] + /// Google's published test ad units. They always fill, never pay, and are + /// safe to click; replace them with your own AdMob units before release. + public static final String REWARDED_AD_UNIT = "ca-app-pub-3940256099942544/5224354917"; + public static final String INTERSTITIAL_AD_UNIT = "ca-app-pub-3940256099942544/1033173712"; + public static final String BANNER_AD_UNIT = "ca-app-pub-3940256099942544/6300978111"; + /// The store products, as configured in App Store Connect and the Play + /// Console. The server's `Catalog` names the same ones. + public static final String SKU_GEMS_80 = "splat.gems_80"; + public static final String SKU_GEMS_500 = "splat.gems_500"; + public static final String SKU_ROYAL_PASS = "splat.royal_pass"; + // end::splat-monetization-ids[] + + private static final String SERVER_KEY = "splat.server"; + private static final String MODE_KEY = "splat.mode"; + private static final String MODE_OWNER_KEY = "splat.mode.owner"; + + private AppConfig() { + } + + /// The server's address, with no trailing slash. A `splat.server.url` + /// system property wins, which is what the tests use; then the address saved + /// from the settings screen; then, in a browser, the address the page came + /// from, since the server that hosts the app is the server it talks to; + /// then [#DEFAULT_SERVER]. + public static String serverUrl() { + String url = System.getProperty("splat.server.url"); + if (url == null || url.length() == 0) { + String origin = pageOrigin(); + // In a browser it is always the page's own server: see + // serverSwitchable(). An address saved before that is ignored. + url = origin != null ? origin : Preferences.get(SERVER_KEY, DEFAULT_SERVER); + } + return clean(url); + } + + /// Whether the app may be pointed at another server. Not in a browser: + /// there the authorization code is sent back to the page's own server + /// ([#redirectUri]), and another server has not registered that address + /// as one of its own, so every sign-in after a switch would be refused. + /// The hosted app is the server's own, and talks to that server. + public static boolean serverSwitchable() { + return pageOrigin() == null; + } + + /// The address of the page the app is running in -- `https://host`, with + /// no path -- or null when it is not running in a browser. + public static String pageOrigin() { + String origin = CN.getProperty("browser.window.location.origin", null); + if (origin == null || !(origin.regionMatches(true, 0, "http://", 0, 7) + || origin.regionMatches(true, 0, "https://", 0, 8))) { + return null; + } + return clean(origin); + } + + /// Where the server is asked to send the authorization code. + /// + /// The installed app names the loopback address and reads the code off the + /// redirect without following it. A browser follows a redirect itself and + /// will not show the page an answer from another origin, so there the code + /// is sent to the server the page came from, which hands it back. + public static String redirectUri() { + String origin = pageOrigin(); + return origin == null ? REDIRECT_URI : origin + WEB_REDIRECT_PATH; + } + + public static void setServerUrl(String url) { + Preferences.set(SERVER_KEY, clean(url)); + } + + /// The mode the account `owner` (see + /// [com.codenameone.examples.splat.net.Session#owner(String)]) chose last + /// -- `player` or `admin` -- or + /// empty when they have chosen none, or the choice on record is someone + /// else's. A choice recorded with no name is anyone's: that is how a test + /// puts the app in a mode before it signs in. + public static String mode(String owner) { + String chosenBy = Preferences.get(MODE_OWNER_KEY, ""); + if (chosenBy.length() > 0 && !chosenBy.equals(owner)) { + return ""; + } + return Preferences.get(MODE_KEY, ""); + } + + /// Records `mode` as chosen by whoever signs in next. + public static void setMode(String mode) { + setMode(mode, ""); + } + + public static void setMode(String mode, String username) { + Preferences.set(MODE_KEY, mode == null ? "" : mode); + Preferences.set(MODE_OWNER_KEY, username == null ? "" : username); + } + + /// Whether `url` names the server `current` does, as [#setServerUrl] + /// would store it. + public static boolean sameServer(String url, String current) { + return clean(url).equals(current); + } + + + + /// The challenge code the app was opened with -- a challenge link opened in + /// a browser, or handed to the installed app -- or null. + public static String challengeCode() { + return linkParameter("challenge"); + } + + /// The Forge pack a shared link opened the app with, or null. + public static String packId() { + return linkParameter("pack"); + } + + /// The link that opens a Forge pack, for a player to send a friend. + public static String packLink(String id) { + String origin = pageOrigin(); + return (origin == null ? serverUrl() : origin) + "/?pack=" + id; + } + + private static String linkParameter(String name) { + String key = name + "="; + String link = CN.getProperty("browser.window.location.href", null); + if (link == null || link.indexOf(key) < 0) { + link = CN.getProperty("AppArg", null); + } + if (link == null) { + return null; + } + int at = link.indexOf(key); + if (at < 0) { + return null; + } + int end = at + key.length(); + while (end < link.length() && link.charAt(end) != '&' && link.charAt(end) != '#') { + end++; + } + String code = link.substring(at + key.length(), end); + return code.length() == 0 ? null : code; + } + + private static String clean(String url) { + String text = url == null ? "" : url.trim(); + while (text.endsWith("/")) { + text = text.substring(0, text.length() - 1); + } + if (text.length() == 0) { + return DEFAULT_SERVER; + } + // Compared without folding the case: a scheme is ASCII, and + // toLowerCase() follows the device's language. + if (!text.regionMatches(true, 0, "http://", 0, 7) + && !text.regionMatches(true, 0, "https://", 0, 8)) { + text = "http://" + text; + } + return text; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/Splat.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/Splat.java new file mode 100644 index 00000000000..1580d4e4d01 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/Splat.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.appreview.AppReview; +import com.codename1.payment.PurchaseCallback; +import com.codename1.system.Lifecycle; +import com.codenameone.examples.splat.play.Music; +import com.codenameone.examples.splat.play.Sfx; +import com.codenameone.examples.splat.engage.Ads; +import com.codenameone.examples.splat.engage.Friends; +import com.codenameone.examples.splat.engage.Notifications; +import com.codenameone.examples.splat.engage.Store; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.ui.Nav; +import com.codenameone.examples.splat.ui.SettingsForm; +import com.codenameone.examples.splat.ui.Ui; + +/// The game. It opens on whatever the stored session allows -- home, the +/// admin console, or a challenge link it was opened with -- and on the sign-in +/// screen otherwise. +/// +/// Where things are: +/// +/// - `rules` (its own module) -- the simulation. The server runs the same +/// classes, so the client never decides a score on its own. +/// - `run` -- the track, drawn with `com.codename1.gaming` in 3D and, where +/// a level says so, as a side-scroller, and the drivers that feed it: solo, +/// live race, replay. +/// - `ui` -- every other screen, built from ordinary widgets that +/// `src/main/css/theme.css` dresses as a game. +/// - `admin` -- the operator's console. +/// - `engage` -- analytics, push, ads and in-app purchases. +/// - `net` -- the session, the REST clients and the game socket. +/// +/// The class is the `PurchaseCallback` because that is how Codename One +/// reports a finished store purchase: to the main class. +public class Splat extends Lifecycle implements PurchaseCallback { + @Override + public void runApp() { + Ui.desktopFonts(); + SettingsForm.apply(); + Sfx.load(); + Music.menu(); + Telemetry.start(); + Ads.start(); + // tag::splat-app-review[] + // The store's own rating prompt, asked only of a player who has come + // back: five launches over three days, and never twice in a month. + // ResultForm asks after a win, the moment a player is happiest. + AppReview.getInstance().setMinimumLaunches(5).setMinimumDaysInstalled(3) + .setDaysBetweenPrompts(30).registerSession(); + // end::splat-app-review[] + Session.start(AppConfig.serverUrl()); + Nav.launch(); + } + + @Override + public void start() { + super.start(); + Notifications.start(); + Friends.start(); + } + + @Override + public void stop() { + super.stop(); + Telemetry.flush(); + } + + // tag::splat-purchase-callback[] + @Override + public void itemPurchased(String sku) { + Store.purchased(sku); + } + + @Override + public void itemPurchaseError(String sku, String errorMessage) { + Ui.toast("The purchase did not go through: " + errorMessage); + } + // end::splat-purchase-callback[] + + @Override + public void itemRefunded(String sku) { + // The server hears about refunds from the store itself. + } + + @Override + public void subscriptionStarted(String sku) { + Store.purchased(sku); + } + + @Override + public void subscriptionCanceled(String sku) { + } + + @Override + public void paymentFailed(String paymentCode, String failureReason) { + } + + @Override + public void paymentSucceeded(String paymentCode, double amount, String currency) { + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/AdminConsole.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/AdminConsole.java new file mode 100644 index 00000000000..04f14ce954f --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/AdminConsole.java @@ -0,0 +1,406 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.components.MultiButton; +import com.codename1.ui.CN; +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.util.UITimer; +import com.codenameone.examples.splat.api.AdminActionDto; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.api.AuditDto; +import com.codenameone.examples.splat.api.FlagDto; +import com.codenameone.examples.splat.api.LiveRaceDto; +import com.codenameone.examples.splat.api.RaceSummaryDto; +import com.codenameone.examples.splat.api.PlayerDetailDto; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.ui.HistoryForm; +import com.codenameone.examples.splat.ui.MatchLobby; +import com.codenameone.examples.splat.ui.Nav; +import com.codenameone.examples.splat.ui.ReplayForm; +import com.codenameone.examples.splat.ui.Ui; +import java.util.List; + +/// "God mode": the game as its operator sees it. Every number and list comes +/// from `/api/admin`, which only an account with the admin role may call -- +/// the server enforces that, not this screen. +/// +/// - **Overview**: what is happening now, and charts of players, races, +/// sales, the funnel, retention and the trophy ladder ([ReportsTab]). +/// - **Flags**: reports from players and the server's own suspicions (a +/// desynced client, impossible play rates), newest first. New ones raise a +/// notification here and, on a phone, a push. +/// - **Players**: search, a player's history, flags and purchases, and the +/// actions -- warn, mute, suspend, ban, reset trophies, revoke items. Each +/// one is written to the audit log with the reason given. +/// - **Live**: every race being run, each one watchable as it happens. +/// - **Audit**: who did what to whom. +/// - **Query**: which players meet a set of conditions ([QueryTab]). +/// - **Pipelines**: when a player meets conditions, push or reward them, +/// every hour, with a cool-down ([PipelinesTab]). +/// - **Promote**: push broadcasts, the home spotlight and campaign links. +/// +/// The console is a tool, not part of the game, so it has a look of its own +/// ([Desk]): a desktop application's, in the same app. +// tag::splat-admin[] +public final class AdminConsole { + private static final int POLL_MILLIS = 15000; + private static int knownFlags = -1; + + private static final String[] TABS = {"Overview", "Flags", "Players", "Live", "Audit", "Query", + "Pipelines", "Promote"}; + // Not MATERIAL_DASHBOARD: the Initializr renames every "Splat" in this + // template to the new app's name, and the constant would be renamed too. + private static final char[] ICONS = {FontImage.MATERIAL_ANALYTICS, FontImage.MATERIAL_FLAG, + FontImage.MATERIAL_PEOPLE, FontImage.MATERIAL_SPORTS_ESPORTS, FontImage.MATERIAL_HISTORY, + FontImage.MATERIAL_SEARCH, FontImage.MATERIAL_ALT_ROUTE, + FontImage.MATERIAL_CAMPAIGN}; + + private final Form form = new Form("Splat console", new BorderLayout()); + private final ReportsTab overview = new ReportsTab(); + private final Container flags = Desk.page(); + private final Container players = Desk.page(); + private final Container live = Desk.page(); + private final Container audit = Desk.page(); + private final QueryTab query = new QueryTab(this); + private final PipelinesTab pipelines = new PipelinesTab(); + private final PromoteTab promote = new PromoteTab(); + private Tabs tabs; + /// The desktop side bar's width. + private static final float SIDE_MM = 42f; + + private AdminConsole() { + } + + public static void show() { + new AdminConsole().build(); + } + + private void build() { + form.setName("admin"); + form.setUIID("DeskForm"); + form.getToolbar().setUIID("DeskToolbar"); + form.getToolbar().getTitleComponent().setUIID("DeskTitle"); + form.setScrollableY(false); + Telemetry.screen("admin"); + form.getToolbar().addMaterialCommandToRightBar("", FontImage.MATERIAL_SPORTS_ESPORTS, + e -> Nav.home()); + tabs = new Tabs(); + tabs.setUIID("DeskTabs"); + tabs.setTabUIID("DeskTab"); + tabs.getTabsContainer().setUIID("DeskSide"); + tabs.setName("adminTabs"); + Component[] pages = {overview.page(), flags, playersTab(), live, audit, query.page(), + pipelines.page(), promote.column()}; + for (int i = 0; i < TABS.length; i++) { + tabs.addTab(TABS[i], FontImage.createMaterial(ICONS[i], "DeskTab", 3.2f), pages[i]); + tabs.setTabSelectedIcon(i, FontImage.createMaterial(ICONS[i], + UIManager.getInstance().getComponentSelectedStyle("DeskTab"), 3.2f)); + } + // tag::splat-admin-desktop[] + // On a desktop or a tablet the console is a side bar and a page, the + // way an operator's tools usually are; on a phone, tabs along the top. + if (wide()) { + tabs.setTabPlacement(Component.LEFT); + tabs.setTabTextPosition(Component.RIGHT); + // Tabs lays a side bar out as a grid, which spreads nine tabs over + // the full height of a desktop window, and sizes it to its widest + // label. A side bar is a list from the top, wide enough to read. + Container side = tabs.getTabsContainer(); + side.setLayout(BoxLayout.y()); + side.setPreferredW(Math.max(side.getPreferredW(), CN.convertToPixels(SIDE_MM))); + } else { + // Nine tabs do not fit across a phone: they scroll sideways. + tabs.getTabsContainer().setScrollableX(true); + tabs.setTabTextPosition(Component.BOTTOM); + } + // end::splat-admin-desktop[] + tabs.addSelectionListener((was, now) -> refresh(now)); + form.add(BorderLayout.CENTER, tabs); + refresh(0); + // The queue is watched while the console is open; a phone also gets a + // push for each new flag, sent by the server's GamePushes. + UITimer.timer(POLL_MILLIS, true, form, this::pollFlags); + form.show(); + } + + private void refresh(int tab) { + if (tab == 0) { + overview.refresh(); + } else if (tab == 1) { + Api.admin().flags(true, Net.to(list -> Ui.later(() -> showFlags(list)), Ui::failed)); + } else if (tab == 2) { + search(""); + } else if (tab == 3) { + Api.admin().liveRaces(Net.to(list -> Ui.later(() -> showLive(list)), Ui::failed)); + } else if (tab == 4) { + Api.admin().audit(Net.to(list -> Ui.later(() -> showAudit(list)), Ui::failed)); + } else if (tab == 5) { + query.refresh(); + } else if (tab == 6) { + pipelines.refresh(); + } else { + promote.refresh(); + } + } + + /// From the query tool: a pipeline that starts with its conditions. + void newPipeline(List conditions) { + pipelines.editNext(PipelinesTab.blank(conditions)); + if (tabs.getSelectedIndex() == 7) { + refresh(7); + } else { + tabs.setSelectedIndex(7); + } + } + + void openPlayer(String username) { + player(username, null); + } + private void pollFlags() { + Api.admin().stats(Net.to(s -> Ui.later(() -> { + if (knownFlags >= 0 && s.openFlags() > knownFlags) { + Ui.toast((s.openFlags() - knownFlags) + " new flag(s) to review"); + } + knownFlags = s.openFlags(); + }), (st, m) -> { })); + } + + private void showFlags(List list) { + flags.removeAll(); + if (list.isEmpty()) { + flags.add(Desk.empty("Nothing to review.")); + } + for (final FlagDto fl : list) { + MultiButton b = Desk.row(fl.displayName() + " (" + fl.kind() + ")", fl.reason(), + fl.reporter() == null || fl.reporter().length() == 0 ? "raised by the server" + : "reported by " + fl.reporter()); + b.setUIID(Flag.AUTO.equals(fl.kind()) ? "DeskRowAlert" : "DeskRowWarn"); + b.setName("flag-" + fl.id()); + b.addActionListener(e -> player(fl.username(), fl.id())); + flags.add(b); + } + form.revalidate(); + } + + private Container playersTab() { + final TextField q = new TextField("", "Search name or email", 20, TextField.ANY); + q.setUIID("DeskInput"); + q.setName("playerSearch"); + q.addActionListener(e -> search(q.getText().trim())); + Container c = new Container(new BorderLayout()); + c.setUIID("DeskPage"); + c.add(BorderLayout.NORTH, Desk.field("Search", q)).add(BorderLayout.CENTER, players); + return c; + } + + private void search(String query) { + Api.admin().players(query, Net.to(list -> Ui.later(() -> { + players.removeAll(); + for (final PlayerDto p : list) { + MultiButton b = Desk.row(p.displayName(), p.username() + " " + p.trophies() + " trophies", + status(p)); + if (p.flagged()) { + b.setUIID("DeskRowWarn"); + } + b.setName("player-" + p.username()); + b.addActionListener(e -> player(p.username(), null)); + players.add(b); + } + form.revalidate(); + }), Ui::failed)); + } + + private static String joined(List names) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < names.size(); i++) { + sb.append(i == 0 ? "" : ", ").append(names.get(i)); + } + return sb.toString(); + } + + private void showLive(List list) { + live.removeAll(); + if (list.isEmpty()) { + live.add(Desk.empty("No races being run.")); + } + for (final LiveRaceDto m : list) { + MultiButton b = Desk.row(m.level() + ": " + joined(m.names()), "leading: " + m.leader() + + " " + (m.tick() / 60) + "s" + (m.bots() > 0 ? " (" + m.bots() + " bots)" : ""), null); + b.setName("live-" + m.id()); + b.addActionListener(e -> MatchLobby.spectate(m.id())); + live.add(b); + } + form.revalidate(); + } + + private void showAudit(List list) { + audit.removeAll(); + for (AuditDto a : list) { + MultiButton b = Desk.row(a.action() + " " + a.username(), a.reason(), "by " + a.admin()); + audit.add(b); + } + form.revalidate(); + } + + /// One player, everything known about them, and what can be done. + private void player(final String username, final String flagId) { + Api.admin().player(username, Net.to(d -> Ui.later(() -> showPlayer(d, flagId)), Ui::failed)); + } + + private void showPlayer(final PlayerDetailDto d, final String flagId) { + final PlayerDto p = d.player(); + Form f = new Form(p.displayName(), new BorderLayout()); + f.setName("adminPlayer"); + f.setUIID("DeskForm"); + f.getToolbar().setUIID("DeskToolbar"); + f.getToolbar().getTitleComponent().setUIID("DeskTitle"); + Ui.back(f, AdminConsole::show); + Container page = Desk.page(); + f.add(BorderLayout.CENTER, page); + Container facts = Desk.panel(); + facts.add(Desk.field("Account", new Label(p.username(), "DeskValue"))); + facts.add(Desk.field("Record", new Label(p.trophies() + " trophies, " + p.wins() + " won, " + p.losses() + + " lost", "DeskValue"))); + facts.add(Desk.field("Wallet", new Label(p.coins() + " coins, " + p.gems() + " gems", "DeskValue"))); + facts.add(Desk.field("Standing", new Label(status(p), p.suspended() ? "DeskValueAlert" : "DeskValue"))); + page.add(facts); + if (flagId != null) { + page.add(Desk.actions(Desk.primary("Resolve this flag", "resolveFlag", e -> Api.admin().resolve(flagId, + Net.to(done -> Ui.later(() -> { + Ui.toast("Resolved"); + AdminConsole.show(); + }), Ui::failed))))); + } + page.add(Desk.section("Actions")); + page.add(Desk.actions( + act(p, Moderation.WARN, "Warn", 0), + act(p, p.muted() ? Moderation.UNMUTE : Moderation.MUTE, p.muted() ? "Unmute" : "Mute", 0), + act(p, Moderation.SUSPEND, "Suspend 3 days", 3), + act(p, Moderation.RESET_TROPHIES, "Reset trophies", 0), + act(p, Moderation.REVOKE_ITEMS, "Revoke items", 0), + act(p, p.suspended() ? Moderation.UNBAN : Moderation.BAN, p.suspended() ? "Lift ban" : "Ban", 0))); + section(page, "Flags", d.flags().size()); + for (FlagDto fl : d.flags()) { + page.add(new Label(fl.kind() + ": " + fl.reason(), fl.open() ? "DeskValueAlert" : "DeskValue")); + } + section(page, "Races", d.races().size()); + for (final RaceSummaryDto m : d.races()) { + MultiButton b = Desk.row(HistoryForm.place(m.place()) + " of " + m.runners() + " on " + m.level(), + m.score() + " points, " + (m.trophyChange() >= 0 ? "+" : "") + m.trophyChange() + " trophies", + null); + b.addActionListener(e -> ReplayForm.load(m.id())); + page.add(b); + } + section(page, "Purchases", d.purchases().size()); + for (String purchase : d.purchases()) { + page.add(new Label(purchase, "DeskValue")); + } + section(page, "Moderation history", d.actions().size()); + for (AuditDto a : d.actions()) { + page.add(new Label(a.action() + ": " + a.reason(), "DeskValue")); + } + f.show(); + } + + private Button act(final PlayerDto p, final String action, final String text, final int days) { + ActionListener go = e -> { + TextField reason = new TextField("", "Reason (kept in the audit log)", 30, TextField.ANY); + reason.setName("reason"); + if (Dialog.show(text + " " + p.displayName() + "?", reason, new Command("Cancel"), + new Command("Confirm")).getCommandName().equals("Confirm")) { + Api.admin().act(p.username(), new AdminActionDto(action, reason.getText().trim(), days), + Net.to(updated -> Ui.later(() -> { + Ui.toast(text + ": done"); + player(p.username(), null); + }), Ui::failed)); + } + }; + Button b = Moderation.BAN.equals(action) ? Desk.danger(text, "act-" + action, go) + : Desk.button(text, "act-" + action, go); + return b; + } + // end::splat-admin[] + + private static String status(PlayerDto p) { + StringBuilder sb = new StringBuilder(); + if (p.suspended()) { + sb.append("suspended "); + } + if (p.muted()) { + sb.append("muted "); + } + if (p.flagged()) { + sb.append("flagged "); + } + if (p.admin()) { + sb.append("admin "); + } + return sb.length() == 0 ? "in good standing" : sb.toString().trim(); + } + + private static void section(Container page, String title, int count) { + page.add(Desk.section(title + " (" + count + ")")); + } + + /// Wider than a phone held sideways: a desktop window or a tablet. + static boolean wide() { + return CN.isDesktop() || CN.isTablet() + || CN.getDisplayWidth() > CN.convertToPixels(160); + } + + /// The action and flag names, as the server spells them. + static final class Moderation { + static final String WARN = "WARN"; + static final String MUTE = "MUTE"; + static final String UNMUTE = "UNMUTE"; + static final String SUSPEND = "SUSPEND"; + static final String BAN = "BAN"; + static final String UNBAN = "UNBAN"; + static final String RESET_TROPHIES = "RESET_TROPHIES"; + static final String REVOKE_ITEMS = "REVOKE_ITEMS"; + + private Moderation() { + } + } + + static final class Flag { + static final String AUTO = "AUTO"; + + private Flag() { + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/Conditions.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/Conditions.java new file mode 100644 index 00000000000..6c1c79c6363 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/Conditions.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.Component; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.splat.api.ConditionDto; +import java.util.ArrayList; +import java.util.List; + +/// The list of conditions a query or a pipeline is made of: "days away is +/// at least 7", "purchases equals 0". Each line is a field, a comparison and +/// a number; a player must meet every line. The fields are the server's +/// (`social.Audience`), which refuses anything else. +// tag::splat-conditions[] +final class Conditions { + static final String[] FIELDS = {"daysAway", "daysSinceJoin", "trophies", "races", "wins", "gems", "coins", + "purchases", "friends"}; + static final String[] FIELD_NAMES = {"Days away", "Days since joining", "Trophies", "Races", "Wins", "Gems", + "Coins", "Purchases", "Friends invited"}; + /// The same names as they read inside a sentence. + static final String[] FIELD_WORDS = {"days away", "days since joining", "trophies", "races", "wins", "gems", + "coins", "purchases", "friends invited"}; + static final String[] OPS = {">=", "<=", "=", ">", "<", "!="}; + static final String[] OP_NAMES = {"at least", "at most", "equals", "more than", "less than", "is not"}; + static final int MAX = 6; + + private final List lines = new ArrayList<>(); + private final Container box = new Container(BoxLayout.y()); + private final String name; + + Conditions(String name, List start) { + this.name = name; + if (start != null) { + lines.addAll(start); + } + box.setName(name); + build(); + } + + Container component() { + return box; + } + + List value() { + return new ArrayList<>(lines); + } + + /// "days away at least 7 and purchases equals 0", for a list. + static String describe(List conditions) { + StringBuilder sb = new StringBuilder(); + for (ConditionDto c : conditions) { + if (sb.length() > 0) { + sb.append(" and "); + } + sb.append(FIELD_WORDS[index(FIELDS, c.field())]).append(' ') + .append(OP_NAMES[index(OPS, c.op())]).append(' ').append(c.value()); + } + return sb.length() == 0 ? "every player" : sb.toString(); + } + + private void build() { + box.removeAll(); + for (int i = 0; i < lines.size(); i++) { + box.add(line(i)); + } + if (lines.size() < MAX) { + Button add = Desk.button("Add condition", name + "-add", e -> { + lines.add(new ConditionDto("daysAway", ">=", 7)); + build(); + }); + FontImage.setMaterialIcon(add, FontImage.MATERIAL_ADD); + box.add(Desk.actions(add)); + } + if (box.getComponentForm() != null) { + box.getComponentForm().revalidate(); + } + } + + private Container line(final int i) { + ConditionDto c = lines.get(i); + Component field = Desk.choice(name + "-field-" + i, FIELD_NAMES, index(FIELDS, c.field()), + v -> set(i, FIELDS[v], null, null)); + Component op = Desk.choice(name + "-op-" + i, OP_NAMES, index(OPS, c.op()), v -> set(i, null, OPS[v], null)); + Component value = Desk.number(name + "-value-" + i, c.value(), v -> set(i, null, null, v)); + Container fields; + if (AdminConsole.wide()) { + fields = GridLayout.encloseIn(3, field, op, value); + } else { + // A phone has no room for three pickers side by side: the field + // on its own line, the comparison under it. + fields = BoxLayout.encloseY(field, GridLayout.encloseIn(2, op, value)); + } + Button remove = Desk.button("", name + "-remove-" + i, e -> { + lines.remove(i); + build(); + }); + FontImage.setMaterialIcon(remove, FontImage.MATERIAL_CLOSE); + Container row = new Container(new BorderLayout()); + row.setUIID("DeskConditionRow"); + // One width for "Where" and "and", so the rows' fields line up. + com.codename1.ui.Label joint = new com.codename1.ui.Label(i == 0 ? "Where" : "and", "DeskLabel"); + joint.setPreferredW(new com.codename1.ui.Label("Where", "DeskLabel").getPreferredW()); + row.add(BorderLayout.WEST, joint); + row.add(BorderLayout.CENTER, fields).add(BorderLayout.EAST, remove); + return row; + } + + private void set(int i, String field, String op, Integer value) { + ConditionDto c = lines.get(i); + lines.set(i, new ConditionDto(field == null ? c.field() : field, op == null ? c.op() : op, + value == null ? c.value() : value)); + } + + static int index(String[] all, String s) { + for (int i = 0; i < all.length; i++) { + if (all[i].equals(s)) { + return i; + } + } + return 0; + } +} +// end::splat-conditions[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/Desk.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/Desk.java new file mode 100644 index 00000000000..81ab8773d58 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/Desk.java @@ -0,0 +1,382 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.charts.ChartComponent; +import com.codename1.charts.models.XYMultipleSeriesDataset; +import com.codename1.charts.models.XYSeries; +import com.codename1.charts.renderers.XYMultipleSeriesRenderer; +import com.codename1.charts.renderers.XYSeriesRenderer; +import com.codename1.charts.views.BarChart; +import com.codename1.charts.views.LineChart; +import com.codename1.components.MultiButton; +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.spinner.Picker; +import java.util.List; + +/// The console's own widgets. The game is all night track and neon; the +/// console is a tool, so it looks like a desktop application: a light page, +/// a side bar, plain fields in one aligned column and charts. Every look is +/// a `Desk*` style in `theme.css`. +// tag::splat-desk[] +final class Desk { + /// The width of the label column on a phone; a wide window gets more. + private static final float LABEL_MM = 24f; + private static final float WIDE_LABEL_MM = 34f; + + interface Text { + void set(String value); + } + + interface Choice { + void set(int index); + } + + private Desk() { + } + + static Container page() { + Container c = new Container(BoxLayout.y()); + c.setUIID("DeskPage"); + c.setScrollableY(true); + return c; + } + + static Label section(String title) { + return new Label(title, "DeskSection"); + } + + /// A white panel, the way a settings window groups its fields. + static Container panel() { + Container c = new Container(BoxLayout.y()); + c.setUIID("DeskPanel"); + return c; + } + + static SpanLabel help(String text) { + SpanLabel l = new SpanLabel(text); + l.setUIID("DeskHelpBox"); + l.setTextUIID("DeskHelp"); + return l; + } + + // tag::splat-desk-field[] + /// A label and its field on one line, the label in a column of its own, + /// right aligned, so every field of a form starts at the same place. + static Container field(String label, Component input) { + Label l = new Label(label, "DeskLabel"); + l.setPreferredW(CN.convertToPixels(AdminConsole.wide() ? WIDE_LABEL_MM : LABEL_MM)); + Container c = new Container(new BorderLayout()); + c.setUIID("DeskFieldRow"); + c.add(BorderLayout.WEST, l).add(BorderLayout.CENTER, input); + return c; + } + // end::splat-desk-field[] + + static TextField text(String name, String value, String hint, int max, final Text sink) { + final TextField t = new TextField(value == null ? "" : value, hint, 20, TextArea.ANY); + t.setUIID("DeskInput"); + t.setName(name); + t.setMaxSize(max); + t.addDataChangedListener((type, index) -> sink.set(t.getText())); + return t; + } + + static TextArea area(String name, String value, int max, final Text sink) { + final TextArea t = new TextArea(value == null ? "" : value, 3, 20); + t.setUIID("DeskInput"); + t.setName(name); + t.setMaxSize(max); + t.setGrowByContent(true); + t.addDataChangedListener((type, index) -> sink.set(t.getText())); + return t; + } + + static TextField number(String name, int value, final Choice sink) { + final TextField t = new TextField(String.valueOf(value), "", 6, TextArea.NUMERIC); + t.setUIID("DeskInput"); + t.setName(name); + t.setMaxSize(7); + t.addDataChangedListener((type, index) -> { + try { + sink.set(Integer.parseInt(t.getText().trim())); + } catch (NumberFormatException notYet) { + // Blank or half typed: keep the last number, Save checks it. + } + }); + return t; + } + + static Picker choice(String name, final String[] options, int selected, final Choice sink) { + final Picker p = new Picker(); + p.setType(com.codename1.ui.Display.PICKER_TYPE_STRINGS); + p.setStrings(options); + p.setSelectedString(options[Math.max(0, Math.min(options.length - 1, selected))]); + p.setUIID("DeskInput"); + p.setName(name); + p.addActionListener(e -> { + for (int i = 0; i < options.length; i++) { + if (options[i].equals(p.getSelectedString())) { + sink.set(i); + return; + } + } + }); + return p; + } + + static Button button(String text, String name, ActionListener go) { + return make(text, "DeskButton", name, go); + } + + static Button primary(String text, String name, ActionListener go) { + return make(text, "DeskPrimary", name, go); + } + + static Button danger(String text, String name, ActionListener go) { + return make(text, "DeskDanger", name, go); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Button make(String text, String uiid, String name, ActionListener go) { + Button b = new Button(text, uiid); + b.setName(name); + b.addActionListener(go); + return b; + } + + /// Buttons in a row at the right, under the fields they act on. + static Container actions(Component... buttons) { + Container c = new Container(new FlowLayout(Component.RIGHT)); + c.setUIID("DeskActions"); + for (Component b : buttons) { + c.add(b); + } + return c; + } + + /// A number with its name under it. + static Container card(String name, String value, String detail) { + Container c = new Container(BoxLayout.y()); + c.setUIID("DeskCard"); + c.setName("stat-" + name); + c.add(new Label(value, "DeskCardValue")); + c.add(new Label(name, "DeskCardName")); + if (detail != null) { + c.add(new Label(detail, "DeskCardDetail")); + } + return c; + } + + static Container cards(int columns, Component... cards) { + Container c = new Container(new GridLayout(columns)); + for (Component card : cards) { + c.add(card); + } + return c; + } + + static MultiButton row(String line1, String line2, String line3) { + MultiButton b = new MultiButton(line1); + b.setUIID("DeskRow"); + b.setUIIDLine1("DeskRowTitle"); + b.setUIIDLine2("DeskRowDetail"); + b.setUIIDLine3("DeskRowDetail"); + if (line2 != null) { + b.setTextLine2(line2); + } + if (line3 != null) { + b.setTextLine3(line3); + } + return b; + } + + static Label empty(String text) { + return new Label(text, "DeskEmpty"); + } + + /// One row of a table: cells of equal width. + static Container tableRow(boolean header, String... cells) { + Container c = new Container(new GridLayout(1, cells.length)); + c.setUIID(header ? "DeskTableHeader" : "DeskTableRow"); + for (String cell : cells) { + c.add(new Label(cell, header ? "DeskTableHead" : "DeskCell")); + } + return c; + } + // end::splat-desk[] + + // tag::splat-desk-charts[] + /// A line chart of daily numbers, drawn by Codename One's charts + /// package. `labels` names the days along the bottom. + static Component line(String name, String[] series, List[] values, String[] labels) { + if (labels.length == 0) { + return named(name, empty("No data yet.")); + } + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYMultipleSeriesRenderer r = renderer(); + int[] colors = colors(); + double most = 1; + for (int s = 0; s < series.length; s++) { + XYSeries xy = new XYSeries(series[s]); + for (int i = 0; i < values[s].size(); i++) { + xy.add(i, values[s].get(i)); + most = Math.max(most, values[s].get(i)); + } + data.addSeries(xy); + XYSeriesRenderer line = new XYSeriesRenderer(); + line.setColor(colors[s % colors.length]); + line.setLineWidth(CN.convertToPixels(0.5f)); + r.addSeriesRenderer(line); + } + axis(r, labels, most); + // The chart's own legend is drawn in its bottom margin and can fall + // outside the box; a row of labels under it always shows. + r.setShowLegend(false); + Component chart = wrap(name, new ChartComponent(new LineChart(data, r))); + if (series.length < 2) { + return chart; + } + Container legend = new Container(new FlowLayout(Component.CENTER)); + for (int s = 0; s < series.length; s++) { + Style dot = new Style(UIManager.getInstance().getComponentStyle("DeskHelp")); + dot.setFgColor(colors[s % colors.length]); + legend.add(new Label(series[s], FontImage.createMaterial(FontImage.MATERIAL_CIRCLE, dot, 2), "DeskHelp")); + } + return BoxLayout.encloseY(chart, legend); + } + + /// Bars, one per named count: a funnel, a distribution. + static Component bars(String name, List labels, List values) { + if (values.isEmpty()) { + return named(name, empty("No data yet.")); + } + XYMultipleSeriesDataset data = new XYMultipleSeriesDataset(); + XYMultipleSeriesRenderer r = renderer(); + XYSeries xy = new XYSeries(name); + double most = 1; + String[] names = new String[labels.size()]; + for (int i = 0; i < values.size(); i++) { + xy.add(i, values.get(i)); + most = Math.max(most, values.get(i)); + names[i] = labels.get(i); + } + data.addSeries(xy); + XYSeriesRenderer bar = new XYSeriesRenderer(); + bar.setColor(colors()[0]); + bar.setDisplayChartValues(true); + bar.setChartValuesTextSize(CN.convertToPixels(2.4f)); + r.addSeriesRenderer(bar); + r.setBarSpacing(0.4); + axis(r, names, most); + r.setXAxisMin(-0.6); + r.setXAxisMax(values.size() - 0.4); + r.setShowLegend(false); + return wrap(name, new ChartComponent(new BarChart(data, r, BarChart.Type.DEFAULT))); + } + + private static Component named(String name, Component c) { + c.setName(name); + return c; + } + + private static XYMultipleSeriesRenderer renderer() { + Style page = UIManager.getInstance().getComponentStyle("DeskChart"); + XYMultipleSeriesRenderer r = new XYMultipleSeriesRenderer(); + int text = page.getFgColor(); + r.setApplyBackgroundColor(true); + r.setBackgroundColor(0xff000000 | page.getBgColor()); + r.setMarginsColor(0xff000000 | page.getBgColor()); + r.setAxesColor(0xff000000 | UIManager.getInstance().getComponentStyle("DeskChartGrid").getFgColor()); + r.setGridColor(0xff000000 | UIManager.getInstance().getComponentStyle("DeskChartGrid").getFgColor()); + r.setLabelsColor(0xff000000 | text); + r.setXLabelsColor(0xff000000 | text); + r.setYLabelsColor(0, 0xff000000 | text); + r.setShowGrid(true); + r.setLabelsTextSize(CN.convertToPixels(2.4f)); + r.setLegendTextSize(CN.convertToPixels(2.4f)); + int m = CN.convertToPixels(3); + r.setMargins(new int[] {m, m * 2, m * 2, m}); + r.setPanEnabled(false, false); + r.setZoomEnabled(false, false); + r.setYLabelsAlign(Component.RIGHT); + r.setYLabelsPadding(CN.convertToPixels(1)); + return r; + } + + private static void axis(XYMultipleSeriesRenderer r, String[] labels, double most) { + r.setXLabels(0); + // As many names along the bottom as fit the width without touching: + // seven dates fill a desktop chart, and overlap on a phone's. + int longest = 1; + for (String l : labels) { + longest = Math.max(longest, l.length()); + } + // A digit or letter of the label font is about 0.6 of its size, and + // 3 mm of air keeps neighbours apart. The plot is about 60% of the + // screen's width once the card and the axis margins are taken. + float labelPx = longest * 0.6f * CN.convertToPixels(2.4f) + CN.convertToPixels(3); + int fit = Math.max(2, Math.min(7, (int) (CN.getDisplayWidth() * 0.6f / labelPx))); + int every = Math.max(1, (labels.length + fit - 1) / fit); + for (int i = 0; i < labels.length; i += every) { + r.addXTextLabel(i, labels[i]); + } + r.setYAxisMin(0); + r.setYAxisMax(most * 1.15); + r.setYLabels(5); + } + + /// The series colours: the accent first. + private static int[] colors() { + UIManager m = UIManager.getInstance(); + return new int[] {0xff000000 | m.getComponentStyle("DeskChartA").getFgColor(), + 0xff000000 | m.getComponentStyle("DeskChartB").getFgColor(), + 0xff000000 | m.getComponentStyle("DeskChartC").getFgColor()}; + } + + private static Component wrap(String name, ChartComponent chart) { + chart.setName(name); + chart.setUIID("DeskChart"); + Container c = new Container(new BorderLayout()) { + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension(CN.convertToPixels(60), CN.convertToPixels(45)); + } + }; + c.setUIID("DeskChartBox"); + c.add(BorderLayout.CENTER, chart); + return c; + } + // end::splat-desk-charts[] +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/PipelinesTab.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/PipelinesTab.java new file mode 100644 index 00000000000..eee09874eb3 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/PipelinesTab.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.api.PipelineDto; +import com.codenameone.examples.splat.api.QueryDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.ui.Ui; +import java.util.Date; +import java.util.List; + +/// Engagement pipelines: "when a player matches these conditions, do this, +/// at most once every N days". The server runs every enabled pipeline each +/// hour (`social.Pipelines`); this page lists them, edits them and runs one +/// at once. +/// +/// A push from a pipeline carries the campaign `pipeline-`, so how +/// many players it brought back is read in Codename One analytics next to +/// every other campaign. +// tag::splat-pipelines-tab[] +final class PipelinesTab { + static final String[] ACTIONS = {"push", "gems"}; + static final String[] ACTION_NAMES = {"Send a push", "Give gems, with a push"}; + + private final Container page = Desk.page(); + + PipelinesTab() { + page.setName("pipelines"); + } + + Container page() { + return page; + } + + /// Set by the query tool just before it selects this tab: selecting a tab + /// refreshes it, and the list arriving afterwards would replace the editor. + private PipelineDto pending; + + void refresh() { + if (pending != null) { + PipelineDto start = pending; + pending = null; + edit(start); + return; + } + Api.admin().pipelines(Net.to(list -> Ui.later(() -> show(list)), Ui::failed)); + } + + private void show(List list) { + page.removeAll(); + Container head = new Container(new BorderLayout()); + head.add(BorderLayout.CENTER, Desk.section("Pipelines")); + Button add = Desk.primary("New pipeline", "pipelineNew", e -> edit(blank(null))); + FontImage.setMaterialIcon(add, FontImage.MATERIAL_ADD); + head.add(BorderLayout.EAST, add); + page.add(head); + page.add(Desk.help("Each pipeline runs every hour. A player it acts on is left alone for its cool-down, " + + "so nobody hears the same thing twice in a row.")); + if (list.isEmpty()) { + page.add(Desk.empty("No pipelines yet.")); + } + for (final PipelineDto p : list) { + page.add(card(p)); + } + page.revalidate(); + } + + private Container card(final PipelineDto p) { + Container c = Desk.panel(); + c.setName("pipeline-" + p.name()); + Container title = new Container(new BorderLayout()); + title.add(BorderLayout.CENTER, new Label(p.name(), "DeskPanelTitle")); + title.add(BorderLayout.EAST, new Label(p.enabled() ? "On" : "Off", p.enabled() ? "DeskBadgeOn" : "DeskBadge")); + c.add(title); + c.add(Desk.field("When", new Label(Conditions.describe(p.conditions()), "DeskValue"))); + c.add(Desk.field("Then", new Label("gems".equals(p.action()) + ? "give " + p.amount() + " gems: \"" + p.title() + "\"" : "push \"" + p.title() + "\"", "DeskValue"))); + c.add(Desk.field("At most", new Label("once every " + p.cooldownDays() + " days a player", "DeskValue"))); + c.add(Desk.field("Reached", new Label(p.reached() + " players" + + (p.lastRun() == 0 ? ", not run yet" : ", last run " + new SimpleDateFormat("MMM d, HH:mm").format(new Date(p.lastRun()))), "DeskValue"))); + c.add(Desk.actions( + Desk.danger("Delete", "pipelineDelete-" + p.name(), e -> { + if (Dialog.show("Delete pipeline", "Delete \"" + p.name() + "\"?", "Delete", "Cancel")) { + Api.admin().deletePipeline(p.id(), Net.to(x -> Ui.later(this::refresh), Ui::failed)); + } + }), + Desk.button("Edit", "pipelineEdit-" + p.name(), e -> edit(p)), + Desk.button("Run now", "pipelineRun-" + p.name(), e -> Api.admin().runPipeline(p.id(), + Net.to(done -> Ui.later(() -> { + Ui.toast("Reached " + (done.reached() - p.reached()) + " players"); + refresh(); + }), Ui::failed))))); + return c; + } + + static PipelineDto blank(List conditions) { + return new PipelineDto(null, "", true, conditions == null ? java.util.Collections.emptyList() + : conditions, "push", "", "", "", 20, 14, 0, 0L); + } + + /// The editor, in place of the list. Every field is in one aligned + /// column; the count tells the operator who it would reach today. + void editNext(PipelineDto start) { + pending = start; + } + + void edit(PipelineDto start) { + final PipelineDto[] d = {start}; + final Conditions when = new Conditions("pipelineWhen", start.conditions()); + page.removeAll(); + page.add(Desk.section(start.id() == null ? "New pipeline" : "Edit " + start.name())); + Container form = Desk.panel(); + form.add(Desk.field("Name", Desk.text("pipelineName", start.name(), "Win back", 40, + v -> d[0] = with(d[0], v, null, null, null, null, null, -1, -1)))); + final Switch on = new Switch(); + on.setValue(start.enabled()); + on.setName("pipelineEnabled"); + form.add(Desk.field("Enabled", on)); + form.add(Desk.field("When", when.component())); + form.add(Desk.field("Then", Desk.choice("pipelineAction", ACTION_NAMES, + Conditions.index(ACTIONS, start.action()), i -> d[0] = with(d[0], null, ACTIONS[i], null, null, null, + null, -1, -1)))); + form.add(Desk.field("Gems", Desk.number("pipelineGems", start.amount(), + v -> d[0] = with(d[0], null, null, null, null, null, null, v, -1)))); + form.add(Desk.field("Title", Desk.text("pipelineTitle", start.title(), "Your troops miss you", 60, + v -> d[0] = with(d[0], null, null, v, null, null, null, -1, -1)))); + form.add(Desk.field("Message", Desk.area("pipelineBody", start.body(), 200, + v -> d[0] = with(d[0], null, null, null, v, null, null, -1, -1)))); + form.add(Desk.field("Opens", Desk.choice("pipelineScreen", PromoteTab.SCREEN_NAMES, + Conditions.index(PromoteTab.SCREENS, start.screen()), + i -> d[0] = with(d[0], null, null, null, null, PromoteTab.SCREENS[i], null, -1, -1)))); + form.add(Desk.field("Cool-down", Desk.number("pipelineCooldown", start.cooldownDays(), + v -> d[0] = with(d[0], null, null, null, null, null, null, -1, v)))); + form.add(Desk.help("Gems is used only when the pipeline gives gems. The cool-down is in days.")); + final Label count = new Label("", "DeskHelp"); + count.setName("pipelineCount"); + form.add(count); + form.add(Desk.actions( + Desk.button("Cancel", "pipelineCancel", e -> refresh()), + Desk.button("Count players", "pipelineCountButton", e -> Api.admin().query( + new QueryDto(when.value()), Net.to(r -> Ui.later(() -> { + count.setText(r.count() + " players match today"); + page.revalidate(); + }), Ui::failed))), + Desk.primary("Save", "pipelineSave", e -> { + PipelineDto p = d[0]; + if (p.name().trim().length() == 0 + || "push".equals(p.action()) && p.title().trim().length() == 0) { + Ui.toast("Give the pipeline a name, and its push a title"); + return; + } + if (p.cooldownDays() < 1 || "gems".equals(p.action()) && p.amount() < 1) { + Ui.toast("Set a cool-down of at least a day, and the gems to give"); + return; + } + PipelineDto save = new PipelineDto(p.id(), p.name(), on.isValue(), when.value(), p.action(), + p.title(), p.body(), p.screen(), p.amount(), p.cooldownDays(), p.reached(), p.lastRun()); + Api.admin().savePipeline(save, Net.to(x -> Ui.later(() -> { + Ui.toast("Saved"); + refresh(); + }), Ui::failed)); + }))); + page.add(form); + page.revalidate(); + } + + private static PipelineDto with(PipelineDto p, String name, String action, String title, String body, + String screen, Boolean enabled, int amount, int cooldown) { + return new PipelineDto(p.id(), name == null ? p.name() : name, enabled == null ? p.enabled() : enabled, + p.conditions(), action == null ? p.action() : action, title == null ? p.title() : title, + body == null ? p.body() : body, screen == null ? p.screen() : screen, amount < 0 ? p.amount() : amount, + cooldown < 0 ? p.cooldownDays() : cooldown, p.reached(), p.lastRun()); + } +} +// end::splat-pipelines-tab[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/PromoteTab.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/PromoteTab.java new file mode 100644 index 00000000000..7fa3b912326 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/PromoteTab.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.analytics.invite.Invite; +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Label; +import com.codenameone.examples.splat.api.BroadcastDto; +import com.codenameone.examples.splat.api.InviterDto; +import com.codenameone.examples.splat.api.PromoteDto; +import com.codenameone.examples.splat.api.SpotlightDto; +import com.codenameone.examples.splat.engage.Friends; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.ui.Ui; +import java.util.Date; + +/// The console's Promote tab: how a game developer brings players in and back. +/// +/// The reports, the query tool and the pipelines have pages of their own; +/// this one is the hand-sent side. The numbers here are the game's own +/// economy: who joined through an invite and who was paid for it. Behaviour +/// inside the app, and what each campaign brought back, is in Codename One +/// analytics. +/// +/// - **Broadcast** sends a Codename One push to a segment of players, with the +/// screen a tap opens and a campaign name. The app reports the tap under +/// that campaign, so the console shows what each broadcast brought back. +/// - **Spotlight** is the banner on every player's home screen. +/// - **Campaign link** makes an invite link for an advert or a post, with no +/// player's code in it; everyone it brings in is reported under its +/// campaign. +// tag::splat-promote-tab[] +final class PromoteTab { + private static final String[] SEGMENTS = {"all", "lapsed", "new", "invited"}; + private static final String[] SEGMENT_NAMES = {"Everyone", "Away for a while", "Joined recently", + "Came through an invite"}; + /// The screens a message can open: the ones `Nav.screen` routes, and the + /// only ones the server's `Promotion.SCREENS` accepts. + static final String[] SCREENS = {"", "shop", "friends", "loadout", "play", "crews"}; + static final String[] SCREEN_NAMES = {"Home", "Store", "Friends", "Operator", "Operations", "Crews"}; + + private final Container col = Desk.page(); + private String segment = SEGMENTS[0]; + private int days = 7; + private String title = ""; + private String body = ""; + private String screen = ""; + private String campaign = ""; + private String spotText = ""; + private String spotPack = ""; + private String spotScreen = ""; + private String linkCampaign = "launch"; + + PromoteTab() { + col.setName("promote"); + } + + Container column() { + return col; + } + + void refresh() { + Api.admin().promote(Net.to(p -> Ui.later(() -> show(p)), Ui::failed)); + } + + private void show(PromoteDto p) { + col.removeAll(); + broadcastComposer(); + if (!p.broadcasts().isEmpty()) { + col.add(Desk.section("Sent")); + col.add(Desk.tableRow(true, "Title", "Campaign", "Players", "Sent")); + SimpleDateFormat fmt = new SimpleDateFormat("MMM d, HH:mm"); + for (BroadcastDto b : p.broadcasts()) { + col.add(Desk.tableRow(false, b.title(), b.campaign(), String.valueOf(b.targets()), + fmt.format(new Date(b.sentAt())))); + } + } + spotlightEditor(p.spotlight()); + campaignLink(); + col.add(Desk.section("Invitations")); + col.add(Desk.cards(AdminConsole.wide() ? 4 : 2, + Desk.card("Players", String.valueOf(p.players()), null), + Desk.card("New this week", String.valueOf(p.joinedThisWeek()), null), + Desk.card("Invited", String.valueOf(p.invited()), "joined through a link"), + Desk.card("Played", String.valueOf(p.invitedPlayed()), "of those, ran a race"))); + if (!p.inviters().isEmpty()) { + col.add(Desk.tableRow(true, "Top inviters", "Joined", "Played")); + for (InviterDto i : p.inviters()) { + col.add(Desk.tableRow(false, i.displayName(), String.valueOf(i.joined()), String.valueOf(i.played()))); + } + } + col.revalidate(); + } + + private void broadcastComposer() { + col.add(Desk.section("Broadcast a push")); + Container form = Desk.panel(); + form.add(Desk.field("To", Desk.choice("promoteSegment", SEGMENT_NAMES, indexOf(SEGMENTS, segment), + i -> segment = SEGMENTS[i]))); + form.add(Desk.field("Days", Desk.number("promoteDays", days, v -> days = v))); + form.add(Desk.field("Title", Desk.text("promoteTitle", title, "Season two is here", 60, v -> title = v))); + form.add(Desk.field("Message", Desk.area("promoteBody", body, 200, v -> body = v))); + form.add(Desk.field("Opens", Desk.choice("promoteScreen", SCREEN_NAMES, indexOf(SCREENS, screen), + i -> screen = SCREENS[i]))); + form.add(Desk.field("Campaign", Desk.text("promoteCampaign", campaign, "season-two", 40, + v -> campaign = v))); + form.add(Desk.help("Days counts back from today: how long a lapsed player has been away, or how " + + "recently a new one joined. The campaign names this push in analytics. For a push that goes " + + "out by itself whenever a player qualifies, make a pipeline.")); + form.add(Desk.actions( + Desk.button("Count players", "promoteCount", e -> Api.admin().audience(draft(), Net.to( + b -> Ui.later(() -> Ui.toast(b.targets() + " players would get this")), Ui::failed))), + Desk.primary("Send", "promoteSend", e -> send()))); + col.add(form); + } + + private BroadcastDto draft() { + return new BroadcastDto(null, segment, days, title.trim(), body.trim(), screen, campaign.trim(), 0, + null, 0L); + } + + /// Counted first and confirmed: a push to every player cannot be taken + /// back, and the server allows only a few a day. + private void send() { + final BroadcastDto b = draft(); + Api.admin().audience(b, Net.to(counted -> Ui.later(() -> { + if (!Dialog.show("Send push", "Send \"" + b.title() + "\" to " + counted.targets() + " players?", + "Send", "Cancel")) { + return; + } + Api.admin().broadcast(b, Net.to(sent -> Ui.later(() -> { + Ui.toast("Sent to " + sent.targets() + " players"); + title = ""; + body = ""; + campaign = ""; + refresh(); + }), Ui::failed)); + }), Ui::failed)); + } + + private void spotlightEditor(SpotlightDto current) { + if (current != null) { + spotText = current.text() == null ? "" : current.text(); + spotPack = current.packId() == null ? "" : current.packId(); + spotScreen = current.screen() == null ? "" : current.screen(); + } + col.add(Desk.section("Home spotlight")); + Container form = Desk.panel(); + form.add(Desk.field("Banner", Desk.text("spotlightText", spotText, "", 140, v -> spotText = v))); + form.add(Desk.field("Forge pack", Desk.text("spotlightPack", spotPack, "pack id", 40, v -> spotPack = v))); + form.add(Desk.field("Opens", Desk.choice("spotlightScreen", SCREEN_NAMES, indexOf(SCREENS, spotScreen), + i -> spotScreen = SCREENS[i]))); + form.add(Desk.help("Shown at the top of every player's home screen. A pack id wins over the " + + "screen. Save an empty banner to take it down.")); + form.add(Desk.actions(Desk.primary("Save spotlight", "spotlightSave", e -> Api.admin().spotlight( + new SpotlightDto(spotText.trim(), spotPack.trim(), spotScreen, 0L), + Net.to(saved -> Ui.later(() -> Ui.toast("Spotlight saved")), Ui::failed))))); + col.add(form); + } + + private void campaignLink() { + col.add(Desk.section("Campaign link")); + Container form = Desk.panel(); + form.add(Desk.field("Campaign", Desk.text("linkCampaign", linkCampaign, "launch", 40, + v -> linkCampaign = v))); + form.add(Desk.help("An invite link for an advert, a post or a newsletter. It opens the store for " + + "anyone without the game, and the players it brings in are reported under its campaign.")); + final Container shown = new Container(); + form.add(shown); + form.add(Desk.actions(Desk.primary("Make link", "linkMake", e -> { + String name = linkCampaign.trim(); + if (name.length() == 0) { + Ui.toast("Name the campaign first"); + return; + } + Invite link = Friends.campaignLink(name); + Display.getInstance().copyToClipboard(link.getUrl()); + shown.removeAll(); + shown.add(Desk.field("Link", new Label(link.getUrl(), "DeskValue"))); + Ui.toast("Link copied"); + col.revalidate(); + }))); + col.add(form); + } + + private static int indexOf(String[] options, String value) { + for (int i = 0; i < options.length; i++) { + if (options[i].equals(value)) { + return i; + } + } + return 0; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/QueryTab.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/QueryTab.java new file mode 100644 index 00000000000..04e21df5836 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/QueryTab.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.splat.api.ConditionDto; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.QueryDto; +import com.codenameone.examples.splat.api.QueryResultDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.ui.Ui; +import java.util.Arrays; + +/// The query tool: which players meet a set of conditions, how many, and +/// who they are. The same conditions make a pipeline's audience, so an +/// operator tries a query here first, then turns it into a pipeline. +// tag::splat-query-tab[] +final class QueryTab { + private final Container page = Desk.page(); + /// Starts as "never bought anything": the players a first offer is for. + private final Conditions conditions = new Conditions("query", Arrays.asList( + new ConditionDto("purchases", "=", 0))); + private final Container results = new Container(BoxLayout.y()); + private final AdminConsole console; + + QueryTab(AdminConsole console) { + this.console = console; + page.setName("query"); + page.add(Desk.section("Find players")); + Container panel = Desk.panel(); + panel.add(conditions.component()); + panel.add(Desk.actions( + Desk.button("Make a pipeline", "queryToPipeline", e -> console.newPipeline(conditions.value())), + run())); + page.add(panel); + page.add(Desk.help("Days away counts from the last time the player opened the game. Purchases are store " + + "purchases; friends invited are players who joined through this player's link.")); + page.add(results); + } + + private Button run() { + Button b = Desk.primary("Run query", "queryRun", e -> Api.admin().query( + new QueryDto(conditions.value()), Net.to(r -> Ui.later(() -> show(r)), Ui::failed))); + FontImage.setMaterialIcon(b, FontImage.MATERIAL_SEARCH); + return b; + } + + Container page() { + return page; + } + + void refresh() { + // The query runs when asked: it reads every player. + } + + private void show(QueryResultDto r) { + results.removeAll(); + com.codename1.ui.Label count = Desk.section(r.count() == 1 ? "1 player" : r.count() + " players"); + count.setName("queryCount"); + results.add(count); + if (r.count() > r.players().size()) { + results.add(Desk.help("The first " + r.players().size() + ", longest away first.")); + } + results.add(Desk.tableRow(true, "Player", "Trophies", "Races", "Gems")); + for (final PlayerDto p : r.players()) { + Container row = Desk.tableRow(false, "", String.valueOf(p.trophies()), + String.valueOf(p.wins() + p.losses() + p.draws()), String.valueOf(p.gems())); + // The name is a link to everything the console knows about them. + Button name = new Button(p.displayName(), "DeskLink"); + name.setName("queryPlayer-" + p.username()); + name.addActionListener(e -> console.openPlayer(p.username())); + row.replace(row.getComponentAt(0), name, null); + results.add(row); + } + page.revalidate(); + } +} +// end::splat-query-tab[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/ReportsTab.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/ReportsTab.java new file mode 100644 index 00000000000..340037efc4b --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/admin/ReportsTab.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.admin; + +import com.codename1.l10n.SimpleDateFormat; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.splat.AppConfig; +import com.codenameone.examples.splat.api.AdminStatsDto; +import com.codenameone.examples.splat.api.InsightsDto; +import com.codenameone.examples.splat.api.NameCountDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.ui.Ui; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/// The console's first page: what is happening now, and how the game has +/// done over the last days -- players, new accounts, races, sales, the +/// funnel from joining to paying, retention and the trophy ladder. +/// +/// Every number is read from the game's own records by the server's +/// `Insights`. Behaviour inside the app (screens, sessions, shares, push +/// opens) is in Codename One analytics; the last button opens it. +// tag::splat-reports-tab[] +final class ReportsTab { + private static final String[] RANGES = {"Last 7 days", "Last 14 days", "Last 30 days", "Last 90 days"}; + private static final int[] RANGE_DAYS = {7, 14, 30, 90}; + private static final long DAY = 24L * 60 * 60 * 1000; + + private final Container page = Desk.page(); + private final Container now = new Container(BoxLayout.y()); + private final Container trends = new Container(BoxLayout.y()); + private int range = 2; + + ReportsTab() { + page.setName("reports"); + page.add(Desk.section("Right now")); + page.add(now); + Container head = new Container(new BorderLayout()); + head.add(BorderLayout.CENTER, Desk.section("Trends")); + head.add(BorderLayout.EAST, Desk.choice("reportRange", RANGES, range, i -> { + range = i; + loadTrends(); + })); + page.add(head); + page.add(trends); + } + + Container page() { + return page; + } + + void refresh() { + Api.admin().stats(Net.to(s -> Ui.later(() -> showNow(s)), Ui::failed)); + loadTrends(); + } + + private void loadTrends() { + final int asked = range; + Api.admin().reports(RANGE_DAYS[asked], Net.to(r -> Ui.later(() -> { + if (asked == range) { + showTrends(r); + } + }), Ui::failed)); + } + + private void showNow(AdminStatsDto s) { + now.removeAll(); + int columns = AdminConsole.wide() ? 4 : 2; + now.add(Desk.cards(columns, + Desk.card("Players", String.valueOf(s.players()), s.online() + " online"), + Desk.card("Live races", String.valueOf(s.liveRaces()), s.racesToday() + " today"), + Desk.card("Open flags", String.valueOf(s.openFlags()), s.suspended() + " suspended"), + Desk.card("Sales today", String.valueOf(s.purchasesToday()), s.gemsSoldToday() + " gems"))); + page.revalidate(); + } + + @SuppressWarnings("unchecked") + private void showTrends(InsightsDto r) { + trends.removeAll(); + int days = r.active().size(); + String[] labels = new String[days]; + SimpleDateFormat fmt = new SimpleDateFormat("MMM d"); + for (int i = 0; i < days; i++) { + labels[i] = fmt.format(new Date(r.firstDay() + i * DAY)); + } + Container charts = new Container(new GridLayout(AdminConsole.wide() ? 2 : 1)); + charts.add(titled("Daily players", "Ran a race or a level that day, and accounts created", + Desk.line("chartPlayers", new String[] {"Played", "Joined"}, + new List[] {r.active(), r.joined()}, labels))); + charts.add(titled("Races", "Finished races a day", + Desk.line("chartRaces", new String[] {"Races"}, new List[] {r.races()}, labels))); + charts.add(titled("Funnel", "Every player so far, by how far they got", + bars("chartFunnel", r.funnel()))); + charts.add(titled("Gems sold", "Through the store, a day", + Desk.line("chartGems", new String[] {"Gems"}, new List[] {r.gems()}, labels))); + charts.add(titled("Trophy ladder", "Players in each trophy band", bars("chartTrophies", r.trophies()))); + Container back = Desk.panel(); + back.add(new Label("Retention", "DeskPanelTitle")); + back.add(new Label("Seen again at least this long after joining", "DeskHelp")); + List cards = new ArrayList<>(); + for (NameCountDto d : r.retention()) { + cards.add(Desk.card(d.name(), d.count() + "%", null)); + } + if (cards.isEmpty()) { + back.add(Desk.empty("No players yet.")); + } else { + int columns = AdminConsole.wide() ? cards.size() : Math.min(2, cards.size()); + back.add(Desk.cards(columns, cards.toArray(new Component[cards.size()]))); + } + charts.add(back); + trends.add(charts); + trends.add(Desk.help("Read from the game's own records: accounts, races and purchases. What players " + + "do inside the app (screens, sessions, shared invites, opened pushes) is reported to Codename " + + "One analytics.")); + trends.add(Desk.actions(Desk.button("Open Codename One analytics", "openAnalytics", + e -> CN.execute(AppConfig.ANALYTICS_CONSOLE)))); + page.revalidate(); + } + + private static Component bars(String name, List counts) { + List names = new ArrayList<>(); + List values = new ArrayList<>(); + for (NameCountDto c : counts) { + names.add(c.name()); + values.add(c.count()); + } + return Desk.bars(name, names, values); + } + + private static Container titled(String title, String detail, Component chart) { + Container c = Desk.panel(); + c.add(new Label(title, "DeskPanelTitle")); + c.add(new Label(detail, "DeskHelp")); + c.add(chart); + return c; + } +} +// end::splat-reports-tab[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Ads.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Ads.java new file mode 100644 index 00000000000..31d68f22dcf --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Ads.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.engage; + +import com.codename1.ads.AdConfig; +import com.codename1.ads.AdError; +import com.codename1.ads.AdFormat; +import com.codename1.ads.AdListener; +import com.codename1.ads.AdManager; +import com.codename1.ads.BannerAd; +import com.codename1.ads.InterstitialAd; +import com.codename1.ads.RewardedAd; +import com.codename1.ads.ServerSideVerificationOptions; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Dialog; +import com.codenameone.examples.splat.AppConfig; +import com.codenameone.examples.splat.net.Session; + +/// Advertising, through `com.codename1.ads`. Three placements, each the kind +/// players accept in a free game: +/// +/// - **Rewarded**: opt in, watch, get coins. The server grants the coins and +/// caps them per day, so a modified client gains nothing. +/// - **Interstitial**: between runs, at most every third one, never +/// during play and never for a Royal Pass holder. +/// - **Banner**: on the home screen only. +/// +/// The ad network is a cn1lib (AdMob, AppLovin, ...) registered with +/// [AdManager]; this class names none of them. Where no network is installed +/// -- the simulator, the web build, a fork that has not added one yet -- a +/// rewarded ad is a short stand-in dialog, so the reward flow can be built and +/// tested end to end, and the other placements are simply absent. +// tag::splat-ads[] +public final class Ads { + private static final int INTERSTITIAL_EVERY = 3; + private static int runsSinceAd; + + private Ads() { + } + + public static void start() { + if (AdManager.getProvider() != null) { + AdManager.initialize(new AdConfig().testMode(true), ready -> { }); + } + } + + /// Plays a rewarded ad and runs `granted` once the network says it was + /// watched to the end. + public static void rewarded(final Runnable granted, final Runnable unavailable) { + Telemetry.adWatched("rewarded_offer"); + if (!AdManager.isInitialized() || !AdManager.isSupported(AdFormat.REWARDED)) { + standIn(granted); + return; + } + final RewardedAd ad = new RewardedAd(AppConfig.REWARDED_AD_UNIT); + // Server-side verification: the network calls the game's server with + // this user id, so a reward could be granted there and nowhere else. + String who = Session.user() == null ? "" : Session.user().username(); + ad.setServerSideVerificationOptions(new ServerSideVerificationOptions(who, "coins")); + ad.setAdListener(new AdListener() { + @Override + public void onLoaded() { + ad.show(reward -> CN.callSerially(granted)); + } + + @Override + public void onFailedToLoad(AdError error) { + CN.callSerially(unavailable); + } + }); + ad.load(); + } + + /// Called after every run; shows an interstitial every few. + public static void betweenRuns(boolean adFree) { + runsSinceAd++; + if (adFree || runsSinceAd < INTERSTITIAL_EVERY || !AdManager.isInitialized() + || !AdManager.isSupported(AdFormat.INTERSTITIAL)) { + return; + } + runsSinceAd = 0; + final InterstitialAd ad = new InterstitialAd(AppConfig.INTERSTITIAL_AD_UNIT); + ad.setAdListener(new AdListener() { + @Override + public void onLoaded() { + ad.show(); + } + }); + ad.load(); + } + + /// A banner for the home screen, or null where there is no network. + public static Component banner(boolean adFree) { + if (adFree || !AdManager.isInitialized() || !AdManager.isSupported(AdFormat.BANNER)) { + return null; + } + BannerAd banner = new BannerAd(AppConfig.BANNER_AD_UNIT); + banner.load(); + return banner; + } + + private static void standIn(final Runnable granted) { + Dialog d = new Dialog("Sponsored", new com.codename1.ui.layouts.BorderLayout()); + d.setUIID("AdDialog"); + d.add(com.codename1.ui.layouts.BorderLayout.CENTER, + new com.codename1.components.SpanLabel("An ad would play here. Install an ad network cn1lib " + + "to show real ones.")); + d.setTimeout(1500); + d.show(); + granted.run(); + } +} +// end::splat-ads[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Friends.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Friends.java new file mode 100644 index 00000000000..c95ed4a3011 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Friends.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.engage; + +import com.codename1.analytics.invite.Invite; +import com.codename1.analytics.invite.InviteAttribution; +import com.codename1.analytics.invite.InviteListener; +import com.codename1.analytics.invite.InviteRequest; +import com.codename1.analytics.invite.Invites; +import com.codename1.io.Log; +import com.codename1.io.Preferences; +import com.codenameone.examples.splat.api.InviteClaimDto; +import com.codenameone.examples.splat.api.InviteDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.ui.Rewards; +import com.codenameone.examples.splat.ui.Ui; + +/// Invite a friend, through Codename One's `com.codename1.analytics.invite`. +/// +/// The inviter's side mints a link with [Invites#create] -- the campaign, the +/// channel, and the player's own code from the server in the payload -- and +/// hands it to the native share sheet. The friend's side calls +/// [Invites#checkForInvite] on every start: a link that opened the installed +/// app, or one that went through the store first (the Play install referrer, +/// an iOS App Clip), comes back as an [InviteAttribution]. From then on every +/// analytics event the friend's app sends carries the campaign and the +/// referrer, so the invite funnel, retention and revenue per campaign are in +/// the Codename One console without the game counting anything itself. +/// +/// The server's part is the reward: the code is kept until the friend is +/// signed in, then claimed, and the inviter is paid once the friend has +/// played. [Invites#conversion] marks those steps in analytics too. +public final class Friends { + public static final String CAMPAIGN = "friends"; + private static final String PENDING = "splat.invite."; + + private Friends() { + } + + // tag::splat-invites[] + /// From the app's start(): pick up an invite this launch carries. + public static void start() { + Invites.setInviteListener(new InviteListener() { + @Override + public void inviteReceived(InviteAttribution a) { + if (a.getPayload() == null || a.getPayload().length() == 0) { + // A campaign link from the console: attributed in + // analytics, nobody to reward. + return; + } + Preferences.set(PENDING + "code", a.getPayload()); + Preferences.set(PENDING + "campaign", a.getCampaign() == null ? "" : a.getCampaign()); + Preferences.set(PENDING + "channel", a.getChannel() == null ? "" : a.getChannel()); + Preferences.set(PENDING + "match", a.getMatchType() == null ? "" : a.getMatchType()); + if (Session.user() != null) { + Ui.later(Friends::claimPending); + } + } + + @Override + public void attributionUnavailable(String reason) { + // The ordinary case: most installs were not invited. + } + }); + Invites.checkForInvite(); + } + // end::splat-invites[] + + /// Signed in: credit a waiting invite to this account, once. + public static void claimPending() { + final String code = Preferences.get(PENDING + "code", ""); + if (code.length() == 0 || Session.user() == null) { + return; + } + InviteClaimDto claim = new InviteClaimDto(code, Preferences.get(PENDING + "campaign", ""), + Preferences.get(PENDING + "channel", ""), Preferences.get(PENDING + "match", "")); + Api.social().claim(claim, Net.to(reward -> { + clear(); + Telemetry.inviteAccepted(); + Ui.later(() -> Rewards.show(reward, () -> { })); + }, (status, message) -> { + // Refused for good (an old account, a second claim, our own code) + // or the code is unknown: forget it. Anything else is retried on + // the next sign-in. + if (status >= 400 && status < 500) { + clear(); + } + Log.p("Invite not credited: " + status + " " + message); + })); + } + + private static void clear() { + Preferences.delete(PENDING + "code"); + Preferences.delete(PENDING + "campaign"); + Preferences.delete(PENDING + "channel"); + Preferences.delete(PENDING + "match"); + } + + // tag::splat-invite-share[] + /// Opens the share sheet with the player's invite link. + public static void share(InviteDto mine) { + Invite invite = Invites.create(InviteRequest.create() + .campaign(CAMPAIGN) + .channel("share_sheet") + .payload(mine.code()) + .title("Join me in Splat") + .description("An endless runner. Race me, same track, live.") + .build()); + Invites.share(invite, "Race me in Splat! You get " + mine.friendCoins() + + " coins to start with: " + invite.getUrl()); + Telemetry.inviteSent(CAMPAIGN); + } + // end::splat-invite-share[] + + /// A link for a marketing campaign, made from the admin console: no + /// player's code in it, so it rewards nobody, and everything the players + /// it brings in do is reported under `campaign` in analytics. + public static Invite campaignLink(String campaign) { + return Invites.create(InviteRequest.create() + .campaign(campaign) + .channel("console") + .title("Splat") + .description("An endless runner you can race your friends in") + .build()); + } + + /// Marks a step of an invited player's way in, for the invite funnel: + /// the first race and the first win, once each. A no-op on an install + /// no invite brought in. + public static void raceFinished(boolean won) { + once("first_race"); + if (won) { + once("first_win"); + } + } + + private static void once(String action) { + String key = PENDING + "conv." + action; + if (!Preferences.get(key, false)) { + Preferences.set(key, true); + Invites.conversion(action); + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Notifications.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Notifications.java new file mode 100644 index 00000000000..50cf5d33977 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Notifications.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.engage; + +import com.codename1.io.Log; +import com.codename1.io.Preferences; +import com.codename1.push.PushClient; +import com.codename1.push.PushError; +import com.codename1.push.PushListener; +import com.codename1.push.PushMessage; +import com.codename1.push.PushSubscription; +import com.codename1.ui.CN; +import com.codenameone.examples.splat.AppConfig; +import com.codenameone.examples.splat.api.PushDeviceDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.ui.Nav; + +/// Push notifications, the game's way of bringing a player back: a friend +/// accepted your challenge, your daily rewards are ready, and -- for admins +/// -- a player was flagged. +/// +/// The device registers with `com.codename1.push`, then hands its token to +/// the server, which sends through the Codename One push service. A player +/// can turn them off in Settings; the server is told, and stops. +// tag::splat-push[] +public final class Notifications { + private static final String ENABLED = "splat.push"; + private static PushClient client; + private static PushSubscription subscription; + + private Notifications() { + } + + public static boolean enabled() { + return Preferences.get(ENABLED, true); + } + + public static void setEnabled(boolean on) { + Preferences.set(ENABLED, on); + if (on) { + start(); + } else { + signingOut(() -> { }); + } + } + + public static void start() { + if (!available() || !enabled()) { + return; + } + if (client == null) { + client = PushClient.builder(AppConfig.PUSH_APP_KEY).listener(new Listener()).build(); + } + client.register(); + } + + /// Tells the server where to reach this device; called after signing in. + public static void signedIn() { + PushDeviceDto device = device(); + if (device != null && Session.user() != null && enabled()) { + Api.account().registerPush(device, Net.to(saved -> { }, (s, m) -> { })); + } + } + + public static void signingOut(Runnable then) { + PushDeviceDto device = device(); + if (device == null || Session.user() == null) { + then.run(); + return; + } + Api.account().unregisterPush(device, Net.to(gone -> then.run(), (s, m) -> then.run())); + } + + /// Only on a phone with a push key configured; the simulator and the web + /// build simply never ask. + public static boolean available() { + if (AppConfig.PUSH_APP_KEY.length() == 0 || CN.isSimulator()) { + return false; + } + String platform = CN.getPlatformName(); + return "and".equals(platform) || "ios".equals(platform); + } + + private static PushDeviceDto device() { + if (subscription == null) { + return null; + } + return new PushDeviceDto(subscription.getTransportId(), subscription.getToken(), + CN.getPlatformName()); + } + + private static final class Listener implements PushListener { + @Override + public void onRegistration(PushSubscription registered) { + subscription = registered; + signedIn(); + } + + /// A tap on a notification opens the screen it is about. + @Override + public void onMessage(PushMessage message) { + if (Session.user() == null) { + return; + } + // The keys are the ones the server's GamePushes puts in `data`. + Object challenge = message.getData().get("challenge"); + Object screen = message.getData().get("screen"); + Object campaign = message.getData().get("campaign"); + if (campaign != null) { + Telemetry.pushOpened(String.valueOf(campaign)); + } + if (challenge != null) { + Nav.challenge(String.valueOf(challenge)); + } else if ("admin".equals(screen) && Session.user().admin()) { + Nav.adminConsole(); + } else { + Nav.screen(screen == null ? "" : String.valueOf(screen)); + } + } + + @Override + public void onError(PushError error) { + Log.p("Push registration failed: " + error.getCode() + " " + error.getMessage()); + } + } +} +// end::splat-push[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Store.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Store.java new file mode 100644 index 00000000000..98e28df5a5d --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Store.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.engage; + +import com.codename1.io.Log; +import com.codename1.payment.Product; +import com.codename1.payment.Purchase; +import com.codename1.payment.Receipt; +import com.codename1.ui.CN; +import com.codenameone.examples.splat.api.ReceiptDto; +import com.codenameone.examples.splat.api.WalletDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import java.util.List; + +/// In-app purchases, through `com.codename1.payment`. +/// +/// The store takes the money; the game's server grants what was bought. A +/// purchase the device reports is sent to the server as a receipt, the server +/// asks Apple or Google whether it is real, and only then adds the gems -- +/// once, however many times the receipt arrives. A client that claims a +/// purchase it did not make gets nothing. +/// +/// The main class implements `PurchaseCallback`, which is how Codename One +/// reports a finished purchase; it calls [#purchased]. +// tag::splat-store[] +public final class Store { + /// Told about the wallet after the server granted a purchase. + public interface Granted { + void granted(WalletDto wallet); + } + + private static Granted listener; + + private Store() { + } + + public static void listen(Granted granted) { + listener = granted; + } + + /// Whether a real store takes the money. The simulator's own store stands + /// in for one only when the app's main class is running to hear back, so + /// here the simulator takes the simulated-receipt path instead, which + /// exercises the same server validation. + public static boolean available() { + Purchase p = Purchase.getInAppPurchase(); + return !CN.isSimulator() && p != null && p.isManagedPaymentSupported(); + } + + /// The store's own price for `sku`, in the player's currency, or null + /// where there is no store to ask (the simulator) or it has not said. + public static String price(String sku) { + if (!available()) { + return null; + } + Product[] products = Purchase.getInAppPurchase().getProducts(new String[] {sku}); + if (products == null || products.length == 0 || products[0] == null) { + return null; + } + String p = products[0].getLocalizedPrice(); + return p == null || p.length() == 0 ? null : p; + } + + /// Starts buying `sku` from the platform's store. In the simulator the + /// purchase completes at once with a simulated receipt, which the server + /// accepts on its development profiles only. + public static void buy(String sku) { + if (available()) { + Purchase.getInAppPurchase().purchase(sku); + } else { + redeem(new ReceiptDto(Receipt.STORE_CODE_SIMULATOR, sku, + "sim-" + System.currentTimeMillis(), "")); + } + } + + /// From `PurchaseCallback.itemPurchased`: finds the store's receipt and + /// sends it to the server. + public static void purchased(String sku) { + Telemetry.purchase(sku); + Purchase p = Purchase.getInAppPurchase(); + if (p == null) { + return; + } + Receipt[] receipts = p.getReceipts(sku); + if (receipts == null || receipts.length == 0) { + return; + } + Receipt r = receipts[receipts.length - 1]; + redeem(new ReceiptDto(r.getStoreCode(), sku, r.getTransactionId(), r.getOrderData())); + } + + /// Sends every receipt the store still holds. The server grants each only + /// once, so this is safe to repeat; it picks up a purchase whose first + /// report was lost when the app was killed mid-way. + public static void resend() { + Purchase p = Purchase.getInAppPurchase(); + if (p == null || !available()) { + return; + } + List all = p.getReceipts(); + for (int i = 0; i < all.size(); i++) { + Receipt r = all.get(i); + redeem(new ReceiptDto(r.getStoreCode(), r.getSku(), r.getTransactionId(), r.getOrderData())); + } + } + + private static void redeem(ReceiptDto receipt) { + Api.shop().receipt(receipt, Net.to(wallet -> { + if (listener != null) { + CN.callSerially(() -> listener.granted(wallet)); + } + }, (status, message) -> Log.p("Receipt refused: " + status + " " + message))); + } +} +// end::splat-store[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Telemetry.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Telemetry.java new file mode 100644 index 00000000000..ed15b8e462d --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/engage/Telemetry.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.engage; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.analytics.AnalyticsProvider; +import com.codename1.analytics.CodenameOneAnalyticsProvider; +import com.codename1.analytics.LoggingAnalyticsProvider; +import com.codename1.ui.CN; + +/// What the game reports about how it is played, through +/// `com.codename1.analytics`. +/// +/// The events are a funnel a game designer reads top to bottom: installed, +/// signed up, first run, first race win, level published, first purchase. +/// Runs also report the level, how far the runner got and how it ended, +/// which is what tuning a level's difficulty needs. Nothing is sent until the player says yes on the +/// consent screen, and nothing that names them is sent at all. +/// +/// The simulator logs every event to the console instead of sending it; swap +/// in your own provider (Firebase, Amplitude, your own server) with [#use]. +// tag::splat-analytics[] +public final class Telemetry { + private static final String ACCOUNT = "account"; + private static final String RUN = "run"; + private static final String ECONOMY = "economy"; + private static boolean started; + private static String screen; + + private Telemetry() { + } + + public static void start() { + if (started) { + return; + } + started = true; + Analytics.addProvider(CN.isSimulator() ? new LoggingAnalyticsProvider() + : new CodenameOneAnalyticsProvider()); + } + + /// Replaces the providers; the tests use it to read what was sent. + public static void use(AnalyticsProvider... providers) { + started = true; + Analytics.clearProviders(); + for (AnalyticsProvider p : providers) { + Analytics.addProvider(p); + } + screen = null; + } + + public static boolean asked() { + return Analytics.getConsent() != null; + } + + public static boolean allowed() { + AnalyticsConsent c = Analytics.getConsent(); + return c != null && c.isAnalytics(); + } + + public static void allow(boolean yes) { + Analytics.setConsent(AnalyticsConsent.builder() + .analytics(yes) + .crashReporting(yes) + .personalization(false) + .adStorage(false) + .build()); + } + + public static void flush() { + Analytics.flush(); + } + + public static void screen(String name) { + if (name.equals(screen) || !allowed()) { + return; + } + Analytics.screen(name, screen); + screen = name; + } + + public static void registered() { + send(ACCOUNT, "sign_up", "method", "password"); + } + + public static void signedIn() { + send(ACCOUNT, "login", "method", "password"); + } + + /// `mode` is solo, daily, ghost, race or tutorial. + public static void runStarted(String mode, String level) { + send(RUN, "run_start", "mode", mode, "level", level); + } + + /// `place` counts from 0; a solo run is place 0 of 1. + public static void runEnded(String mode, String level, int place, int metres, boolean crashed) { + send(RUN, "run_end", "mode", mode, "level", level, "place", Integer.valueOf(place), + "metres", Integer.valueOf(metres), "crashed", crashed ? "yes" : "no"); + } + + public static void levelPublished(String pack) { + send(RUN, "level_published", "pack", pack); + } + + public static void runnerChosen(String runner) { + send(ECONOMY, "runner_chosen", "runner", runner); + } + + public static void itemBought(String item, String currency) { + send(ECONOMY, "spend_virtual_currency", "item", item, "currency", currency); + } + + public static void purchase(String sku) { + send(ECONOMY, "purchase", "sku", sku); + } + + public static void adWatched(String placement) { + send(ECONOMY, "ad_reward", "placement", placement); + } + + public static void challengeShared() { + send(ACCOUNT, "share", "content", "challenge"); + } + + public static void inviteSent(String campaign) { + send(ACCOUNT, "share", "content", "invite", "campaign", campaign); + } + + public static void inviteAccepted() { + send(ACCOUNT, "invite_accepted"); + } + + /// A tap on a push notification. The broadcast's campaign rides in its + /// data, so the console reports what each broadcast brought back. + public static void pushOpened(String campaign) { + send(ACCOUNT, "push_open", "campaign", campaign); + } + + /// Who the player is, as segments the console slices every report by: + /// how far they got. No names, no ids. + public static void describe(int level, int trophies) { + if (!allowed()) { + return; + } + Analytics.setUserProperty("level_band", level < 5 ? "1-4" : level < 10 ? "5-9" : "10+"); + Analytics.setUserProperty("trophy_band", trophies < 100 ? "0-99" : trophies < 500 ? "100-499" : "500+"); + } + + private static void send(String category, String name, Object... pairs) { + if (!allowed()) { + return; + } + AnalyticsEvent.Builder event = AnalyticsEvent.create(name).category(category); + for (int i = 0; i + 1 < pairs.length; i += 2) { + event.param(String.valueOf(pairs[i]), pairs[i + 1]); + } + Analytics.event(event.build()); + } +} +// end::splat-analytics[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/Api.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/Api.java new file mode 100644 index 00000000000..ee824df05e0 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/Api.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.net; + +import com.codename1.io.rest.RestClients; +import com.codenameone.examples.splat.api.AccountApi; +import com.codenameone.examples.splat.api.AdminApi; +import com.codenameone.examples.splat.api.GameApi; +import com.codenameone.examples.splat.api.MissionApi; +import com.codenameone.examples.splat.api.ProgressApi; +import com.codenameone.examples.splat.api.ShopApi; +import com.codenameone.examples.splat.api.SocialApi; + +/// The generated REST clients, one per contract in the shared module, all +/// pointed at the server [Session] is using. +public final class Api { + private static AccountApi account; + private static GameApi game; + private static ShopApi shop; + private static AdminApi admin; + private static ProgressApi progress; + private static SocialApi social; + private static MissionApi mission; + + private Api() { + } + + static void connect(String baseUrl) { + account = RestClients.create(AccountApi.class, baseUrl); + game = RestClients.create(GameApi.class, baseUrl); + shop = RestClients.create(ShopApi.class, baseUrl); + admin = RestClients.create(AdminApi.class, baseUrl); + progress = RestClients.create(ProgressApi.class, baseUrl); + social = RestClients.create(SocialApi.class, baseUrl); + mission = RestClients.create(MissionApi.class, baseUrl); + } + + public static AccountApi account() { + return account; + } + + public static MissionApi mission() { + return mission; + } + + public static GameApi game() { + return game; + } + + public static ShopApi shop() { + return shop; + } + + public static AdminApi admin() { + return admin; + } + + public static ProgressApi progress() { + return progress; + } + + + public static SocialApi social() { + return social; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/GameLink.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/GameLink.java new file mode 100644 index 00000000000..8fe73867282 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/GameLink.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.net; + +import com.codename1.appkit.Net; + +import com.codename1.io.CharArrayReader; +import com.codename1.io.JSONParser; +import com.codename1.io.WebSocket; +import com.codename1.io.WebSocketState; +import com.codename1.ui.CN; +import java.io.IOException; +import java.util.Map; +import java.util.Timer; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/// The game socket, `/ws/game` on the server. One is open while the player is +/// queued, playing, or (as an admin) watching. +/// +/// Opening it takes a one-time ticket from the REST API, because a browser +/// cannot put a bearer token on a WebSocket. Every message arrives on the EDT, +/// already parsed. +// tag::splat-game-link[] +public final class GameLink { + /// What a screen does with the socket. + public interface Listener { + void message(Map message); + + /// The socket is gone. `code` is the WebSocket close code, or 0 when + /// it never opened or dropped without one. + void closed(int code); + } + + private static final int PING_MILLIS = 20000; + private final Listener listener; + private WebSocket socket; + private Timer pinger; + private boolean closed; + + private GameLink(Listener listener) { + this.listener = listener; + } + + /// Opens a socket; `listener` hears `ready` first, then whatever follows. + public static GameLink open(Listener listener) { + final GameLink link = new GameLink(listener); + if (!WebSocket.isSupported()) { + CN.callSerially(() -> link.lost(0)); + return link; + } + Api.account().gameTicket(Net.to(ticket -> link.connect(ticket.ticket()), + (status, message) -> link.lost(0))); + return link; + } + + private void connect(String ticket) { + if (closed) { + return; + } + String base = Session.serverUrl(); + String url = "ws" + base.substring(4) + "/ws/game?ticket=" + ticket; + socket = WebSocket.build(url) + .onConnect(ws -> CN.callSerially(this::opened)) + .onTextMessage((ws, text) -> { + arrived.incrementAndGet(); + CN.callSerially(() -> frame(text)); + }) + .onClose((ws, code, reason) -> CN.callSerially(() -> lost(code))) + .onError((ws, err) -> { + error.set(String.valueOf(err)); + CN.callSerially(() -> lost(0)); + }) + .connect(10000); + } + + private void opened() { + pinger = CN.setInterval(PING_MILLIS, () -> send("{\"type\":\"ping\"}")); + } + + /// Frames the socket has delivered and frames the EDT has read, for a test + /// that has to tell a server that went quiet from a client that stopped + /// reading. The first is counted on the socket's own thread. + private final AtomicInteger arrived = new AtomicInteger(); + private int read; + private String closedBy; + private int largest; + private long lastFrameAt; + /// The socket's last error, written on the socket's own thread. + private final AtomicReference error = new AtomicReference(); + + /// Where the socket stands, for diagnostics. + public String describe() { + return "frames arrived " + arrived.get() + ", read " + read + ", largest " + largest + " chars" + + (closed ? ", closed by " + closedBy : ""); + } + + private void frame(String text) { + read++; + lastFrameAt = System.currentTimeMillis(); + largest = Math.max(largest, text.length()); + if (closed) { + return; + } + try { + listener.message(new JSONParser().parseJSON(new CharArrayReader(text.toCharArray()))); + } catch (IOException malformed) { + // A frame that is not JSON is not from our server; ignore it. + } + } + + private void lost(int code) { + if (closed) { + return; + } + closedBy = "socket, code " + code + (error.get() == null ? "" : " (" + error.get() + ")") + ", " + + (System.currentTimeMillis() - lastFrameAt) + " ms after the last frame"; + close(); + listener.closed(code); + } + + public void send(String json) { + if (!closed && socket != null && socket.getReadyState() == WebSocketState.OPEN) { + socket.send(json); + } + } + + public void close() { + if (closedBy == null) { + closedBy = "client"; + } + closed = true; + if (pinger != null) { + pinger.cancel(); + pinger = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + } + + public boolean isClosed() { + return closed; + } + + public static String text(Map m, String key) { + Object v = m.get(key); + return v == null ? "" : v.toString(); + } + + public static int number(Map m, String key) { + Object v = m.get(key); + return v instanceof Number ? ((Number) v).intValue() : 0; + } +} +// end::splat-game-link[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/MissionsClient.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/MissionsClient.java new file mode 100644 index 00000000000..4566571886b --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/MissionsClient.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.net; + +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.api.MissionDto; +import com.codenameone.examples.splat.rules.Mission; +import com.codenameone.examples.splat.rules.Mode; +import java.util.ArrayList; +import java.util.List; + +/// The Mission Builder's calls to the server, in the editor's own terms: a +/// [Mission] goes up as its JSON and comes back as one. +public final class MissionsClient { + /// One line of the published list. + public static final class Row { + public final String code; + public final String name; + public final String owner; + public final String modeTitle; + public final int plays; + + Row(MissionDto d) { + code = d.code(); + name = d.name(); + owner = d.ownerName() == null ? "" : d.ownerName(); + String title = d.mode(); + try { + title = Mode.byName(d.mode()).title; + } catch (IllegalArgumentException unknown) { + // shown as the server named it + } + modeTitle = title; + plays = d.plays(); + } + } + + public interface Got { + void got(T value); + } + + private MissionsClient() { + } + + /// Publishes; answers the server's code, or null when it was refused. + public static void publish(Mission m, final Got done) { + MissionDto body = new MissionDto("", m.name, m.mode.name(), "", "", 0, 0L, m.toJson()); + Api.mission().publishMission(body, Net.to(d -> done.got(d.code()), (status, msg) -> done.got(null))); + } + + public static void list(final Got> done) { + Api.mission().missions(Net.to(rows -> { + List out = new ArrayList(); + if (rows != null) { + for (MissionDto d : rows) { + out.add(new Row(d)); + } + } + done.got(out); + }, (status, msg) -> done.got(new ArrayList()))); + } + + /// The mission with that code, or null. + public static void get(String code, final Got done) { + Api.mission().mission(code, Net.to(d -> { + Mission m = null; + try { + m = Mission.fromJson(d.json()); + m.code = d.code(); + } catch (IllegalArgumentException bad) { + m = null; + } + done.got(m); + }, (status, msg) -> done.got(null))); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/Session.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/Session.java new file mode 100644 index 00000000000..b913ecece8e --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/net/Session.java @@ -0,0 +1,543 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.net; + +import com.codename1.appkit.Net; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.NetworkManager; +import com.codename1.io.RequestAuthorizer; +import com.codename1.io.Util; +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcConfiguration; +import com.codename1.io.oidc.OidcRequestAuthorizer; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.PkceChallenge; +import com.codename1.io.oidc.SecureStorageTokenStore; +import com.codename1.io.oidc.TokenStore; +import com.codename1.ui.CN; +import com.codename1.util.SuccessCallback; +import com.codenameone.examples.splat.AppConfig; +import com.codenameone.examples.splat.api.PlayerDto; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.Map; + +/// Who is signed in, and the tokens that say so. +/// +/// Signing in is OAuth's authorization-code flow with PKCE against the app's +/// own server, made without a browser: the app shows its own sign-in screen, +/// posts the password to the server for a short-lived session, exchanges the +/// session for an authorization code and the code for tokens. The password is +/// sent once, to the server that owns it, and is never stored; what is stored +/// is the token pair, in the device's secure storage. +/// +/// From then on [OidcRequestAuthorizer] does the work: it puts the access token +/// on every request under `/api`, renews it shortly before it expires, and +/// renews and retries when the server refuses one. When renewing fails -- the +/// refresh token ran out, or the account was suspended -- it says so, and the +/// app goes back to the welcome screen. +public final class Session { + private static final String STORE_PREFIX = "splat"; + private static final String SESSION_COOKIE = "CN1SESSION"; + + private static String base; + private static OidcClient client; + private static OidcRequestAuthorizer authorizer; + private static TokenStore store; + private static PlayerDto user; + private static Runnable onSignedOut; + /// Counts the sessions: moved on whenever the server changes, an account + /// signs in, or the session ends. An answer to a request made in an + /// earlier session describes somebody who is no longer signed in here -- + /// another server's account, or the account before a sign-out on this + /// one -- and is dropped rather than installed for whoever is now. + private static int session; + + private Session() { + } + + /// Points the app at a server. Called at start-up, and again when the + /// server address is changed. + public static void start(String baseUrl) { + if (store == null) { + // Falls back to ordinary storage where the platform has no keystore + // to offer, which is the simulator. + store = new SecureStorageTokenStore().allowPlainStorageFallback(true); + } + if (base != null) { + NetworkManager.getInstance().setAuthorizer(base + "/api", null); + if (!base.equals(baseUrl)) { + // Changing the server ends the session with the old one, and + // its tokens go with it. + store.clear(storeKey(base)); + } + } + base = baseUrl; + setUser(null); + session++; + client = OidcClient.create(OidcConfiguration.newBuilder() + .issuer(base) + .authorizationEndpoint(base + "/oauth2/authorize") + .tokenEndpoint(base + "/oauth2/token") + .revocationEndpoint(base + "/oauth2/revoke") + .jwksUri(base + "/oauth2/jwks") + .build()) + .setClientId(AppConfig.CLIENT_ID) + .setRedirectUri(AppConfig.redirectUri()) + .setScopes("openid", "profile") + .setTokenStore(store) + .setStoreKey(storeKey(base)); + authorizer = new OidcRequestAuthorizer(client); + authorizer.install(base + "/api"); + // Not tied to this start(). A request to an earlier server still in + // flight when the app switched, answered 401 with its refresh refused, + // signs the new server out too: one more sign-in, after a server + // switch that already asked for one, and nothing of either account is + // exposed by it. + authorizer.addSignInRequiredListener((source, reason) -> { + setUser(null); + session++; + if (onSignedOut != null) { + onSignedOut.run(); + } + }); + Api.connect(base); + } + + /// Where the tokens issued by one server are kept: under a name made from + /// that server's address. + /// + /// One name for every server would hand the tokens of the last one to + /// whichever address is typed next -- the app would restore them and send + /// them, refresh token included, to a server that never issued them. + /// Every character that is not a letter or a digit is written as its code, + /// so two addresses never share a name and the name is one any platform's + /// storage accepts. + static String storeKey(String server) { + StringBuilder key = new StringBuilder(STORE_PREFIX).append('-'); + for (int iter = 0; iter < server.length(); iter++) { + char c = server.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { + key.append(c); + } else { + key.append('_').append(Integer.toHexString(c)).append('_'); + } + } + return key.toString(); + } + + /// Keeps the tokens somewhere else. The tests keep them in memory, so one + /// run leaves nothing behind for the next. + public static void useStore(TokenStore tokens) { + store = tokens; + } + + /// What to do when the session ends without the user asking: go back to + /// the welcome screen. + public static void onSignedOut(Runnable handler) { + onSignedOut = handler; + } + + public static String serverUrl() { + return base; + } + + /// The signed-in user as the server last described them, or null. + public static PlayerDto user() { + return user; + } + + public static void setUser(PlayerDto described) { + user = described; + } + + /// Who what the device keeps belongs to: the signed-in account, or null. + /// See [#owner(String)]. + public static String owner() { + return user == null || user.username() == null ? null : owner(user.username()); + } + + /// The device's name for the account `username` on the server in use. A + /// name is unique on one server only: the same e-mail address signed in + /// to two servers is two accounts, and what one left -- recent places, + /// a change it has not sent yet -- is not the other's to see or send. + public static String owner(String username) { + return storeKey(base == null ? "" : base) + "/" + accountName(username); + } + + /// Picks up the session a previous run left, if it is still good. + /// + /// @param signedIn there is a session, and [#user] is set + /// @param signedOut there is none + /// @param unreachable there is one, but the server did not answer + public static void restore(final Runnable signedIn, final Runnable signedOut, + final Runnable unreachable) { + // Loading the tokens can outlast the session it was asked in -- the + // server is changed while it reads -- and then neither answer is this + // session's to act on: see [#session]. + final int asked = session; + authorizer.load().ready(new SuccessCallback() { + @Override + public void onSucess(OidcTokens tokens) { + if (asked != session) { + return; + } + if (tokens == null || tokens.getAccessToken() == null) { + signedOut.run(); + return; + } + refreshUser(signedIn, (status, message) -> { + if (status == 0) { + unreachable.run(); + } else { + signedOut.run(); + } + }); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + if (asked == session) { + signedOut.run(); + } + } + }); + } + + /// Asks the server who this is. The roles in the answer decide which modes + /// the app offers, so it is asked again whenever that could have changed. + public static void refreshUser(final Runnable done, Net.Failed failed) { + // An answer from a session that has since ended -- the server was + // changed while an approval poll waited, or the account signed out + // and another signed in -- describes that account, not this one; + // it is dropped. See [#session]. + final int asked = session; + Api.account().me(Net.to(described -> { + if (asked != session) { + return; + } + setUser(described); + done.run(); + }, (status, message) -> { + if (asked == session) { + failed.failed(status, message); + } + })); + } + + /// Signs in with an e-mail address and password. + /// An e-mail address the way the server names its account: trimmed, with + /// its ASCII letters in lower case (`Accounts.email` on the server). Signing + /// in as `Player@Splat.example` must reach, and record its mode under, the + /// same account as `player@splat.example`. Folded by hand: `toLowerCase()` + /// follows the device locale, and under Turkish the `I` would not fold to `i`. + public static String accountName(String email) { + if (email == null) { + return null; + } + char[] chars = email.trim().toCharArray(); + for (int iter = 0; iter < chars.length; iter++) { + char c = chars[iter]; + if (c >= 'A' && c <= 'Z') { + chars[iter] = (char) (c + ('a' - 'A')); + } + } + return new String(chars); + } + + public static void signIn(String email, String password, final Runnable done, + final Net.Failed failed) { + // The server can be changed while the sign-in is under way, and + // start() then puts a new authorizer in place. Tokens the old server + // issued are dropped rather than installed in the new one, which would + // send them -- the refresh token too -- to a server that never issued + // them. See [#session]. + final int asked = session; + authenticate(email, password, tokens -> { + if (asked != session) { + return; + } + session++; + authorizer.setTokens(tokens); + store.save(storeKey(base), tokens); + refreshUser(done, failed); + }, failed); + } + + /// Exchanges an e-mail address and password for tokens, and does nothing + /// with them: [#signIn] is this, and then keeping what comes back. + public static void authenticate(final String email, final String password, + final Net.Ok got, final Net.Failed failed) { + final PkceChallenge pkce = PkceChallenge.generate(); + // Any unguessable value does for the state; this is one. + final String state = PkceChallenge.generate().getVerifier(); + // In a browser the app sees neither the session cookie nor a redirect: + // the browser keeps the first and follows the second. So there the + // password's answer is its status, and the code is read from the page + // the redirect ends on -- see AppConfig.redirectUri(). + final boolean web = AppConfig.pageOrigin() != null; + final String redirect = AppConfig.redirectUri(); + + // Every step goes to the server the sign-in started with, and none goes + // anywhere once the user has changed server: the session cookie of + // step 1 and the verifier of step 3 are only for the first server, and + // a new one that received them could replay them there for tokens. + final String server = base; + // 1. The password, for a session. + final Step login = new Step("POST", server + "/login"); + login.addArgument("username", accountName(email)); + login.addArgument("password", password); + login.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + if (login.status == 0) { + failed.failed(0, "Could not reach the server. Check your connection."); + return; + } + if (login.status == 429) { + failed.failed(429, "Too many attempts. Wait a minute and try again."); + return; + } + if (web ? login.status != 200 : login.session == null) { + failed.failed(401, "That e-mail and password do not match an account."); + return; + } + // 2. The session, for an authorization code. + final Step authorize = new Step("GET", server + "/oauth2/authorize"); + if (web) { + // Or a session that did not take is answered with a page. + authorize.addRequestHeader("Accept", "application/json"); + } else { + authorize.addRequestHeader("Cookie", login.session); + } + authorize.addArgument("response_type", "code"); + authorize.addArgument("client_id", AppConfig.CLIENT_ID); + authorize.addArgument("redirect_uri", redirect); + authorize.addArgument("scope", "openid profile"); + authorize.addArgument("state", state); + authorize.addArgument("code_challenge", pkce.getChallenge()); + authorize.addArgument("code_challenge_method", pkce.getMethod()); + authorize.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + String code = web ? authorize.answer("code") + : parameter(authorize.location, "code"); + String returned = web ? authorize.answer("state") + : parameter(authorize.location, "state"); + // The state has to come back as it went: a code that arrives + // with another was not issued for this sign-in. + if (code == null || !state.equals(returned)) { + failed.failed(authorize.status, "Signing in failed. Try again."); + return; + } + // 3. The code, for tokens. + final Step token = new Step("POST", server + "/oauth2/token"); + token.addArgument("grant_type", "authorization_code"); + token.addArgument("code", code); + token.addArgument("redirect_uri", redirect); + token.addArgument("code_verifier", pkce.getVerifier()); + token.addArgument("client_id", AppConfig.CLIENT_ID); + token.send(() -> { + if (!server.equals(base)) { + failed.failed(0, "The server changed. Sign in again."); + return; + } + OidcTokens tokens = token.status == 200 ? token.tokens() : null; + if (tokens == null || tokens.getAccessToken() == null) { + failed.failed(token.status, "Signing in failed. Try again."); + return; + } + // 4. The session has done its work, and is ended rather + // than left to run out: in a browser it is a cookie that + // would go on signing this user in to whoever sat down + // next. Nothing waits on the answer; the tokens stand + // without it. + Step end = new Step("POST", server + "/logout"); + if (!web) { + end.addRequestHeader("Cookie", login.session); + } + end.send(() -> { }); + got.got(tokens); + }); + }); + }); + } + + /// Signs out: the refresh token is revoked at the server, so the session + /// is over there too and not only forgotten here. + public static void signOut(final Runnable done) { + OidcTokens tokens = authorizer.getTokens(); + if (tokens != null && tokens.getRefreshToken() != null) { + // Sent and not waited for. Signing out is something the user does + // to this phone, and it is done here whether or not the server + // answers: the tokens are gone from the device either way. A + // revocation that is lost leaves a refresh token only someone who + // already copied it off the phone could use, and it expires on + // its own; holding the sign-out until the network allows would + // trap the user signed in on a phone with no connection. + client.revoke(tokens.getRefreshToken()); + } + setUser(null); + session++; + authorizer.signOut().ready(new SuccessCallback() { + @Override + public void onSucess(Boolean cleared) { + done.run(); + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable err) { + done.run(); + } + }); + } + + /// A parameter of the query string of `url`, or null. + static String parameter(String url, String name) { + int query = url == null ? -1 : url.indexOf('?'); + if (query < 0) { + return null; + } + int at = query + 1; + while (at < url.length()) { + int end = url.indexOf('&', at); + if (end < 0) { + end = url.length(); + } + if (url.startsWith(name + "=", at)) { + return Util.decode(url.substring(at + name.length() + 1, end), "UTF-8", false); + } + at = end + 1; + } + return null; + } + + /// One request of the sign-in. It follows no redirect and keeps no cookie + /// jar, because the redirect and the cookie are the answers it is after; + /// and it carries no bearer token, because there is not one yet. + private static final class Step extends ConnectionRequest { + int status; + String location; + String session; + private byte[] body; + private Runnable then; + private boolean reported; + + Step(String method, String url) { + setUrl(url); + setHttpMethod(method); + setPost(!"GET".equals(method)); + setFollowRedirects(false); + setCookiesEnabled(false); + setReadResponseForErrors(true); + setDuplicateSupported(true); + setFailSilently(true); + setAuthorizer(RequestAuthorizer.NONE); + } + + void send(Runnable next) { + then = next; + NetworkManager.getInstance().addToQueue(this); + } + + /// A text member of the JSON this request was answered with, or null. + String answer(String name) { + if (status != 200 || body == null) { + return null; + } + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body), "UTF-8")); + Object value = json == null ? null : json.get(name); + return value instanceof String ? (String) value : null; + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + OidcTokens tokens() { + try { + Map json = new JSONParser().parseJSON(new InputStreamReader( + new ByteArrayInputStream(body == null ? new byte[0] : body), "UTF-8")); + return OidcTokens.fromTokenResponse(json, null); + } catch (IOException malformed) { + return null; + } catch (RuntimeException malformed) { + return null; + } + } + + @Override + protected void readHeaders(Object connection) throws IOException { + location = getHeader(connection, "Location"); + String[] cookies = getHeaders(connection, "Set-Cookie"); + for (int iter = 0; cookies != null && iter < cookies.length; iter++) { + if (cookies[iter] != null && cookies[iter].startsWith(SESSION_COOKIE + "=")) { + int end = cookies[iter].indexOf(';'); + session = end < 0 ? cookies[iter] : cookies[iter].substring(0, end); + } + } + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + status = code; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + if (status == 0) { + status = getResponseCode(); + } + body = Util.readInputStream(input); + } + + @Override + protected void postResponse() { + if (status == 0) { + status = getResponseCode(); + } + report(); + } + + @Override + protected void handleException(Exception err) { + status = 0; + report(); + } + + private void report() { + if (!reported) { + reported = true; + CN.callSerially(then); + } + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ArenaView.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ArenaView.java new file mode 100644 index 00000000000..052df4a29c7 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ArenaView.java @@ -0,0 +1,2126 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + + +import com.codename1.gaming.AnimatedModel; +import com.codename1.gaming.GameCamera; +import com.codename1.gaming.GameView; +import com.codename1.gaming.Model; +import com.codename1.gaming.ParticleEmitter; +import com.codename1.gaming.TouchControls; +import com.codename1.gaming.VirtualButton; +import com.codename1.gaming.VirtualJoystick; +import com.codename1.gpu.GltfScene; +import com.codename1.gpu.GraphicsDevice; +import com.codename1.gpu.Material; +import com.codename1.gpu.Matrix4; +import com.codename1.gpu.Mesh; +import com.codename1.gpu.PostProcess; +import com.codename1.gpu.Primitives; +import com.codename1.gpu.RenderState; +import com.codename1.gpu.Texture; +import com.codename1.ui.CN; +import com.codename1.ui.Display; +import com.codename1.util.MathUtil; +import com.codenameone.examples.splat.rules.Arena; +import com.codenameone.examples.splat.rules.Ball; +import com.codenameone.examples.splat.rules.Box; +import com.codenameone.examples.splat.rules.Event; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Player; +import com.codenameone.examples.splat.rules.Trig; +import java.util.List; + +/// The field in first person, drawn with `com.codename1.gaming`: every +/// operator is a skinned glTF character playing the clip that matches what the +/// rules say they are doing, every bunker is the prop it was sized after, and +/// the marker in your hands sways, kicks and comes up to the eye when you aim +/// down the sights. Paint stays on the field for the whole match. +/// +/// The rules count in millimetres and binary angles; this class is the only +/// place they become floats. World x is drawn mirrored, so that a positive +/// strafe, which the rules move toward +x, goes right on the screen. +// tag::splat-arena-view[] +public class ArenaView extends GameView { + /// What the view tells the HUD once per drawn frame and once per event. + public interface Hud { + void frame(Match match, int me, float lookYaw, float lookPitch); + + void event(Event e); + } + // end::splat-arena-view[] + + public static final double TICK = 1.0 / Match.TICK_HZ; + /// The preference that flips vertical look, for pilots. + public static final String INVERT_KEY = "splat.invertLook"; + /// Paint colours: what a team's balls leave on the field. + public static final int[] TEAM_COLORS = {0xffff7a1f, 0xff1fc6ff}; + private static final int HAZE = 0xffb9c6cf; + private static final float SHADOW_AHEAD = 12f; + + public static final String[] TEAM_NAMES = {"Task Force Tangerine", "Teal Directorate"}; + /// Field of view from the hip and down the sights. + static final float HIP_FOV = 70f; + static final float ADS_FOV = 46f; + private static final int MAX_STEPS_PER_FRAME = 12; + private static final int MAX_BALLS = 120; + private static final int MAX_DECALS = 260; + private static final float MM = 0.001f; + /// The near plane. Further out than looks necessary: depth precision is + /// spent on the first metre, and at 3 cm paint on the floor fought the + /// floor for every pixel past twenty metres. + private static final float NEAR = 0.07f; + private static final float FP_NEAR = 0.2f; + /// How far past the first-person near plane a held stock must end. + private static final float STOCK_CLEARANCE = 0.03f; + /// How far the first-person marker sits right of and below the eye at the hip, in metres. + private static final float HIP_RIGHT = 0.11f; + private static final float HIP_DROP = 0.1f; + /// Your eyes, from your operator's head bone, in metres. + private static final float EYE_ABOVE_HEAD = 0.07f; + private static final float EYE_AHEAD_OF_HEAD = 0.07f; + private static final float OPERATOR_HEIGHT = 1.8f; + /// How far apart the winners stand to cheer, in metres. + private static final float CHEER_ROOM = 1.1f; + /// A marker in another operator's hands, in metres. Real ones are about + /// this long; at the viewmodel's size they vanished into a fist, and + /// half the field looked unarmed. + private static final float HELD_LENGTH = 0.55f; + + // Key codes the touch buttons press, read with the keyboard's. + static final int KEY_FIRE = 'f'; + static final int KEY_JUMP = ' '; + static final int KEY_CROUCH = 'c'; + static final int KEY_SPRINT = 'e'; + static final int KEY_RELOAD = 'r'; + static final int KEY_THROW = 'g'; + static final int KEY_WIPE = 'x'; + static final int KEY_AIM = 'q'; + static final int KEY_VIEW = 'v'; + + private final Driver driver; + private Hud hud; + private GraphicsDevice device; + private Mesh sphere; + + /// Twelve seconds of the match, one snapshot a tick, for the instant + /// replay, which plays the hit back slowly while the live match runs on: + /// it reaches a second before the hit until well after it. + private static final int HISTORY = 360; + private final int[][] history = new int[HISTORY][]; + private int recordedTick = -1; + /// The replay plays in its own match, restored from the history, so the + /// match in play goes on underneath it. + private Match ghost; + private int replayShooter = -1; + private int replayVictim = -1; + private int replayHit; + /// The replay's shots, one row each: the angle, the first and last tick + /// relative to the hit, and the speed it plays at. Over the shooter's + /// shoulder at half speed, then side on at a quarter for the splash, then + /// in front of the victim for the performance. Together they last a little + /// under the time out (Match.RESPAWN), so the replay ends as you go back in. + static final int ANGLE_SHOOTER = 0; + static final int ANGLE_SIDE = 1; + static final int ANGLE_VICTIM = 2; + private static final float[][] SHOTS = { + {ANGLE_SHOOTER, -30, 0, 0.5f}, + {ANGLE_SIDE, -9, 9, 0.25f}, + {ANGLE_VICTIM, 3, 27, 0.6f}, + }; + private int shot; + /// When the current shot began, for the HUD's cut to it. + private long shotAt; + private boolean splashed; + /// The replay camera's heading, in radians, eased toward the shooter's + /// look; NaN until a replay's first frame sets it. + private double chaseYaw = Double.NaN; + private int replayTo; + private float replayPos; + private float replaySpeed = 1f; + /// The match the last frame showed: the live one, or the replay's ghost. + private Match drawn; + private long replayStartAt; + private boolean overheadChosen; + private boolean viewHeld; + private boolean overheadNow; + private float overX; + private float overZ; + private boolean overSet; + /// The jolt of a ball to the goggles: 1 on the hit, settling to 0. + private float knock; + private Material[][][] splatPaint; + private Model sky; + private boolean controlsHidden; + private Mesh quad; + private Material[] teamPaint; + private AnimatedModel[] operators = new AnimatedModel[0]; + private AnimatedModel[] held = new AnimatedModel[0]; + private float[] opScale = new float[0]; + private float[] heldScale = new float[0]; + /// How far each held marker reaches behind the grip (its stock), in metres. + private float[] heldRear = new float[0]; + private String[] clips = new String[0]; + /// Per operator, smoothed so one jittery tick never changes the clip: + /// ground speed forward and sideways in m/s, the yaw drawn, and how long + /// a different clip has been asked for. + private float[] moveF = new float[0]; + private float[] moveS = new float[0]; + private float[] bodyYaw = new float[0]; + /// Who was alive last frame, so a respawn can leave a puff behind. + private boolean[] wasAlive = new boolean[0]; + private float[] pending = new float[0]; + /// The ground speed, in m/s, each locomotion clip was authored at; the + /// clip plays faster or slower so the feet keep pace with the ground. + private static final float RUN_PACE = 4.2f; + private static final float WALK_PACE = 1.7f; + private final Model[] balls = new Model[MAX_BALLS]; + private final Model[] decals = new Model[MAX_DECALS]; + private int nextDecal; + // Effects. Every one is a pool of soft billboards drawn in one call. + private final ParticleEmitter[] spray = new ParticleEmitter[2]; + private final ParticleEmitter[] mist = new ParticleEmitter[2]; + private final ParticleEmitter[] goo = new ParticleEmitter[2]; + private ParticleEmitter dust; + private ParticleEmitter flash; + /// The gas a marker vents with each shot, hanging at the muzzle. + private ParticleEmitter puff; + /// A streak behind every ball in flight, per team, so a shot reads as a + /// shot from across the yard. + private final ParticleEmitter[] trail = new ParticleEmitter[2]; + private ParticleEmitter motes; + private float moteClock; + private int moteSeed = 0x2545f491; + /// A capture-the-flag flag: a steel pole, a team-coloured cloth that + /// waves, and a glowing ring on the ground at home so the base reads + /// from across the yard. Two plain boxes, because no art pack has one. + private final Model[] flagPole = new Model[2]; + private final Model[] flagCloth = new Model[2]; + private final Model[] flagBase = new Model[2]; + private float flagWave; + private final float[] hand = new float[16]; + private final float[] gunTurn = new float[16]; + private final Grip grip = new Grip(); + private final float[] bodyTurn = new float[16]; + private final float[] pitchTurn = new float[16]; + /// Per operator, the mesh node of its head, hidden in your own view. + private String[] headNodes = new String[0]; + /// The operator whose head is hidden because you are looking out of it. + private int headless = -1; + /// Per operator, the end of the barrel in the world, for the flash. + private float[][] muzzle = new float[0][]; + private float recoil; + private float viewMuzzleX; + private float viewMuzzleY; + private float viewMuzzleZ; + private float ads; + private float bob; + private float swayX; + private float swayY; + private int lastYaw; + private int lastPitch; + private double accumulator; + // The quality governor: seconds of frames slower than SLOW_FRAME, and how + // many steps down it has taken (0 everything, 1 no post, 2 no shadows). + private static final double SLOW_FRAME = 1.0 / 20; + private double slowFor; + private int qualityDrops; + private float alpha; + private float[] prevX = new float[0]; + private float[] prevY = new float[0]; + private float[] prevZ = new float[0]; + private int lookYaw; + private int lookPitch; + private boolean aimed; + private int lastLookX = -1; + private int lastLookY = -1; + private int tapFire; + private com.codenameone.examples.splat.rules.Rng autopilot; + private final boolean invert = com.codename1.io.Preferences.get(INVERT_KEY, false); + private VirtualButton[] buttons = new VirtualButton[0]; + + public ArenaView(Driver driver) { + this.driver = driver; + } + + public void setHud(Hud hud) { + this.hud = hud; + } + + /// Hands the player's thumbs to the rules' own bot, which is how the + /// end-to-end tests and the promo video play a match; null gives them back. + public void setAutopilot(com.codenameone.examples.splat.rules.Rng rng) { + autopilot = rng; + } + + private int heldButtons; + + /// Holds buttons down on top of whatever plays (the autopilot or your + /// thumbs), as `Input` bits; 0 lets go. The walkthrough video uses it to + /// show a control by name: aim, throw, reload, wipe. + public void hold(int buttons) { + heldButtons = buttons; + } + + public Driver driver() { + return driver; + } + + /// How far down the sights you are: 0 from the hip, 1 fully aimed. + public float aimAmount() { + return ads; + } + + @Override + protected void onSetup(GraphicsDevice d) { + device = d; + Match match = driver.match(); + Arena arena = match.arena(); + // An overcast, hazy afternoon: the colour of every serious shooter. + setClearColor(HAZE); + // A low, warm sun through haze: long shadows, a cool sky fill and fog + // that swallows the far side of the yard. + getLight().setDirection(-0.55f, -0.62f, -0.3f).setColor(0xfffff0dc).setAmbientColor(0xff8a929c) + .setShadows(true).setShadowMap(2048, 24f).setShadowStyle(0.72f, 0.0012f); + // tag::splat-atmosphere[] + getEnvironment().setFog(HAZE, 0.0075f, 0.05f).setHemisphere(0xffa9bdd2, 0xff4f5a3c) + .setToneMapping(true).setExposure(1.3f) + .setSky(0xff4f7fb8, HAZE, 0xfffff0d0, 0.45f); + setPostProcess(new PostProcess().setBloom(0.78f, 0.55f) + .setGrade(1.12f, 0.86f, 0xff0b1720, 0xfffff2dc).setVignette(0.42f).setGrain(0.035f)); + // end::splat-atmosphere[] + getCamera().setPerspective(HIP_FOV, NEAR, 400f); + sphere = Primitives.sphere(d, 0.5f, 10, 14, false); + // the sky is a shader on the inside of a sphere that follows the eye + sky = new Model(Primitives.sphere(d, 300f, 16, 24, true), new Material(Material.Type.SKYBOX)); + sky.getMaterial().getRenderState().setDepthWrite(false); + addModel(sky); + quad = Primitives.quad(d, 1f); + teamPaint = new Material[2]; + for (int t = 0; t < 2; t++) { + // wet paint: smooth, and lit a little from within so it blooms + teamPaint[t] = new Material(Material.Type.PBR).setColor(TEAM_COLORS[t]).setRoughness(0.22f) + .setEmissive(dim(TEAM_COLORS[t], 0.18f)); + } + // Paint on the field: a splat-shaped texture per variant, flat ones for + // floors and tops, dripping ones for walls, tinted per team. + splatPaint = new Material[2][2][PaintDecals.VARIANTS]; + for (int v = 0; v < PaintDecals.VARIANTS; v++) { + for (int wall = 0; wall < 2; wall++) { + Texture tex = d.createTexture(PaintDecals.SIZE, PaintDecals.SIZE, + PaintDecals.splat(31L * v + 7 * wall + 1, wall == 1)); + for (int t = 0; t < 2; t++) { + Material m = new Material(Material.Type.PBR).setColor(TEAM_COLORS[t]).setRoughness(0.3f) + .setEmissive(dim(TEAM_COLORS[t], 0.12f)).setTexture(tex); + m.setRenderState(RenderState.transparent().setDepthWrite(false) + .setCullMode(RenderState.CullMode.NONE)); + m.setCastShadows(false); + splatPaint[t][wall][v] = m; + } + } + } + float w = arena.width * MM; + float dpt = arena.depth * MM; + // Photo-scanned surfaces (Poly Haven, CC0), tiled every few metres: + // the yard is concrete, the street asphalt, and beyond the wire grass. + boolean street = arena.id.indexOf("sac") >= 0; + addModel(ground(surface(street ? "asphalt" : "concrete", 0xff4a4d50), 0.92f, -w / 2f, 0f, dpt / 2f, + w, dpt, street ? 5f : 4f)); + addModel(ground(surface("grass", 0xff55683a), 1f, -w / 2f, -0.02f, dpt / 2f, 400f, 400f, 6f)); + perimeter(w, dpt); + greenery(w, dpt); + List boxes = arena.boxes(); + for (int i = 0; i < boxes.size(); i++) { + cover(boxes.get(i), arena); + } + if (match.mode() == Mode.SNACK_BAR) { + Model hill = new Model(sphere, new Material(Material.Type.UNLIT).setColor(0xfff2b632)); + float r = arena.hillRadius * MM * 2f; + hill.setScale(r, 0.01f, r); + hill.setPosition(-arena.hillX * MM, 0.005f, arena.hillZ * MM); + addModel(hill); + } + if (match.mode() == Mode.CAPTURE_THE_FLAG) { + Mesh box = Primitives.cube(d, 1f); + Material steel = new Material(Material.Type.PBR).setColor(0xffb8bec6).setRoughness(0.35f); + for (int t = 0; t < 2; t++) { + flagPole[t] = new Model(box, steel); + flagPole[t].setScale(0.05f, 2.6f, 0.05f); + addModel(flagPole[t]); + flagCloth[t] = new Model(box, new Material(Material.Type.PBR).setColor(TEAM_COLORS[t]) + .setRoughness(0.8f)); + flagCloth[t].setScale(0.95f, 0.6f, 0.03f); + addModel(flagCloth[t]); + flagBase[t] = new Model(sphere, new Material(Material.Type.UNLIT).setColor(TEAM_COLORS[t])); + flagBase[t].setScale(2.4f, 0.01f, 2.4f); + flagBase[t].setPosition(-arena.baseX(t) * MM, 0.006f, arena.baseZ(t) * MM); + addModel(flagBase[t]); + } + } + + int n = match.playerCount(); + operators = new AnimatedModel[n]; + held = new AnimatedModel[n]; + muzzle = new float[n][3]; + opScale = new float[n]; + heldScale = new float[n]; + heldRear = new float[n]; + clips = new String[n]; + moveF = new float[n]; + moveS = new float[n]; + bodyYaw = new float[n]; + wasAlive = new boolean[n]; + pending = new float[n]; + prevX = new float[n]; + prevY = new float[n]; + prevZ = new float[n]; + int[] perTeam = new int[2]; + for (int i = 0; i < n; i++) { + Player p = match.player(i); + AnimatedModel op = Models.create(d, Models.operator(p.team(), perTeam[p.team() & 1]++)); + opScale[i] = fit(op, OPERATOR_HEIGHT); + op.play(Models.IDLE, true); + clips[i] = Models.IDLE; + bodyYaw[i] = (float) -(p.yaw() * 360.0 / Trig.FULL); + op.addTo(this); + operators[i] = op; + AnimatedModel m = Models.create(d, Models.marker(p.marker.id)); + heldScale[i] = length(m, HELD_LENGTH); + heldRear[i] = Math.max(0f, Models.bounds(m)[5]) * heldScale[i]; + m.addTo(this); + held[i] = m; + } + for (int i = 0; i < MAX_BALLS; i++) { + balls[i] = new Model(sphere, teamPaint[0]); + balls[i].setScale(0.1f); + balls[i].setVisible(false); + addModel(balls[i]); + } + for (int i = 0; i < MAX_DECALS; i++) { + decals[i] = new Model(quad, splatPaint[0][0][0]); + decals[i].setVisible(false); + addModel(decals[i]); + } + effects(d); + // There is no separate marker for your own view: you see your own + // operator, head hidden, and the marker in its hands (`firstPerson`). + // A marker drawn on its own, with no arms, read as a gun floating in + // front of the camera. + headNodes = new String[n]; + for (int i = 0; i < n; i++) { + headNodes[i] = headNode(operators[i]); + } + remember(); + touchControls(); + Sfx.load(); + } + + /// A tiling texture from the resources, or a plain `fallback` colour + /// where it cannot be read. + private Texture surface(String name, int fallback) { + try { + java.io.InputStream in = CN.getResourceAsStream("/tex_" + name + ".jpg"); + if (in != null) { + try { + return device.createTexture(com.codename1.ui.Image.createImage(in)).setWrap(Texture.Wrap.REPEAT); + } finally { + in.close(); + } + } + } catch (java.io.IOException e) { + // drawn in the fallback colour below + } + return device.createTexture(1, 1, new int[] {fallback}); + } + + /// A flat rectangle of ground with `tex` repeated every `tile` metres. + private Model ground(Texture tex, float roughness, float x, float y, float z, float sx, float sz, float tile) { + float u = sx / tile; + float v = sz / tile; + float[] vs = { + -0.5f, -0.5f, 0, 0, 0, 1, 0, v, + 0.5f, -0.5f, 0, 0, 0, 1, u, v, + 0.5f, 0.5f, 0, 0, 0, 1, u, 0, + -0.5f, 0.5f, 0, 0, 0, 1, 0, 0, + }; + com.codename1.gpu.VertexBuffer vb = device.createVertexBuffer( + com.codename1.gpu.VertexFormat.POSITION_NORMAL_TEXCOORD, 4); + vb.setData(vs); + com.codename1.gpu.IndexBuffer ib = device.createIndexBuffer(6); + ib.setData(new int[] {0, 1, 2, 0, 2, 3}); + Mesh mesh = new Mesh(vb, ib, com.codename1.gpu.PrimitiveType.TRIANGLES); + Material m = new Material(Material.Type.PBR).setColor(0xffffffff).setRoughness(roughness); + m.setTexture(tex); + Model model = new Model(mesh, m); + model.setRotation(-90f, 0f, 0f); + model.setScale(sx, sz, 1f); + model.setPosition(x, y, z); + return model; + } + + /// Scales `m` to stand `height` tall and returns the scale. + private static float fit(AnimatedModel m, float height) { + float[] b = Models.bounds(m); + float s = height / Math.max(0.01f, b[4] - b[1]); + m.setScale(s); + return s; + } + + /// Sets the model's Euler angles from a column-major rotation, in the + /// order `Model` composes them (Rz * Ry * Rx). + /// Scales `m` so its longest side is `len` and returns the scale. + private static float length(AnimatedModel m, float len) { + float[] b = Models.bounds(m); + float s = len / Math.max(0.01f, Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2]))); + m.setScale(s); + return s; + } + + /// The prop a box was sized after, turned to lie along the box and scaled + /// to its height, its middle on the box's middle. Mirrored twins on the far + /// half face the other way. + private void cover(Box box, Arena arena) { + AnimatedModel m; + try { + m = Models.create(device, box.kind); + } catch (IllegalArgumentException e) { + m = Models.create(device, "crate"); + } + float[] b = Models.bounds(m); + float bw = (box.x1 - box.x0) * MM; + float bd = (box.z1 - box.z0) * MM; + float mw = b[3] - b[0]; + float md = b[5] - b[2]; + boolean turn = (bw > bd) != (mw > md) && Math.abs(bw - bd) > 0.05f; + float s = box.height * MM / Math.max(0.01f, b[4] - b[1]); + // Fit the footprint to the box the rules collide against, so an operator + // stopped by the box is stopped by the prop and never inside it. A prop + // whose box is only its trunk (a tree) keeps at least 40% of its width. + float sx = Math.max(s * 0.4f, (turn ? bd : bw) / Math.max(0.01f, mw)); + float sz = Math.max(s * 0.4f, (turn ? bw : bd) / Math.max(0.01f, md)); + float cx = -(box.x0 + box.x1) * MM / 2f; + float cz = (box.z0 + box.z1) * MM / 2f; + float deg = (turn ? 90f : 0f) + ((box.x0 + box.x1) / 2 > arena.width / 2 ? 180f : 0f); + double r = Math.toRadians(deg); + float c = (float) Math.cos(r); + float sn = (float) Math.sin(r); + float mx = (b[0] + b[3]) / 2f * sx; + float mz = (b[2] + b[5]) / 2f * sz; + m.setScale(s); + com.codename1.gaming.Model[] parts = m.getParts(); + for (int i = 0; i < parts.length; i++) { + parts[i].setScale(sx, s, sz); + } + m.setRotation(0f, deg, 0f); + m.setPosition(cx - (mx * c + mz * sn), -b[1] * s, cz - (-mx * sn + mz * c)); + m.addTo(this); + } + + /// Puts the middle of `m`'s footprint at `(x, z)` with its feet on the ground. + private static void place(AnimatedModel m, float s, float deg, float x, float z, float[] b) { + double r = Math.toRadians(deg); + float c = (float) Math.cos(r); + float sn = (float) Math.sin(r); + float mx = (b[0] + b[3]) / 2f * s; + float mz = (b[2] + b[5]) / 2f * s; + m.setScale(s); + m.setRotation(0f, deg, 0f); + m.setPosition(x - (mx * c + mz * sn), -b[1] * s, z - (-mx * sn + mz * c)); + } + + /// A chain-link yard: fence panels on every side, lights at the corners, + /// and a few big shapes beyond the wire so the horizon is not empty. + private void perimeter(float w, float d) { + AnimatedModel probe = Models.create(device, "metal-fence"); + float[] b = Models.bounds(probe); + float s = 2.6f / Math.max(0.01f, b[4] - b[1]); + float panel = Math.max(0.5f, (b[3] - b[0]) * s); + fenceRow(b, s, panel, 0f, -0.3f, -w, -0.3f, 0f); + fenceRow(b, s, panel, 0f, d + 0.3f, -w, d + 0.3f, 0f); + fenceRow(b, s, panel, 0.3f, 0f, 0.3f, d, 90f); + fenceRow(b, s, panel, -w - 0.3f, 0f, -w - 0.3f, d, 90f); + float[][] corners = {{1f, -1f}, {-w - 1f, -1f}, {1f, d + 1f}, {-w - 1f, d + 1f}}; + for (int i = 0; i < corners.length; i++) { + AnimatedModel l = Models.create(device, "streetlight"); + float[] lb = Models.bounds(l); + place(l, 7f / Math.max(0.01f, lb[4] - lb[1]), i * 90f + 45f, corners[i][0], corners[i][1], lb); + l.addTo(this); + } + String[] beyond = {"shack", "watertower", "container", "tank", "shack", "container-small", "tree", "tree-2"}; + float[] height = {7.8f, 9f, 2.13f, 2.14f, 7.8f, 2.13f, 6f, 5f}; + for (int i = 0; i < 12; i++) { + int k = i % beyond.length; + float t = (i * 0.618f) % 1f; + float x; + float z; + if (i % 4 == 0) { + x = -w * t; + z = -6f - (i % 3) * 3f; + } else if (i % 4 == 1) { + x = -w * t; + z = d + 6f + (i % 3) * 3f; + } else if (i % 4 == 2) { + x = 6f + (i % 3) * 3f; + z = d * t; + } else { + x = -w - 6f - (i % 3) * 3f; + z = d * t; + } + AnimatedModel m = Models.create(device, beyond[k]); + float[] mb = Models.bounds(m); + place(m, height[k] / Math.max(0.01f, mb[4] - mb[1]), i * 37f, x, z, mb); + m.addTo(this); + } + } + + /// Fields and woods around the yard, and weeds where the asphalt meets + /// the fence. All of it is scenery: nothing here blocks a player or a ball. + private void greenery(float w, float d) { + int seed = 0x5eed; + String[] trees = {"oak", "pine", "oak-2", "birch", "pine-2", "oak"}; + float[] treeHeight = {7.5f, 11f, 8.5f, 9f, 12f, 6.5f}; + for (int i = 0; i < 26; i++) { + seed = seed * 1103515245 + 12345; + float t = ((seed >>> 8) & 1023) / 1023f; + seed = seed * 1103515245 + 12345; + float out = 9f + ((seed >>> 8) & 1023) / 1023f * 26f; + float[] p = around(w, d, i % 4, t, out); + int k = (seed >>> 4) % trees.length; + scenery(trees[k], treeHeight[k] * (0.8f + 0.4f * t), i * 53f, p[0], p[1], true); + } + String[] low = {"bush", "bush-2", "bush-berries", "grass", "grass-2", "plant", "rock-moss", "log", "stump"}; + float[] lowHeight = {1.1f, 1.3f, 1f, 0.5f, 0.6f, 0.8f, 0.7f, 0.45f, 0.6f}; + for (int i = 0; i < 34; i++) { + seed = seed * 1103515245 + 12345; + float t = ((seed >>> 8) & 1023) / 1023f; + seed = seed * 1103515245 + 12345; + float out = 1.2f + ((seed >>> 8) & 1023) / 1023f * 9f; + float[] p = around(w, d, i % 4, t, out); + int k = (seed >>> 4) % low.length; + scenery(low[k], lowHeight[k], i * 71f, p[0], p[1], false); + } + // weeds through the cracks along the inside of the wire + String[] weeds = {"grass-short", "grass", "flowers", "grass-2"}; + for (int i = 0; i < 24; i++) { + seed = seed * 1103515245 + 12345; + float t = ((seed >>> 8) & 1023) / 1023f; + float[] p = around(w, d, i % 4, t, -0.5f - ((seed >>> 3) & 7) * 0.08f); + scenery(weeds[(seed >>> 5) & 3], 0.35f + 0.1f * t, i * 97f, p[0], p[1], false); + } + } + + /// A point `out` metres outside side `side` of the yard (negative is + /// inside), `t` of the way along it. + private static float[] around(float w, float d, int side, float t, float out) { + switch (side) { + case 0: + return new float[] {-w * t, -out}; + case 1: + return new float[] {-w * t, d + out}; + case 2: + return new float[] {out, d * t}; + default: + return new float[] {-w - out, d * t}; + } + } + + /// One piece of scenery; only the big pieces are worth a place in the + /// shadow pass. + private void scenery(String name, float height, float deg, float x, float z, boolean shadow) { + AnimatedModel m = Models.create(device, name); + if (!shadow) { + Model[] parts = m.getParts(); + for (int i = 0; i < parts.length; i++) { + parts[i].getMaterial().setCastShadows(false); + } + } + float[] b = Models.bounds(m); + place(m, height / Math.max(0.01f, b[4] - b[1]), deg % 360f, x, z, b); + m.addTo(this); + } + + private void fenceRow(float[] b, float s, float panel, float x0, float z0, float x1, float z1, float deg) { + float len = (float) Math.sqrt((x1 - x0) * (x1 - x0) + (z1 - z0) * (z1 - z0)); + int count = Math.max(1, (int) Math.ceil(len / panel)); + for (int i = 0; i < count; i++) { + float t = (i + 0.5f) / count; + AnimatedModel m = Models.create(device, "metal-fence"); + place(m, s, deg, x0 + (x1 - x0) * t, z0 + (z1 - z0) * t, b); + m.addTo(this); + } + } + + // ------------------------------------------------------------ controls + + /// On a touch screen: a stick for the left thumb, the buttons for the right, + /// and dragging anywhere else on the right looks around. A desktop gets + /// none of it: the mouse looks and fires, the keys move. + private void touchControls() { + if (!driver.interactive() || !Display.getInstance().isTouchScreenDevice() || CN.isDesktop()) { + return; + } + TouchControls c = getControls(); + float gap = CN.convertToPixels(3); + c.addJoystick(CN.convertToPixels(11), TouchControls.LEFT, TouchControls.BOTTOM, gap); + float big = CN.convertToPixels(9); + float small = CN.convertToPixels(6); + // The small buttons sit on an arc around FIRE where the right thumb + // rolls to them: left of it, up and to the left, and above it. + float fire = gap + big; + float arc = big + small + CN.convertToPixels(2.5f); + buttons = new VirtualButton[] { + c.addButton(KEY_FIRE, big, TouchControls.RIGHT, TouchControls.BOTTOM, gap, gap), + onArc(c, KEY_AIM, small, fire, arc, 0), + onArc(c, KEY_THROW, small, fire, arc, 45), + onArc(c, KEY_JUMP, small, fire, arc, 90), + // the bird's-eye view, out of the way of the thumb + c.addButton(KEY_VIEW, CN.convertToPixels(4.5f), TouchControls.RIGHT, TouchControls.CENTER, + gap, 0), + }; + String[] labels = {"FIRE", "AIM", "NADE", "JUMP", "VIEW"}; + for (int i = 0; i < buttons.length; i++) { + style(buttons[i], labels[i]); + } + } + + /// A button `deg` degrees round from straight left of FIRE's centre. + private static VirtualButton onArc(TouchControls c, int key, float r, float fire, float arc, int deg) { + double a = Math.toRadians(deg); + float dx = fire + (float) (Math.cos(a) * arc); + float dy = fire + (float) (Math.sin(a) * arc); + return c.addButton(key, r, TouchControls.RIGHT, TouchControls.BOTTOM, dx - r, dy - r); + } + + /// Takes the touch buttons off the screen for good, for the result card. + public void hideControls() { + controlsHidden = true; + // a desktop has a keyboard and mouse and no touch buttons at all + if (buttons.length > 0) { + getControls().setVisible(false); + } + } + + /// The buttons show only while there is something to press: not during + /// the briefing, and the ADS button reads WIPE while you are out. + private void controlsFor(Match m) { + // a desktop has a keyboard and mouse and no touch buttons at all + if (buttons.length == 0) { + return; + } + boolean show = !controlsHidden && driver.countdownTicks() == 0 && !m.over(); + if (show != getControls().isVisible()) { + getControls().setVisible(show); + } + // aiming while you are in, wiping the paint off while you are out + buttons[1].setLabel(isOut(m) ? "WIPE" : "AIM"); + buttons[4].setLabel(overheadChosen ? "EYES" : "VIEW"); + } + + /// True while your own player is painted and waiting to come back. + private boolean isOut(Match m) { + int me = driver.me(); + return me >= 0 && me < m.playerCount() && !m.player(me).alive(); + } + + private static void style(VirtualButton b, String label) { + int key = b.getKeyCode(); + b.setLabel(label); + // dark glass with a light label: readable over sky and asphalt alike + b.setColor(key == KEY_FIRE ? 0xc0b8861e : 0xa80c0e10); + b.setLabelColor(0xffeef1f4); + } + + private boolean onControl(int x, int y) { + for (int i = 0; i < buttons.length; i++) { + float dx = x - buttons[i].getCenterX(); + float dy = y - buttons[i].getCenterY(); + float r = buttons[i].getRadius() * 1.15f; + if (dx * dx + dy * dy <= r * r) { + return true; + } + } + VirtualJoystick j = getControls().getJoystick(); + if (j != null) { + float dx = x - j.getCenterX(); + float dy = y - j.getCenterY(); + return dx * dx + dy * dy <= j.getRadius() * j.getRadius() * 1.6f; + } + return false; + } + + @Override + public void pointerPressed(int[] x, int[] y) { + super.pointerPressed(x, y); + int i = lookPointer(x, y); + if (i >= 0) { + lastLookX = x[i]; + lastLookY = y[i]; + if (buttons.length == 0) { + // A mouse: the click is the trigger. + tapFire = 1; + } + } + } + + @Override + public void pointerDragged(int[] x, int[] y) { + super.pointerDragged(x, y); + int i = lookPointer(x, y); + if (i < 0) { + return; + } + if (lastLookX >= 0) { + look(x[i] - lastLookX, y[i] - lastLookY); + } + lastLookX = x[i]; + lastLookY = y[i]; + } + + @Override + public void pointerReleased(int[] x, int[] y) { + super.pointerReleased(x, y); + lastLookX = -1; + lastLookY = -1; + if (buttons.length == 0) { + tapFire = 0; + } + } + + /// The touch that steers the view: on the right side and on no control, or + /// any pointer at all with a mouse. + private int lookPointer(int[] x, int[] y) { + for (int i = 0; i < x.length; i++) { + int lx = x[i] - getAbsoluteX(); + int ly = y[i] - getAbsoluteY(); + if (buttons.length == 0 || (lx > getWidth() * 2 / 5 && !onControl(lx, ly))) { + return i; + } + } + return -1; + } + + private void look(int dx, int dy) { + int w = Math.max(1, getWidth()); + if (invert) { + dy = -dy; + } + lookYaw = Trig.wrap(lookYaw + dx * Trig.FULL / (w * 2)); + lookPitch = Math.max(-Match.PITCH_LIMIT, Math.min(Match.PITCH_LIMIT, lookPitch - dy * Trig.FULL / (w * 2))); + } + + /// The input for the coming tick, from the keys, the stick and the buttons. + private Input input() { + Player p = driver.match().player(driver.me()); + if (!aimed) { + lookYaw = p.yaw(); + lookPitch = p.pitch(); + aimed = true; + } + int strafe = 0; + int forward = 0; + if (key('w') || key(Display.getInstance().getKeyCode(Display.GAME_UP))) { + forward += 127; + } + if (key('s') || key(Display.getInstance().getKeyCode(Display.GAME_DOWN))) { + forward -= 127; + } + if (key('a')) { + strafe -= 127; + } + if (key('d')) { + strafe += 127; + } + if (key(Display.getInstance().getKeyCode(Display.GAME_LEFT))) { + lookYaw = Trig.wrap(lookYaw - 60); + } + if (key(Display.getInstance().getKeyCode(Display.GAME_RIGHT))) { + lookYaw = Trig.wrap(lookYaw + 60); + } + // V (or the VIEW button) flips the bird's-eye view, once a press + boolean view = key(KEY_VIEW); + if (view && !viewHeld) { + toggleOverhead(); + } + viewHeld = view; + boolean sprint = key(KEY_SPRINT); + VirtualJoystick j = getControls().getJoystick(); + if (j != null && j.isActive()) { + strafe = (int) (j.getAxisX() * 127); + forward = (int) (-j.getAxisY() * 127); + sprint = sprint || j.getAxisY() < -0.92f; + } + int b = 0; + if (key(KEY_FIRE) || tapFire > 0) { + b |= Input.FIRE; + } + if (key(KEY_JUMP)) { + b |= Input.JUMP; + } + if (key(KEY_CROUCH)) { + b |= Input.CROUCH; + } + if (sprint) { + b |= Input.SPRINT; + } + if (key(KEY_RELOAD)) { + b |= Input.RELOAD; + } + if (key(KEY_THROW)) { + b |= Input.THROW; + } + boolean out = isOut(driver.match()); + if (key(KEY_WIPE) || (out && key(KEY_AIM))) { + b |= Input.WIPE; + } + if (!out && key(KEY_AIM)) { + b |= Input.AIM; + } + return new Input(clamp(strafe), clamp(forward), lookYaw, lookPitch, b, -1); + } + + private boolean key(int code) { + return getInput().isKeyDown(code) || (code >= 'a' && code <= 'z' && getInput().isKeyDown(code - 32)); + } + + private static int clamp(int v) { + return Math.max(-127, Math.min(127, v)); + } + + // ------------------------------------------------------------ the loop + + // tag::splat-fixed-tick[] + @Override + protected void update(double dt) { + if (device == null || getWidth() <= 0 || getHeight() <= 0) { + return; + } + controlsFor(driver.match()); + govern(dt); + accumulator += dt * driver.speedPercent() / 100.0; + int steps = 0; + while (accumulator >= TICK && steps < MAX_STEPS_PER_FRAME) { + if (!driver.canStep()) { + // The briefing, or online and the server has gone quiet. + accumulator = Math.min(accumulator, TICK); + break; + } + remember(); + Input in = !driver.interactive() ? Input.IDLE : autopilot != null + ? com.codenameone.examples.splat.rules.Bot.think(driver.match(), driver.match().player(driver.me()), autopilot) + : input(); + if (heldButtons != 0 && driver.interactive()) { + in = new Input(in.strafe, in.forward, in.yaw, in.pitch, in.buttons | heldButtons, in.viewTick); + } + driver.step(in); + // every tick, not every frame: a slow frame steps several + record(); + accumulator -= TICK; + steps++; + } + alpha = (float) Math.min(1, accumulator / TICK); + // and once a frame for a driver whose ticks arrive from the server + record(); + events(); + bodySounds((float) dt); + draw((float) dt); + tick((float) dt); + } + // end::splat-fixed-tick[] + + /// Steps the look down when frames stay slow, so a weak GPU (or a software + /// renderer) still plays at speed: the post chain goes first, then the + /// shadow pass. It never steps back up within a match; a frame time that + /// recovers after a drop is usually the drop working. + private void govern(double dt) { + if (qualityDrops >= 2) { + return; + } + slowFor = dt > SLOW_FRAME ? slowFor + dt : Math.max(0, slowFor - dt); + if (slowFor < 2.0) { + return; + } + slowFor = 0; + qualityDrops++; + if (qualityDrops == 1) { + setPostProcess(null); + } else { + getLight().setShadows(false); + } + } + + private void record() { + Match m = driver.match(); + if (m.tick() != recordedTick) { + recordedTick = m.tick(); + history[recordedTick % HISTORY] = m.snapshot(); + } + } + + /// Plays back the moment you were painted, every time, in the [#SHOTS]: + /// it starts once the stamp has landed and ends when you are back in. + private void replayFrom(int shooter, int victim, int hitTick) { + replayShooter = shooter; + replayVictim = victim; + replayHit = hitTick; + replayStartAt = System.currentTimeMillis() + 800; + startShot(0); + shotAt = replayStartAt; + } + + private void startShot(int s) { + shot = s; + shotAt = System.currentTimeMillis(); + chaseYaw = Double.NaN; + splashed = false; + float[] row = SHOTS[s]; + int from = replayHit + (int) row[1]; + // the first shot starts no earlier than the history goes back + while (from < replayHit && !recorded(from)) { + from++; + } + replayPos = from; + replayTo = replayHit + (int) row[2]; + replaySpeed = row[3]; + } + + /// What the camera is looking at, for the HUD to put words over. + public Match drawnMatch() { + Match m = drawn; + return m == null ? driver.match() : m; + } + + /// The angle of the replay shot on screen, one of the `ANGLE_` values, + /// or -1 when no replay is playing. + public int replayAngle() { + return replayingShooter() >= 0 ? (int) SHOTS[shot][0] : -1; + } + + /// Milliseconds since the replay cut to its current shot. + public long sinceCut() { + return System.currentTimeMillis() - shotAt; + } + + /// The player the replay is about: the one who was painted. + public int replayingVictim() { + return replayVictim; + } + + /// The most ticks a replay blends across: a frame of four at a slow 8 fps. + private static final int GAP = 4; + + private boolean recorded(int tick) { + int[] snap = tick < 0 ? null : history[tick % HISTORY]; + return snap != null && snap[1] == tick; + } + + /// True from the moment you are painted until your replay has played: + /// the stamp is up, and the replay is waiting for it or on screen. + public boolean replayQueued() { + return replayShooter >= 0; + } + + /// The shooter of the replay on screen, or -1 when none is playing. + public int replayingShooter() { + return replayShooter >= 0 && System.currentTimeMillis() >= replayStartAt ? replayShooter : -1; + } + + /// Whether the camera is above the field rather than at the eye. + public boolean isOverhead() { + return overheadNow; + } + + /// Switches between your eyes and the view from above. + public void toggleOverhead() { + overheadChosen = !overheadChosen; + overSet = false; + } + + /// The match to draw this frame: the replay's, positioned between two of + /// its ticks, or the one in play. + private Match shown(float dt) { + if (replayShooter < 0 || System.currentTimeMillis() < replayStartAt) { + return driver.match(); + } + Match live = driver.match(); + // An online driver's ticks arrive with the server's snapshots, so some + // can be missing from the history: blend the nearest recorded ones. + int t = (int) replayPos; + int a = t; + while (a > t - GAP && !recorded(a)) { + a--; + } + int b = t + 1; + while (b < t + 1 + GAP && !recorded(b)) { + b++; + } + if (t + 1 >= replayTo && shot + 1 < SHOTS.length && !live.player(driver.me()).alive()) { + // a cut to the next angle + startShot(shot + 1); + return shown(0f); + } + if (t + 1 >= replayTo || live.player(driver.me()).alive() || !recorded(a) || !recorded(b)) { + replayShooter = -1; + replayVictim = -1; + replaySpeed = 1f; + remember(); + return live; + } + if (ghost == null) { + ghost = live.fork(); + } + // where everyone was a tick before, then where they are, for the lerp + ghost.restore(history[a % HISTORY]); + int n = Math.min(prevX.length, ghost.playerCount()); + for (int i = 0; i < n; i++) { + Player p = ghost.player(i); + prevX[i] = p.x() * MM; + prevY[i] = p.y() * MM; + prevZ[i] = p.z() * MM; + } + ghost.restore(history[b % HISTORY]); + alpha = (replayPos - a) / (b - a); + replayPos += dt * Match.TICK_HZ * replaySpeed; + if (!splashed && replayPos >= replayHit && replayVictim >= 0) { + // the events are not replayed, so the splash is thrown again here + splashed = true; + Player v = ghost.player(replayVictim); + burstHit(ghost.player(replayShooter).team() & 1, v.x(), v.y() + 1200, v.z()); + Sfx.splat(0.9f, 0f); + } + return ghost; + } + + private void remember() { + Match m = driver.match(); + int n = Math.min(prevX.length, m.playerCount()); + for (int i = 0; i < n; i++) { + Player p = m.player(i); + prevX[i] = p.x() * MM; + prevY[i] = p.y() * MM; + prevZ[i] = p.z() * MM; + } + } + + private void events() { + List happened = driver.drainEvents(); + Match m = driver.match(); + int me = driver.me(); + int team = m.player(me).team(); + for (int i = 0; i < happened.size(); i++) { + Event e = happened.get(i); + if (e.kind == Event.SPLAT || e.kind == Event.GRENADE) { + decal(e.b & 1, e.x, e.y, e.z, e.kind == Event.GRENADE ? 2.6f : 0.55f); + burstImpact(e.b & 1, e.x, e.y, e.z, e.kind == Event.GRENADE); + if (e.kind == Event.GRENADE) { + Sfx.grenade(place(e.x, e.z, 45), pan); + } else { + Sfx.splat(place(e.x, e.z, 25) * 0.8f, pan); + } + } else if (e.kind == Event.PAINTED) { + Player shooter = m.player(e.a); + decal(shooter.team(), e.x, e.y, e.z, 0.7f); + burstHit(shooter.team() & 1, e.x, e.y, e.z); + if (e.b == me) { + Sfx.painted(); + Sfx.whistle(0.9f, 0); + knock = 1f; + if (driver.interactive() && e.a != me) { + replayFrom(e.a, e.b, e.tick); + } + } else { + Sfx.splat(place(e.x, e.z, 30), pan); + Sfx.whistle(Math.max(0.35f, place(e.x, e.z, 60)), pan * 0.5f); + } + if (e.a == me) { + Sfx.hit(); + } + } else if (e.kind == Event.SHOT) { + if (e.a == me && driver.interactive()) { + recoil = 1f; + Sfx.fire(); + // your own flash sits at the marker you hold, not the ball + flash.burst(viewMuzzleX, viewMuzzleY, viewMuzzleZ, 6); + puff.burst(viewMuzzleX, viewMuzzleY, viewMuzzleZ, 4); + } else { + Sfx.fireAt(place(e.x, e.z, 35), pan); + if (near(e.x, e.z)) { + // at the end of the shooter's barrel, where it is drawn + boolean held = e.a >= 0 && e.a < muzzle.length && operators[e.a].isVisible(); + float fx = held ? muzzle[e.a][0] : -e.x * MM; + float fy = held ? muzzle[e.a][1] : e.y * MM; + float fz = held ? muzzle[e.a][2] : e.z * MM; + flash.burst(fx, fy, fz, 6); + puff.burst(fx, fy, fz, 4); + } + } + } else if (e.kind == Event.WIPE_OK && e.a == me) { + Sfx.wipe(); + } else if (e.kind == Event.WIPE_CAUGHT) { + if (e.a == me) { + Sfx.wipe(); + Sfx.buzzer(); + } + Sfx.whistle(e.a == me ? 1f : 0.5f, 0); + } else if (e.kind == Event.FRIENDLY && (e.a == me || e.b == me)) { + Sfx.boing(); + } else if (e.kind == Event.RELOAD && e.a == me) { + Sfx.reload(); + } else if (e.kind == Event.FLAG_TAKEN) { + Sfx.flag(e.b != team); + } else if (e.kind == Event.CAPTURE) { + Sfx.capture(e.b == team); + } else if (e.kind == Event.HILL_POINT) { + Sfx.point(e.b == team); + } else if (e.kind == Event.END) { + Sfx.whistleTwice(); + Music.stop(); + if (e.b >= 0 && driver.interactive()) { + if (e.b == team) { + Sfx.win(); + } else { + Sfx.lose(); + } + } + } else if (e.kind == Event.RESPAWN && e.a == me) { + aimed = false; + } + if (hud != null) { + // The HUD is a component the EDT paints; this runs on the + // render thread, so its lists change on the EDT's turn. + final Hud h = hud; + final Event ev = e; + CN.callSerially(() -> h.event(ev)); + } + } + } + + /// The pan of the last sound [#place] placed: -1 is hard left, 1 hard right. + private float pan; + + /// How loud a sound at `x z` is where you stand: full within a few metres, + /// nothing beyond `range` metres. Leaves its direction in [#pan]. + private float place(int x, int z, int range) { + Player p = driver.match().player(driver.me()); + double dx = x - p.x(); + double dz = z - p.z(); + double d = Math.sqrt(dx * dx + dz * dz) / 1000; + int yaw = driver.interactive() && p.alive() ? lookYaw : p.yaw(); + // the same frame as the damage arcs: 0 straight ahead, clockwise positive + double toward = MathUtil.atan2(-dx, dz); + double facing = -yaw * 2 * Math.PI / Trig.FULL; + pan = d < 1.5 ? 0 : (float) Math.sin(toward - facing) * 0.8f; + double v = 1 - (d - 3) / (range - 3); + return (float) Math.max(0, Math.min(1, v * v)); + } + + private int lastCount = -1; + private boolean wasGrounded = true; + private boolean hadGrenade; + private float stride; + + /// The sounds that come from your own body and the clock rather than from + /// an event: the countdown, footsteps, landing and the grenade leaving + /// your hand. + private void bodySounds(float dt) { + Match m = driver.match(); + int me = driver.me(); + Player p = m.player(me); + int count = driver.countdownTicks(); + int secs = (count + Match.TICK_HZ - 1) / Match.TICK_HZ; + if (lastCount >= 0 && secs != lastCount && driver.interactive()) { + if (secs == 0) { + Sfx.horn(); + Sfx.whistleTwice(); + Music.field(); + } else if (secs <= 3) { + Sfx.beep(); + } + } + lastCount = secs; + if (!p.alive() || m.over()) { + stride = 0; + wasGrounded = true; + hadGrenade = p.hasGrenade(); + return; + } + if (driver.interactive() && hadGrenade && !p.hasGrenade()) { + Sfx.toss(); + } + hadGrenade = p.hasGrenade(); + boolean grounded = p.grounded(); + if (grounded && !wasGrounded) { + Sfx.land(); + stride = 0; + } + wasGrounded = grounded; + float dx = p.x() * MM - prevX[me]; + float dz = p.z() * MM - prevZ[me]; + float speed = (float) Math.sqrt(dx * dx + dz * dz) * Match.TICK_HZ; + if (grounded && speed > 0.8f) { + stride += speed * dt; + // a stride is about 1.6 m walking, a little longer at a sprint + if (stride > (speed > 5 ? 1.9f : 1.6f)) { + stride = 0; + Sfx.step(p.crouched() ? 0.25f : speed > 5 ? 0.6f : 0.45f); + } + } + } + + private void effects(GraphicsDevice d) { + for (int t = 0; t < 2; t++) { + // the droplets a ball throws off when it bursts + spray[t] = new ParticleEmitter(d, 220).setColor(TEAM_COLORS[t]).setSpeed(2.5f, 6.5f) + .setLifetime(0.2f, 0.45f).setSize(0.035f, 0.01f).setGravity(9.8f).setDrag(1.5f) + .setDirection(0f, 1f, 0f, 75f); + spray[t].addTo(this); + // and the cloud of it that hangs in the air a moment + mist[t] = new ParticleEmitter(d, 160).setColor(0x40000000 | (TEAM_COLORS[t] & 0xffffff)) + .setSpeed(0.3f, 1.0f).setLifetime(0.3f, 0.7f).setSize(0.12f, 0.45f).setGravity(-0.15f) + .setDrag(2.5f); + mist[t].addTo(this); + // the gobs a hit on a player throws: big, slow, and falling + goo[t] = new ParticleEmitter(d, 120).setColor(TEAM_COLORS[t]).setSpeed(1.2f, 4.2f) + .setLifetime(0.5f, 1.0f).setSize(0.09f, 0.05f).setGravity(9.8f).setDrag(0.6f) + .setDirection(0f, 1f, 0f, 85f); + goo[t].addTo(this); + } + dust = new ParticleEmitter(d, 200).setColor(0x509a8f80).setSpeed(0.4f, 1.6f).setLifetime(0.9f, 1.8f) + .setSize(0.2f, 1.0f).setGravity(-0.25f).setDrag(2f).setDirection(0f, 1f, 0f, 70f); + dust.addTo(this); + flash = new ParticleEmitter(d, 96).setColor(0xffffd28a).setAdditive(true).setSpeed(0.2f, 1.4f) + .setLifetime(0.06f, 0.11f).setSize(0.34f, 0.06f); + flash.addTo(this); + puff = new ParticleEmitter(d, 120).setColor(0x70e8e8e8).setSpeed(0.3f, 1.1f).setLifetime(0.3f, 0.6f) + .setSize(0.06f, 0.32f).setGravity(-0.2f).setDrag(3f); + puff.addTo(this); + for (int t = 0; t < 2; t++) { + trail[t] = new ParticleEmitter(d, 900).setColor(0xc0000000 | (TEAM_COLORS[t] & 0xffffff)) + .setSpeed(0f, 0.05f).setLifetime(0.12f, 0.2f).setSize(0.05f, 0.012f); + trail[t].addTo(this); + } + // dust caught in the sun: a few faint specks drifting around the eye + motes = new ParticleEmitter(d, 90).setColor(0x55fff1d6).setAdditive(true).setSpeed(0.02f, 0.12f) + .setLifetime(3f, 6f).setSize(0.012f, 0.02f).setGravity(-0.01f); + motes.addTo(this); + } + + /// A ball bursting on a player: far more than on a wall, since this is the + /// moment the whole game is about. Drops fly out, fat gobs arc and fall, + /// and a cloud of paint hangs where they stood. + private void burstHit(int team, int x, int y, int z) { + float px = -x * MM; + float py = Math.max(0.3f, y * MM); + float pz = z * MM; + spray[team].burst(px, py, pz, 70); + goo[team].burst(px, py, pz, 28); + mist[team].burst(px, py, pz, 22); + } + + private void burstImpact(int team, int x, int y, int z, boolean grenade) { + float px = -x * MM; + float py = Math.max(0.03f, y * MM); + float pz = z * MM; + spray[team].burst(px, py, pz, grenade ? 90 : 14); + mist[team].burst(px, py, pz, grenade ? 40 : 4); + if (y < 200) { + dust.burst(px, 0.05f, pz, grenade ? 24 : 3); + } + } + + private void drift(float dt) { + moteClock += dt; + if (moteClock < 0.12f) { + return; + } + moteClock = 0f; + GameCamera c = getCamera(); + moteSeed = moteSeed * 1103515245 + 12345; + float ox = ((moteSeed >>> 8) & 1023) / 1023f * 8f - 4f; + moteSeed = moteSeed * 1103515245 + 12345; + float oz = ((moteSeed >>> 8) & 1023) / 1023f * 8f - 4f; + moteSeed = moteSeed * 1103515245 + 12345; + float oy = ((moteSeed >>> 8) & 1023) / 1023f * 2.2f - 0.6f; + motes.burst(c.getEyeX() + ox, c.getEyeY() + oy, c.getEyeZ() + oz, 1); + } + + private void tick(float dt) { + GameCamera c = getCamera(); + // paint hangs in the air as slowly as the replay plays + if (replayVictim >= 0) { + dt *= replaySpeed; + } + for (int t = 0; t < 2; t++) { + spray[t].update(dt, c); + mist[t].update(dt, c); + goo[t].update(dt, c); + } + dust.update(dt, c); + flash.update(dt, c); + puff.update(dt, c); + trail[0].update(dt, c); + trail[1].update(dt, c); + drift(dt); + motes.update(dt, c); + } + + /// Close enough to the camera to be worth a sound: 12 m. + private boolean near(int x, int z) { + Player p = driver.match().player(driver.me()); + long dx = x - p.x(); + long dz = z - p.z(); + return dx * dx + dz * dz < 144000000L; + } + + /// Paint where a ball burst, squashed flat against whatever it hit: the + /// ground, the top of a box or the nearest of its sides. + /// Props whose sides are the sides of their box, so paint can lie on them. + private static boolean flatSided(String kind) { + return "container".equals(kind) || "container-small".equals(kind) || "crate".equals(kind) + || "boxes".equals(kind) || "dumpster".equals(kind) || "snackbar".equals(kind) + || "barrier".equals(kind) || "barrier-single".equals(kind) || "watertower".equals(kind); + } + + private void decal(int team, int x, int y, int z, float size) { + Model d = decals[nextDecal]; + nextDecal = (nextDecal + 1) % MAX_DECALS; + int variant = ((x * 31) ^ (z * 17) ^ y) & (PaintDecals.VARIANTS - 1); + // a mirror on either axis makes four shapes look like sixteen + float s = size * 1.7f * (0.8f + ((x ^ z) & 7) / 20f); + float sx = ((x >> 3) & 1) == 0 ? s : -s; + float sy = ((z >> 3) & 1) == 0 ? s : -s; + Box hit = null; + List boxes = driver.match().arena().boxes(); + for (int i = 0; i < boxes.size(); i++) { + Box b = boxes.get(i); + if (x >= b.x0 - 150 && x <= b.x1 + 150 && z >= b.z0 - 150 && z <= b.z1 + 150 && y <= b.height + 150) { + hit = b; + break; + } + } + float px = -x * MM; + float py = y * MM; + float pz = z * MM; + boolean wall = false; + // The quad faces +z; turned to lie on whatever was hit. + float rx = -90; + float ry = 0; + if (hit == null || y < 120) { + // Each splat a layer of its own, 2 mm apart, so two that overlap + // never trade places from one frame to the next. + py = 0.02f + (nextDecal & 15) * 0.002f; + } else { + int dx0 = Math.abs(x - hit.x0); + int dx1 = Math.abs(x - hit.x1); + int dz0 = Math.abs(z - hit.z0); + int dz1 = Math.abs(z - hit.z1); + int dTop = Math.abs(y - hit.height); + int best = Math.min(Math.min(dx0, dx1), Math.min(Math.min(dz0, dz1), dTop)); + if (!flatSided(hit.kind)) { + // a car or a barrel is round: paint on its box would float + // in the air beside it, so the splat lands at its foot + py = 0.02f + (nextDecal & 15) * 0.002f; + int out = (int) (size * 400); + if (Math.min(dx0, dx1) <= Math.min(dz0, dz1)) { + px = -(dx0 < dx1 ? hit.x0 - out : hit.x1 + out) * MM; + } else { + pz = (dz0 < dz1 ? hit.z0 - out : hit.z1 + out) * MM; + } + } else if (best == dTop) { + py = hit.height * MM + 0.02f + (nextDecal & 7) * 0.002f; + // kept inside the top, or its edge hangs in the air + int half = Math.min((int) (s * 500), Math.min(hit.x1 - hit.x0, hit.z1 - hit.z0) / 2); + px = -fit(x, hit.x0, hit.x1, half) * MM; + pz = fit(z, hit.z0, hit.z1, half) * MM; + s = half / 500f; + sx = sx < 0 ? -s : s; + sy = sy < 0 ? -s : s; + } else { + // drips run down a wall, so the texture is not mirrored upside down + wall = true; + rx = 0; + // kept inside the face it hit: one past an edge or above the + // top is paint in mid air + boolean xFace = best == dx0 || best == dx1; + int half = Math.min((int) (s * 500), Math.min(xFace ? hit.z1 - hit.z0 : hit.x1 - hit.x0, + hit.height) / 2); + if (xFace) { + pz = fit(z, hit.z0, hit.z1, half) * MM; + } else { + px = -fit(x, hit.x0, hit.x1, half) * MM; + } + py = fit(y, 0, hit.height, half) * MM; + s = half / 500f; + sx = sx < 0 ? -s : s; + sy = s; + if (xFace) { + // world x is drawn mirrored, so the x0 face looks toward +x on screen + px = -(best == dx0 ? hit.x0 - 25 : hit.x1 + 25) * MM; + ry = best == dx0 ? 90 : -90; + } else { + pz = (best == dz0 ? hit.z0 - 25 : hit.z1 + 25) * MM; + ry = best == dz0 ? 180 : 0; + } + } + } + d.setMaterial(splatPaint[team & 1][wall ? 1 : 0][variant]); + d.setRotation(rx, ry, 0); + d.setScale(sx, sy, 1); + d.setPosition(px, py, pz); + d.setVisible(true); + } + + /// `v` moved so a span of `half` either side of it stays in `[lo, hi]`. + private static int fit(int v, int lo, int hi, int half) { + return Math.max(lo + half, Math.min(hi - half, v)); + } + + private void draw(float dt) { + Match m = shown(dt); + drawn = m; + int me = driver.me(); + boolean replaying = m != driver.match(); + Player self = m.player(me); + // Above the field when asked for, and while you wait to go back in + // once the replay has played: the floor was all there was to see. + overheadNow = !replaying && (overheadChosen || driver.interactive() && !self.alive() + && System.currentTimeMillis() > replayStartAt + 600 && !m.over()); + boolean firstPerson = driver.interactive() && !replaying && !overheadNow && !m.over(); + Player eye = m.player(me); + for (int i = 0; i < operators.length && i < m.playerCount(); i++) { + operator(m, i, i == me && firstPerson, dt); + // Someone standing inside you -- a teammate on the same spawn -- + // would fill the view from the inside out; a shooter hides them. + if (firstPerson && i != me && Math.abs(m.player(i).x() - eye.x()) < Match.RADIUS * 2 + && Math.abs(m.player(i).z() - eye.z()) < Match.RADIUS * 2) { + operators[i].setVisible(false); + held[i].setVisible(false); + } + } + List live = m.balls(); + for (int i = 0; i < MAX_BALLS; i++) { + if (i < live.size()) { + Ball b = live.get(i); + // Drawn a fraction of a tick ahead, so a ball moves every frame. + float bx = (b.x() + b.vx() * alpha) * MM; + float by = (b.y() + b.vy() * alpha) * MM; + float bz = (b.z() + b.vz() * alpha) * MM; + balls[i].setMaterial(teamPaint[b.team()]); + balls[i].setScale(b.grenade() ? 0.2f : 0.07f); + balls[i].setPosition(-bx, by, bz); + balls[i].setVisible(true); + if (!b.grenade()) { + trail[b.team() & 1].burst(-bx, by, bz, 2); + } + } else { + balls[i].setVisible(false); + } + } + if (m.mode() == Mode.CAPTURE_THE_FLAG && flagPole[0] != null) { + flagWave += dt; + for (int t = 0; t < 2; t++) { + float fx = -m.flagX(t) * MM; + float fy = m.flagY(t) * MM; + float fz = m.flagZ(t) * MM; + float yaw = flagWave * 40f + t * 90f; + int c = m.flagCarrier(t); + if (c >= 0 && c < bodyYaw.length) { + // slung on the carrier's back, the cloth streaming behind, + // so whoever has it is obvious from anywhere + double r = Math.toRadians(-bodyYaw[c]); + fx = -lerp(prevX[c], m.player(c).x() * MM) + (float) Math.sin(r) * 0.3f; + fz = lerp(prevZ[c], m.player(c).z() * MM) - (float) Math.cos(r) * 0.3f; + fy = lerp(prevY[c], m.player(c).y() * MM) + 0.6f; + yaw = bodyYaw[c] + 90f; + } + double yr = Math.toRadians(yaw); + float flap = (float) Math.sin(flagWave * 7f + t) * 12f; + flagPole[t].setPosition(fx, fy + 1.3f, fz); + flagCloth[t].setPosition(fx + (float) Math.cos(yr) * 0.48f, fy + 2.25f, + fz - (float) Math.sin(yr) * 0.48f); + flagCloth[t].setRotation(0f, yaw + flap, flap * 0.3f); + } + } + if (replaying && replayVictim >= 0 && shot > 0) { + angle(m, replayShooter, replayVictim, (int) SHOTS[shot][0]); + } else if (replaying) { + chase(m, replayShooter, dt); + } else if (m.over()) { + celebrate(m, m.winner(), dt); + } else if (overheadNow) { + overhead(m, me, dt); + } else { + camera(m, me, dt); + } + if (hud != null) { + hud.frame(driver.match(), me, lookYaw, lookPitch); + } + } + + /// One operator: where the rules put them, facing their look, playing the + /// clip their motion asks for, the marker in the right hand. + private void operator(Match m, int i, boolean first, float dt) { + Player p = m.player(i); + if (p.alive() && !wasAlive[i] && m.tick() > 2) { + // back in: a puff of smoke where they went down, and one where + // they reappear, so the jump across the yard reads as a respawn + float ox = -prevX[i]; + dust.burst(ox, 0.6f, prevZ[i], 30); + dust.burst(-p.x() * MM, 0.6f, p.z() * MM, 30); + flash.burst(-p.x() * MM, 1.0f, p.z() * MM, 3); + } + wasAlive[i] = p.alive(); + AnimatedModel op = operators[i]; + AnimatedModel gun = held[i]; + op.setVisible(!first || p.alive()); + // the marker stays in the hand when painted, and goes down with it + gun.setVisible(!first || p.alive()); + if (first != (headless == i) && headNodes[i] != null) { + op.setNodeVisible(headNodes[i], !first); + headless = first ? i : -1; + } + float x = -lerp(prevX[i], p.x() * MM); + float y = lerp(prevY[i], p.y() * MM); + float z = lerp(prevZ[i], p.z() * MM); + if (m.over()) { + // The team often finishes in a heap on the flag; spread out to + // cheer, rather than waving through each other. + float sx = 0f; + float sz = 0f; + for (int j = 0; j < m.playerCount(); j++) { + Player q = m.player(j); + if (j == i || !q.alive()) { + continue; + } + float ddx = x + q.x() * MM; + float ddz = z - q.z() * MM; + float d = (float) Math.sqrt(ddx * ddx + ddz * ddz); + if (d < CHEER_ROOM) { + if (d < 0.01f) { + // standing on the same spot: split by index + ddx = j < i ? 1f : -1f; + ddz = 0f; + d = 1f; + } + sx += ddx / d * (CHEER_ROOM - d) * 0.5f; + sz += ddz / d * (CHEER_ROOM - d) * 0.5f; + } + } + x += sx; + z += sz; + } + // The body turns smoothly toward the look; the rules turn in steps. + float target = (float) -(p.yaw() * 360.0 / Trig.FULL); + float turn = target - bodyYaw[i]; + while (turn > 180f) { + turn -= 360f; + } + while (turn < -180f) { + turn += 360f; + } + bodyYaw[i] += turn * Math.min(1f, dt * 14f); + float yawDeg = bodyYaw[i]; + double yr = Math.toRadians(-yawDeg); + // Motion in the operator's own frame, in m/s, smoothed over a fifth + // of a second so a bot's steering jitter never reaches the legs. + float vx = -(p.x() * MM - prevX[i]) * Match.TICK_HZ; + float vz = (p.z() * MM - prevZ[i]) * Match.TICK_HZ; + float k = Math.min(1f, dt * 6f); + moveF[i] += ((float) (vx * -Math.sin(yr) + vz * Math.cos(yr)) - moveF[i]) * k; + moveS[i] += ((float) (vx * -Math.cos(yr) + vz * -Math.sin(yr)) - moveS[i]) * k; + float fwd = moveF[i]; + float side = moveS[i]; + float speed = (float) Math.sqrt(fwd * fwd + side * side); + String want; + float clipSpeed = 1f; + if (m.over()) { + want = celebration(m, i); + } else if (!p.alive()) { + if (p.input().has(Input.WIPE)) { + want = Models.DISPUTE; + } else { + float since = (m.tick() - p.paintedAt()) / (float) Match.TICK_HZ; + want = performance(i, p.paintedAt(), since); + clipSpeed = drama(i, p.paintedAt()) == DRAMA_SLOW ? 0.45f : 1f; + } + } else if (p.crouched()) { + want = speed < 0.4f ? Models.CROUCH : Models.CROUCH_WALK; + } else if (first || speed < 0.6f) { + // marker up and ready, as players carry one + want = Models.AIM; + } else { + // Armed and moving, in any direction: the retargeted walk and run + // keep the arms in the aim pose above the hips, so the marker + // stays on the line of fire while the legs carry the body. + want = speed < WALK_PACE * 1.4f ? Models.WALK : Models.RUN; + } + // A new clip has to be wanted for a moment before it plays, except + // being painted, which is never late. + boolean urgent = !p.alive() || m.over() || clips[i] == null; + if (!want.equals(clips[i])) { + pending[i] += dt; + if (urgent || pending[i] > 0.15f) { + clips[i] = want; + pending[i] = 0; + boolean once = Models.DEATH.equals(want) || Models.HIT.equals(want) + || Models.HIT2.equals(want) || Models.ROLL.equals(want); + op.crossFade(want, once ? 0.08f : 0.25f, !once); + } + } else { + pending[i] = 0; + } + String now = clips[i]; + // Look up and down from the spine: the aim-up or aim-down pose mixed + // in by the pitch, so a player on a rise points the marker down at + // you instead of over your head. + float pitch = (float) (p.pitch() * 360.0 / Trig.FULL); + boolean aiming = p.alive() && !m.over() && !Models.CROUCH.equals(now) && !Models.CROUCH_WALK.equals(now); + if (aiming && Math.abs(pitch) > 2f) { + op.setLayer(pitch > 0 ? Models.AIM_UP : Models.AIM_DOWN, Math.min(1f, Math.abs(pitch) / 45f), Models.SPINE); + } else { + op.setLayer(null, 0f, null); + } + if (Models.WALK.equals(now) || Models.CROUCH_WALK.equals(now)) { + op.setSpeed(Math.max(0.5f, Math.min(1.6f, speed / WALK_PACE))); + } else if (Models.RUN.equals(now)) { + op.setSpeed(Math.max(0.6f, Math.min(1.5f, speed / RUN_PACE))); + } else { + op.setSpeed(clipSpeed); + } + op.update(replayVictim >= 0 && replaying(m) ? dt * replaySpeed : dt); + if (first) { + // placed by the camera, which knows where the eye is this frame + return; + } + op.setPosition(x, y, z); + op.setRotation(0f, yawDeg, 0f); + System.arraycopy(Matrix4.rotation((float) Math.toRadians(yawDeg), 0f, 1f, 0f), 0, bodyTurn, 0, 16); + attach(i, bodyTurn, x, y, z); + } + + /// Puts operator `i`'s marker in its hand. `turn` is the body's rotation + /// and `ox, oy, oz` its origin, as the model is placed this frame. + /// + /// The marker is bolted to the right wrist bone, position and turn, the + /// way a game parents a weapon to a hand: the arm and the marker move as + /// one through every clip, and when a player is painted it goes down with + /// the hand instead of vanishing. Earlier versions kept the marker at a + /// fixed carry point aimed along the look, and the animated arm then ran + /// up and down through a gun glued in the air. + private void attach(int i, float[] turn, float ox, float oy, float oz) { + grip.place(operators[i], opScale[i], turn, ox, oy, oz, held[i], muzzle[i]); + } + + /// The mesh node holding the operator's head: the one whose name ends in + /// `_Head` (the skeleton's own bone is plain `Head`). Null if there is none. + private static String headNode(AnimatedModel op) { + GltfScene sc = op.getScene(); + for (int k = 0; k < sc.getNodeCount(); k++) { + String name = sc.getNode(k).getName(); + if (name != null && name.endsWith("_Head")) { + return name; + } + } + return null; + } + + /// Over the shooter's right shoulder, looking where they look. + private void chase(Match m, int s, float dt) { + Player p = m.player(s); + float x = -lerp(prevX[s], p.x() * MM); + float y = lerp(prevY[s], p.y() * MM); + float z = lerp(prevZ[s], p.z() * MM); + // The rules turn in steps, and a bot's aim twitches every tick: a + // camera 2.6 m behind on that raw heading swings the shooter about + // the screen, so it eases after the look instead. + double want = p.yaw() * 2 * Math.PI / Trig.FULL; + if (Double.isNaN(chaseYaw)) { + chaseYaw = want; + } + double turn = want - chaseYaw; + while (turn > Math.PI) { + turn -= 2 * Math.PI; + } + while (turn < -Math.PI) { + turn += 2 * Math.PI; + } + chaseYaw += turn * Math.min(1f, dt * 4f); + double yr = chaseYaw; + float fx = (float) -Math.sin(yr); + float fz = (float) Math.cos(yr); + float rx = (float) -Math.cos(yr); + float rz = (float) -Math.sin(yr); + // Pulled in toward the shooter while a prop is in the way, or the + // replay is a close-up of the inside of a container. + float back = 2.6f; + int eye = p.y() + 1950; + // tested 0.4 m past the camera, in the rules' millimetres (x mirrored) + while (back > 0.6f && !m.arena().clear(p.x(), eye, p.z(), + (int) (-(x - fx * (back + 0.4f) + rx * 0.7f) * 1000f), eye, + (int) ((z - fz * (back + 0.4f) + rz * 0.7f) * 1000f))) { + back -= 0.2f; + } + if (back < 1.2f) { + // pulled in that close the camera is in the shooter's shoulder, + // and their fist and marker filled the screen + operators[s].setVisible(false); + held[s].setVisible(false); + } + float cx = x - fx * back + rx * 0.7f; + float cy = y + 1.95f; + float cz = z - fz * back + rz * 0.7f; + getCamera().setPerspective(HIP_FOV - 8f, NEAR, 400f); + getCamera().setPosition(cx, cy, cz); + getCamera().setTarget(x + fx * 9f, y + 1.2f, z + fz * 9f); + sky.setPosition(cx, cy, cz); + getLight().setShadowCenter(x + fx * SHADOW_AHEAD * 0.6f, 0f, z + fz * SHADOW_AHEAD * 0.6f); + } + + static final int DRAMA_PLAIN = 0; + static final int DRAMA_STAGGER = 1; + static final int DRAMA_ROLL = 2; + static final int DRAMA_SLOW = 3; + + /// How this operator takes being painted this time. Half the time it is + /// a plain fall; the rest make a scene. Chosen from who and when, so a + /// replay of the same hit plays the same performance. + static int drama(int who, int paintedAt) { + int r = (who * 7 + paintedAt * 13) & 7; + return r < 4 ? DRAMA_PLAIN : r < 6 ? DRAMA_STAGGER : r < 7 ? DRAMA_ROLL : DRAMA_SLOW; + } + + /// The clip a painted operator plays, s seconds after the hit. + static String performance(int who, int paintedAt, float s) { + switch (drama(who, paintedAt)) { + case DRAMA_STAGGER: + // clutches the chest, staggers the other way, then goes down + return s < 0.7f ? Models.HIT : s < 1.5f ? Models.HIT2 : Models.DEATH; + case DRAMA_ROLL: + // a dive for cover that is a second too late + return s < 0.9f ? Models.ROLL : Models.DEATH; + default: + return Models.DEATH; + } + } + + /// The line a drama queen delivers, or null for a quiet fall. + static String complaint(int who, int paintedAt) { + int d = drama(who, paintedAt); + if (d == DRAMA_PLAIN) { + return null; + } + String[] lines = d == DRAMA_STAGGER + ? new String[] {"TELL MY WIFE... I SPLATTED BRAVELY", "IT'S IN MY EYES! MY BEAUTIFUL EYES!", + "I SEE A LIGHT... IT'S MAGENTA"} + : d == DRAMA_ROLL + ? new String[] {"I'D LIKE TO SEE THAT AGAIN, REF", "THAT WAS A BOUNCE. EVERYONE SAW IT.", + "THE WIND TOOK IT!"} + : new String[] {"NOOOOOOOOOO", "WHY... ME...", "AVENGE... MY... HOODIE..."}; + return lines[(who + paintedAt) % lines.length]; + } + + /// Game over: the winners cheer, the losers stand there. + private static String celebration(Match m, int i) { + int w = m.winner(); + if (w < 0) { + // a draw: everyone shakes their head at the ref + return Models.DISPUTE; + } + if (m.player(i).team() != w) { + return Models.IDLE; + } + // A dance on the spot, every limb driven by the clip. A jump drawn by + // lifting the whole body read as being hoisted on a wire. + return Models.DANCE; + } + + private boolean replaying(Match m) { + return m != driver.match(); + } + + private float orbit; + private float orbitRise = 2.2f; + + /// Game over: a slow orbit around the winning team, close enough to see + /// them cheer -- or around everyone, on a draw. + private void celebrate(Match m, int team, float dt) { + float cx = 0f; + float cz = 0f; + float cy = 0f; + int n = 0; + for (int i = 0; i < m.playerCount(); i++) { + Player p = m.player(i); + if (team < 0 || p.team() == team) { + cx += -p.x() * MM; + cy += p.y() * MM; + cz += p.z() * MM; + n++; + } + } + if (n == 0) { + camera(m, driver.me(), dt); + return; + } + cx /= n; + cy /= n; + cz /= n; + orbit += dt * 0.35f; + float ex = cx + (float) Math.sin(orbit) * 5.5f; + float ez = cz + (float) Math.cos(orbit) * 5.5f; + // The orbit passes behind cars and containers: rise over whatever is + // between the camera and the team, instead of flying through it. + float want = 2.2f; + while (want < 6f && !m.arena().clear((int) (-ex * 1000f), (int) ((cy + want) * 1000f), (int) (ez * 1000f), + (int) (-cx * 1000f), (int) ((cy + 1.0f) * 1000f), (int) (cz * 1000f))) { + want += 0.6f; + } + orbitRise += (want - orbitRise) * Math.min(1f, dt * 3f); + getCamera().setPerspective(HIP_FOV - 10f, NEAR, 400f); + getCamera().setPosition(ex, cy + orbitRise, ez); + getCamera().setTarget(cx, cy + 1.0f, cz); + sky.setPosition(ex, cy + orbitRise, ez); + getLight().setShadowCenter(cx, 0f, cz); + } + + /// The replay's later angles: a fixed camera, as a broadcast would cut to. + /// The side angle stands 4 m off the line from shooter to victim, so the + /// ball crosses the frame; the victim angle is 2.2 m in front of the + /// victim at chest height, for the performance. + private void angle(Match m, int s, int v, int which) { + Player a = m.player(s); + Player b = m.player(v); + float ax = -lerp(prevX[s], a.x() * MM); + float az = lerp(prevZ[s], a.z() * MM); + float bx = -lerp(prevX[v], b.x() * MM); + float by = lerp(prevY[v], b.y() * MM); + float bz = lerp(prevZ[v], b.z() * MM); + float dx = bx - ax; + float dz = bz - az; + float len = (float) Math.sqrt(dx * dx + dz * dz); + if (len < 0.01f) { + dx = 0f; + dz = 1f; + } else { + dx /= len; + dz /= len; + } + float cx; + float cy; + float cz; + float tx; + float tz; + if (which == ANGLE_SIDE) { + cx = (ax + bx) * 0.5f - dz * 4f; + cz = (az + bz) * 0.5f + dx * 4f; + cy = by + 1.4f; + tx = bx - dx * 0.8f; + tz = bz - dz * 0.8f; + } else { + cx = bx - dx * 2.2f + dz * 0.4f; + cz = bz - dz * 2.2f - dx * 0.4f; + cy = by + 1.35f; + tx = bx; + tz = bz; + } + // A prop between the camera and the victim: try the other side of the + // line, then closer in, and take the first view that sees them. A + // broadcast never cuts to the inside of a container. + int vx = b.x(); + int vy = b.y() + 1300; + int vz = b.z(); + float ox = cx - bx; + float oz = cz - bz; + float[] tries = {1f, -1f, 0.6f, -0.6f, 0.35f, -0.35f}; + for (int k = 0; k < tries.length; k++) { + float f = tries[k]; + // a negative try mirrors the camera across the shooter-victim line + float px = ox; + float pz = oz; + if (f < 0) { + float along = ox * dx + oz * dz; + px = 2 * along * dx - ox; + pz = 2 * along * dz - oz; + f = -f; + } + float tx2 = bx + px * f; + float tz2 = bz + pz * f; + if (m.arena().clear(vx, vy, vz, (int) (-tx2 * 1000f), (int) (cy * 1000f), (int) (tz2 * 1000f))) { + cx = tx2; + cz = tz2; + break; + } + } + getCamera().setPerspective(which == ANGLE_VICTIM ? 42f : HIP_FOV - 14f, NEAR, 400f); + getCamera().setPosition(cx, cy, cz); + getCamera().setTarget(tx, by + 1.1f, tz); + sky.setPosition(cx, cy, cz); + getLight().setShadowCenter(bx, 0f, bz); + } + + /// High above the field, north up, drifting after the player: the whole + /// fight in one picture. + private void overhead(Match m, int me, float dt) { + Player p = m.player(me); + float x = -lerp(prevX[me], p.x() * MM); + float z = lerp(prevZ[me], p.z() * MM); + if (!overSet) { + overX = x; + overZ = z; + overSet = true; + } + float k = Math.min(1f, dt * 2.5f); + overX += (x - overX) * k; + overZ += (z - overZ) * k; + getCamera().setPerspective(52f, 0.5f, 400f); + getCamera().setPosition(overX, 25f, overZ - 15f); + getCamera().setTarget(overX, 0f, overZ + 1.5f); + sky.setPosition(overX, 25f, overZ - 15f); + getLight().setShadowCenter(overX, 0f, overZ + 2f); + } + + private void camera(Match m, int me, float dt) { + Player p = m.player(me); + float x = -lerp(prevX[me], p.x() * MM); + float z = lerp(prevZ[me], p.z() * MM); + float eye = lerp(prevY[me], p.y() * MM) + p.eyeY() * MM - p.y() * MM; + // Your own view turns with your hand, not with the tick: the look you are + // dragging is drawn now, a tick before the rules have it. + int yaw = driver.interactive() && p.alive() ? lookYaw : p.yaw(); + int pitch = driver.interactive() && p.alive() ? lookPitch : p.pitch(); + knock = Math.max(0f, knock - dt * 1.6f); + if (!p.alive()) { + // Painted: the head snaps back, then the view sinks and looks at + // the floor in shame. + float sink = 1f - knock * knock; + eye -= 0.5f * sink; + pitch = (int) (-500 * sink + 260 * knock * knock); + } + double yr = yaw * 2 * Math.PI / Trig.FULL; + double pr = pitch * 2 * Math.PI / Trig.FULL; + float dx = (float) (-Math.sin(yr) * Math.cos(pr)); + float dy = (float) Math.sin(pr); + float dz = (float) (Math.cos(yr) * Math.cos(pr)); + boolean aiming = p.alive() && p.input().has(Input.AIM); + ads += ((aiming ? 1f : 0f) - ads) * Math.min(1f, dt * 14f); + float fov = HIP_FOV + (ADS_FOV - HIP_FOV) * ads + knock * knock * 14f; + // Your own shoulder and upper arm sit at the eye; clipping what is + // nearer than FP_NEAR leaves the forearm, hand and marker. Nothing in + // the yard gets that close: the rules keep you RADIUS from cover. + getCamera().setPerspective(fov, p.alive() ? FP_NEAR : NEAR, 400f); + getCamera().setPosition(x, eye, z); + sky.setPosition(x, eye, z); + getCamera().setTarget(x + dx, eye + dy, z + dz); + // the shadow map covers what is in front of the eye, not behind it + getLight().setShadowCenter(x + dx * SHADOW_AHEAD, 0f, z + dz * SHADOW_AHEAD); + recoil = Math.max(0f, recoil - dt * 9f); + // Sway lags the look; bob follows the feet. + float turn = Trig.wrap(yaw - lastYaw + Trig.FULL / 2) - Trig.FULL / 2; + swayX += (-turn * 0.0004f - swayX) * Math.min(1f, dt * 10f); + swayY += ((pitch - lastPitch) * 0.0004f - swayY) * Math.min(1f, dt * 10f); + lastYaw = yaw; + lastPitch = pitch; + AnimatedModel op = operators[me]; + if (!p.alive() || !op.isVisible()) { + return; + } + Input in = p.input(); + boolean moving = in.forward != 0 || in.strafe != 0; + if (moving && p.grounded()) { + bob += dt * (in.has(Input.SPRINT) ? 14f : 9f); + } + float hip = 1f - ads; + float bobX = (float) Math.sin(bob) * 0.012f * hip; + float bobY = (float) Math.abs(Math.cos(bob)) * 0.014f * hip; + // Your own operator is the viewmodel: the body turns with the look + // (yaw, then pitch) about its own eye, which sits where yours is. In + // the shooting pose the right arm reaches out from the shoulder, so + // you see a forearm, a hand and the marker in it, low and right, + // exactly as the others see you hold it. Pinning the hand to a spot + // instead put the shoulders above the eye, and both arms rose up + // through the view. + float yawDeg = -yaw * 360f / Trig.FULL; + // the body faces +z, so looking up is a negative turn about x + float pitchDeg = -pitch * 360f / Trig.FULL - recoil * 4f; + System.arraycopy(Matrix4.rotation((float) Math.toRadians(yawDeg), 0f, 1f, 0f), 0, bodyTurn, 0, 16); + System.arraycopy(Matrix4.rotation((float) Math.toRadians(pitchDeg), 1f, 0f, 0f), 0, pitchTurn, 0, 16); + Matrix4.multiply(bodyTurn, pitchTurn, gunTurn); + System.arraycopy(gunTurn, 0, bodyTurn, 0, 16); + if (!op.getNodeMatrix(Models.HEAD, hand)) { + return; + } + float s = opScale[me]; + // from the head bone to the eyes, then the bob, sway and kick + // A portrait phone sees about 18 degrees either side of ahead, and the + // gun hand is 22 degrees right of the eye: there the body moves right + // (its own -x) so the hand comes into the view. + float aspect = (float) getWidth() / Math.max(1, getHeight()); + float narrow = 0.3f * Math.max(0f, 1f - aspect); + // The aim pose holds the marker on the line of sight, cheek to the + // stock; at the hip that filled the middle of the view with gun. Out + // of aim the body drops and steps right, so the marker sits low and + // right of the crosshair, and comes up to the eye as you aim. + float hx = hand[12] * s - (bobX + swayX) * 0.5f - narrow + HIP_RIGHT * hip; + float hy = hand[13] * s + EYE_ABOVE_HEAD - (bobY + swayY) * 0.5f + HIP_DROP * hip; + float hz = hand[14] * s + EYE_AHEAD_OF_HEAD - 0.04f * ads + 0.05f * recoil; + float ox = x - (bodyTurn[0] * hx + bodyTurn[4] * hy + bodyTurn[8] * hz); + float oy = eye - (bodyTurn[1] * hx + bodyTurn[5] * hy + bodyTurn[9] * hz); + float oz = z - (bodyTurn[2] * hx + bodyTurn[6] * hy + bodyTurn[10] * hz); + op.setPosition(ox, oy, oz); + op.setRotation(pitchDeg, yawDeg, 0f); + attach(me, bodyTurn, ox, oy, oz); + // The aim pose holds the marker up by the cheek, and its stock then + // reached back past the near plane: the plane cut it open and you + // looked into the inside of the gun. Slide the body forward along + // the look until the end of the stock is clear of it. + AnimatedModel gun = held[me]; + float[] g = grip.turn(); + float rear = heldRear[me]; + float rx = gun.getX() + g[8] * rear - x; + float ry = gun.getY() + g[9] * rear - eye; + float rz = gun.getZ() + g[10] * rear - z; + float behind = FP_NEAR + STOCK_CLEARANCE - (rx * dx + ry * dy + rz * dz); + if (behind > 0f) { + ox += dx * behind; + oy += dy * behind; + oz += dz * behind; + op.setPosition(ox, oy, oz); + attach(me, bodyTurn, ox, oy, oz); + } + viewMuzzleX = muzzle[me][0]; + viewMuzzleY = muzzle[me][1]; + viewMuzzleZ = muzzle[me][2]; + } + + private float lerp(float a, float b) { + // A teleport (a respawn) is not smoothed across the field. + if (Math.abs(b - a) > 2f) { + return b; + } + return a + (b - a) * alpha; + } + + private static int dim(int rgb, float k) { + int r = (int) (((rgb >> 16) & 0xff) * k); + int g = (int) (((rgb >> 8) & 0xff) * k); + int b = (int) ((rgb & 0xff) * k); + return 0xff000000 | (r << 16) | (g << 8) | b; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Arenas.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Arenas.java new file mode 100644 index 00000000000..8c866deb926 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Arenas.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.Mode; + +/// The playlist: every field with every mode, as the `arena:MODE` references +/// the server takes. The fields ship with the app, so nothing is downloaded. +public final class Arenas { + public static final String DEFAULT = "lot7:TEAM_PAINT"; + + private Arenas() { + } + + public static String[] all() { + Mode[] modes = Mode.values(); + String[] out = new String[Catalog.ARENAS.length * modes.length]; + int k = 0; + for (int a = 0; a < Catalog.ARENAS.length; a++) { + for (int m = 0; m < modes.length; m++) { + out[k++] = Catalog.ARENAS[a].id + ":" + modes[m].name(); + } + } + return out; + } + + public static String arenaName(String ref) { + return Catalog.arena(ref.substring(0, ref.indexOf(':'))).name; + } + + public static Mode mode(String ref) { + return Mode.byName(ref.substring(ref.indexOf(':') + 1)); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Driver.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Driver.java new file mode 100644 index 00000000000..ce16767cb76 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Driver.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codenameone.examples.splat.rules.Event; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import java.util.ArrayList; +import java.util.List; + +/// Where a match's ticks come from. The view asks whether it may step, hands +/// over the player's input, steps and draws; the driver decides what the +/// step means. +/// +/// - [PracticeDriver]: offline against the rules' bots. +/// - [OnlineDriver]: the server decides, the phone predicts. +/// - [ReplayDriver]: a recorded match, at any speed. +/// +/// All three step the same [Match] the server runs, which is why a rule +/// changed in the rules module is changed in every one of them. +// tag::splat-driver[] +public abstract class Driver { + protected Match match; + /// The player the camera looks out of. + protected int me; + protected String[] names = new String[0]; + private final List events = new ArrayList(); + + public Match match() { + return match; + } + + public int me() { + return me; + } + + public String name(int player) { + if (interactive() && player == me) { + return "You"; + } + return player < names.length ? names[player] : "Player " + (player + 1); + } + + /// Whether the next tick may be simulated now. + public abstract boolean canStep(); + + /// Simulates one tick with `in` as the player's input (ignored when not + /// [#interactive()]). + public abstract void step(Input in); + + /// What happened since the view last asked: splats, PAINTED, captures. + public List drainEvents() { + List out = new ArrayList(events); + events.clear(); + return out; + } + + protected void emit(List happened) { + events.addAll(happened); + } + + protected void emit(Event e) { + events.add(e); + } + + public boolean interactive() { + return true; + } + + public int speedPercent() { + return 100; + } + + /// Ticks still to wait before the first step: the briefing. + public int countdownTicks() { + return 0; + } + + public void dispose() { + } +} +// end::splat-driver[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Grip.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Grip.java new file mode 100644 index 00000000000..ce038f6dd8a --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Grip.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.gaming.AnimatedModel; +import com.codename1.util.MathUtil; + +/// Puts a marker in an operator's right hand. +/// +/// The marker is bolted to the hand bone, position and turn, the way a game +/// parents a weapon to a hand: arm and marker move as one through every clip, +/// and when a player is painted the marker goes down with the hand instead of +/// vanishing. A marker kept at a fixed carry point and aimed along the look +/// had the animated arm running up and down through a gun glued in the air. +/// +/// The constants are measured from the `Aim` clip of the operators +/// `tools/make-operators.py` builds, where the marker points straight ahead. +/// Re-measure them after changing the rig. +final class Grip { + /// From the grip to the end of a held marker's barrel, in metres. + static final float MUZZLE_REACH = 0.36f; + /// The hand bone's turn in the aim pose, column major. + private static final float[] REST_HAND = { + -0.9112f, 0.408f, -0.0569f, + 0.0217f, 0.1856f, 0.9824f, + 0.4114f, 0.8939f, -0.178f}; + /// The hand bone's position in the aim pose, model space, in metres. + private static final float[] REST_AT = {-0.158f, 1.357f, 0.418f}; + /// The marker's turn in the hand's frame: in the aim pose it points + /// straight ahead. Column major, the transpose of `REST_HAND`. + private static final float[] GUN_IN_HAND = { + 0.9112f, -0.0217f, -0.4114f, + 0.408f, 0.1856f, 0.8939f, + 0.0569f, -0.9824f, 0.178f}; + /// The grip, in the hand's frame: in the palm, by the knuckles. + private static final float[] GRIP_LOCAL = {-0.0025f, 0.0543f, -0.0057f}; + + private final float[] hand = new float[16]; + private final float[] handTurn = new float[9]; + private final float[] gunTurn = new float[16]; + + /// The marker's turn as the last `place` left it, column major 4x4; its + /// third column is the barrel's back direction. + float[] turn() { + return gunTurn; + } + + /// Places `gun` in the hand of `op`, drawn at `scale` with body turn + /// `turn` (column major 4x4) at `ox, oy, oz`, and writes the end of its + /// barrel into `muzzle` when that is not null. + void place(AnimatedModel op, float s, float[] turn, float ox, float oy, float oz, + AnimatedModel gun, float[] muzzle) { + float[] w = hand; + float wx; + float wy; + float wz; + if (op.getNodeMatrix(Models.HAND, w)) { + // the bone's rotation, without whatever scale the rig carries + for (int c = 0; c < 3; c++) { + float n = (float) Math.sqrt(w[c * 4] * w[c * 4] + w[c * 4 + 1] * w[c * 4 + 1] + + w[c * 4 + 2] * w[c * 4 + 2]); + if (n > 1e-6f) { + w[c * 4] /= n; + w[c * 4 + 1] /= n; + w[c * 4 + 2] /= n; + } + } + wx = w[12]; + wy = w[13]; + wz = w[14]; + } else { + // no hand bone: hold it where the aim pose holds the hand + for (int k = 0; k < 9; k++) { + w[(k / 3) * 4 + k % 3] = REST_HAND[k]; + } + wx = REST_AT[0]; + wy = REST_AT[1]; + wz = REST_AT[2]; + } + // grip in model space: the hand plus the palm offset, in the hand's frame + float lx = w[0] * GRIP_LOCAL[0] + w[4] * GRIP_LOCAL[1] + w[8] * GRIP_LOCAL[2]; + float ly = w[1] * GRIP_LOCAL[0] + w[5] * GRIP_LOCAL[1] + w[9] * GRIP_LOCAL[2]; + float lz = w[2] * GRIP_LOCAL[0] + w[6] * GRIP_LOCAL[1] + w[10] * GRIP_LOCAL[2]; + float hx = (wx + lx) * s; + float hy = (wy + ly) * s; + float hz = (wz + lz) * s; + float gx = ox + turn[0] * hx + turn[4] * hy + turn[8] * hz; + float gy = oy + turn[1] * hx + turn[5] * hy + turn[9] * hz; + float gz = oz + turn[2] * hx + turn[6] * hy + turn[10] * hz; + // marker turn = body * hand * (the hand's aim-pose turn undone) + for (int c = 0; c < 3; c++) { + for (int r = 0; r < 3; r++) { + float v = 0f; + for (int k = 0; k < 3; k++) { + v += w[k * 4 + r] * GUN_IN_HAND[c * 3 + k]; + } + handTurn[c * 3 + r] = v; + } + } + float[] g = gunTurn; + for (int c = 0; c < 3; c++) { + for (int r = 0; r < 3; r++) { + g[c * 4 + r] = turn[r] * handTurn[c * 3] + turn[4 + r] * handTurn[c * 3 + 1] + + turn[8 + r] * handTurn[c * 3 + 2]; + } + g[c * 4 + 3] = 0f; + } + g[12] = 0f; + g[13] = 0f; + g[14] = 0f; + g[15] = 1f; + setEuler(gun, g); + gun.setPosition(gx, gy, gz); + if (muzzle != null) { + // the marker's barrel runs along its own -z, from the grip + muzzle[0] = gx - g[8] * MUZZLE_REACH; + muzzle[1] = gy - g[9] * MUZZLE_REACH; + muzzle[2] = gz - g[10] * MUZZLE_REACH; + } + } + + private static void setEuler(AnimatedModel m, float[] r) { + float r20 = r[2]; + float r21 = r[6]; + float r22 = r[10]; + float r10 = r[1]; + float r00 = r[0]; + if (r20 > 1f) { + r20 = 1f; + } else if (r20 < -1f) { + r20 = -1f; + } + double ry = MathUtil.asin(-r20); + double rx; + double rz; + if (Math.abs(r20) < 0.9999f) { + rx = MathUtil.atan2(r21, r22); + rz = MathUtil.atan2(r10, r00); + } else { + // gimbal lock: fold the whole turn into x + rx = MathUtil.atan2(-r[9], r[5]); + rz = 0; + } + m.setRotation((float) Math.toDegrees(rx), (float) Math.toDegrees(ry), (float) Math.toDegrees(rz)); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Hud.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Hud.java new file mode 100644 index 00000000000..019067c43d2 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Hud.java @@ -0,0 +1,1069 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codenameone.examples.splat.rules.Arena; +import com.codenameone.examples.splat.rules.Box; +import com.codenameone.examples.splat.rules.Event; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mission; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Player; +import com.codenameone.examples.splat.rules.Trig; +import java.util.ArrayList; +import java.util.List; + +/// Everything drawn over the field, painted with `Graphics` straight onto a +/// transparent component layered above the [ArenaView], laid out the way every +/// serious military shooter lays it out: the minimap top left, the score bar and +/// the clock top centre, the kill feed top right, the magazine bottom right, +/// hit markers and damage arcs around the crosshair, name tags over your squad, +/// and -- when it is you -- the big red PAINTED stamp. +/// +/// It keeps to the form's safe area, so a notch never covers the score. +// tag::splat-hud[] +public final class Hud extends Component implements ArenaView.Hud { + private static final long STAMP_MILLIS = 1800; + private static final int FEED_LINES = 5; + private static final long FEED_MILLIS = 6000; + private static final long POP_MILLIS = 1400; + private static final long ARC_MILLIS = 1600; + /// What a painted player says. The ref has heard all of them. + static final String[] EXCUSES = { + "(disputes this)", "(says it was a bounce)", "(is checking for a break)", + "(\"that was my marker\")", "(wants a replay)", "(demands to see the ref's license)", + "(insists it was a teammate)", "(is calling their lawyer)", + }; + + private final Driver driver; + private ArenaView view; + private Match match; + private int me; + private final List feed = new ArrayList(); + private long stampAt = -STAMP_MILLIS; + private String stampText = "PAINTED"; + private String stampSub = ""; + private long hitAt = -1000; + private boolean hitKill; + private long popAt = -POP_MILLIS; + private String pop = ""; + private int streak; + private long arcAt = -ARC_MILLIS; + private int arcX; + private int arcZ; + private long splatAt = -10000; + private int splatSeed; + private String banner = ""; + private long bannerAt = -10000; + private final float[] clip = new float[4]; + private Font huge; + private Font big; + private Font medium; + private Font small; + private Font tiny; + private Font narrow; + private Font stampFont; + /// A phone: less on screen, every element smaller. A desktop or a tablet + /// has room for the whole picture. + private boolean compact; + private boolean feedHidden; + private boolean ended; + private final List chat = new ArrayList(); + private final List chatAt = new ArrayList(); + + /// One line of the kill feed: who, with what, whom. + static final class FeedLine { + final String left; + final int leftColor; + final String verb; + final String right; + final int rightColor; + final long at; + + FeedLine(String left, int leftColor, String verb, String right, int rightColor, long at) { + this.left = left; + this.leftColor = leftColor; + this.verb = verb; + this.right = right; + this.rightColor = rightColor; + this.at = at; + } + } + + public Hud(Driver driver) { + this.driver = driver; + setFocusable(false); + getAllStyles().setBgTransparency(0); + setName("hud"); + } + + /// The view whose camera the name tags are projected through. + public void attach(ArenaView v) { + view = v; + } + + @Override + public void frame(Match m, int me, float lookYaw, float lookPitch) { + this.match = m; + this.me = me; + repaint(); + } + + /// Friendly blue or hostile red, as seen by whoever this HUD belongs to. + private int side(int player) { + if (match == null || player < 0 || player >= match.playerCount()) { + return Type.WHITE; + } + return match.player(player).team() == match.player(me).team() ? Type.FRIENDLY : Type.HOSTILE; + } + + @Override + public void event(Event e) { + long now = System.currentTimeMillis(); + Match m = driver.match(); + if (e.kind == Event.PAINTED) { + feed(driver.name(e.a), side(e.a), "PAINTED", driver.name(e.b) + " " + EXCUSES[(e.tick + e.b) % EXCUSES.length], + side(e.b), now); + if (e.b == me && driver.interactive()) { + stampAt = now; + stampText = "PAINTED"; + stampSub = "BY " + Type.caps(driver.name(e.a)) + ". WALK IT OFF."; + splatAt = now; + splatSeed = e.tick; + streak = 0; + if (m != null && e.a >= 0 && e.a < m.playerCount()) { + arcAt = now; + arcX = m.player(e.a).x(); + arcZ = m.player(e.a).z(); + } + } else if (e.a == me && driver.interactive()) { + hitAt = now; + hitKill = true; + streak++; + popAt = now; + pop = streak >= 3 ? "+150 PAINT STREAK x" + streak : "+100 PAINTED"; + } + } else if (e.kind == Event.SPLAT && e.a == me && driver.interactive() && m != null && near(e)) { + hitAt = now; + hitKill = false; + } else if (e.kind == Event.WIPE_OK) { + feed(driver.name(e.a), side(e.a), "WIPED IT OFF.", "Nobody saw. (Everybody saw.)", Type.STEEL, now); + if (e.a == me && driver.interactive()) { + announce("WIPED. ACT NATURAL.", now); + splatAt = -10000; + } + } else if (e.kind == Event.WIPE_CAUGHT) { + feed("REF", Type.AMBER, "CAUGHT", driver.name(e.a) + " wiping. +3 seconds.", side(e.a), now); + if (e.a == me && driver.interactive()) { + stampAt = now; + stampText = "CAUGHT"; + stampSub = "THE REF IS WRITING YOUR NAME DOWN."; + } + } else if (e.kind == Event.FRIENDLY) { + feed(driver.name(e.a), side(e.a), "TEAMKILLED", "and is not over it", Type.STEEL, now); + } else if (e.kind == Event.CAPTURE) { + feed(driver.name(e.a), side(e.a), "CAPTURED", "the bedsheet", Type.AMBER, now); + if (e.a == me && driver.interactive()) { + popAt = now; + pop = "+300 FLAG CAPTURE"; + } + } else if (e.kind == Event.FLAG_TAKEN) { + feed(driver.name(e.a), side(e.a), "TOOK", "the enemy flag", Type.AMBER, now); + } else if (e.kind == Event.RELOAD && e.a == me && driver.interactive()) { + announce("RELOADING -- SHOUT \"COVER ME!\" FOR EFFECT", now); + } else if (e.kind == Event.END && m != null) { + stampAt = now; + boolean mine = driver.interactive() && e.b == m.player(me).team(); + stampText = e.b < 0 ? "DRAW" : mine ? "VICTORY" : driver.interactive() ? "DEFEAT" : "OVER"; + stampSub = e.b < 0 ? "EVERYONE CLAIMS THEY WON." : Type.caps(ArenaView.TEAM_NAMES[e.b]) + " WINS. SNACKS ON THEM."; + } + } + + /// A ball of yours burst within a metre of somebody on the other team. + private boolean near(Event e) { + int team = match == null ? -1 : match.player(me).team(); + for (int i = 0; match != null && i < match.playerCount(); i++) { + Player p = match.player(i); + if (p.team() == team || !p.alive()) { + continue; + } + long dx = e.x - p.x(); + long dz = e.z - p.z(); + if (dx * dx + dz * dz < 1000000L && e.y < p.height() + 300) { + return true; + } + } + return false; + } + + private void feed(String left, int lc, String verb, String right, int rc, long now) { + // two identical lines in a row (a grenade painting two teammates) + // read as a glitch; one is enough + for (int i = feed.size() - 1; i >= 0 && now - feed.get(i).at < 1500; i--) { + FeedLine f = feed.get(i); + if (f.left.equals(left) && f.verb.equals(verb) && f.right.equals(right)) { + return; + } + } + feed.add(new FeedLine(left, lc, verb, right, rc, now)); + while (feed.size() > FEED_LINES) { + feed.remove(0); + } + } + + private void announce(String text, long now) { + banner = text; + bannerAt = now; + } + + private void fonts() { + if (big != null) { + return; + } + huge = Type.bold(9f); + big = Type.bold(5.2f); + medium = Type.bold(3.4f); + small = Type.bold(2.6f); + tiny = Type.medium(2.2f); + narrow = Type.bold(2.1f); + stampFont = Type.bold(15f); + } + + @Override + public void paint(Graphics g) { + if (match == null || ended) { + return; + } + fonts(); + long now = System.currentTimeMillis(); + int x0 = getX(); + int y0 = getY(); + int w = getWidth(); + int h = getHeight(); + int top = y0 + safeTop() + CN.convertToPixels(2); + int pad = CN.convertToPixels(2.5f); + g.setAntiAliased(true); + g.setAntiAliasedText(true); + Player p = match.player(me); + compact = !CN.isDesktop() && Math.min(w, h) < CN.convertToPixels(110); + int count = driver.countdownTicks(); + if (count > 0) { + // the briefing is a screen of its own; the HUD comes after it + briefing(g, x0, y0, w, h, count); + return; + } + + splatter(g, now, x0, y0, w, h); + if (driver.interactive()) { + nameTags(g, x0, y0, w, h); + } + int mapSize = compact ? Math.min(CN.convertToPixels(17), w / 4) + : Math.min(CN.convertToPixels(26), Math.min(w, h) / 3); + minimap(g, x0 + pad, top, mapSize); + chatLines(g, now, x0 + pad, top + mapSize + CN.convertToPixels(compact ? 1.5f : 5), w / 2); + scoreBar(g, x0 + w / 2, top); + if (compact) { + // Under the score bar, clear of the minimap and the pause button. + killFeed(g, x0 + w - pad, top + medium.getHeight() + CN.convertToPixels(9)); + } else { + // Under the pause button, and under the score bar where both + // would not fit side by side. + boolean roomy = w > CN.convertToPixels(150); + killFeed(g, x0 + w - pad, top + (roomy ? CN.convertToPixels(10) + : big.getHeight() + tiny.getHeight() + CN.convertToPixels(3))); + } + + if (driver.interactive()) { + float ads = view == null ? 0f : view.aimAmount(); + crosshair(g, now, x0 + w / 2, y0 + h / 2, p.alive() && ads < 0.6f); + hitMarker(g, now, x0 + w / 2, y0 + h / 2); + damageArc(g, now, x0 + w / 2, y0 + h / 2, Math.min(w, h) / 4); + ammo(g, p, x0 + w - pad, y0 + h - pad - safeBottom() + - (CN.isDesktop() ? 0 : CN.convertToPixels(34))); + // the stamp has the middle of the screen to itself while it lands + boolean stamping = now - stampAt < STAMP_MILLIS; + if (!stamping) { + xpPop(g, now, x0 + w / 2 + CN.convertToPixels(8), y0 + h / 2 - CN.convertToPixels(6)); + } + if (!p.alive() && !stamping) { + int left = Math.max(0, p.respawnAt() - match.tick()) / Match.TICK_HZ + 1; + String wipe = compact ? "TAP WIPE -- IF THE REF ISN'T LOOKING" + : "HOLD [X] TO WIPE IT OFF -- IF THE REF ISN'T LOOKING"; + String back = p.canWipe(match.tick()) ? wipe : "REDEPLOYING IN " + left; + // where the crosshair was: nothing else is drawn there while + // you are out + plate(g, back, x0 + w / 2, y0 + h / 2 - small.getHeight() / 2, compact ? small : medium, Type.AMBER); + } + } else { + plate(g, "SPECTATING " + Type.caps(driver.name(me)), x0 + w / 2, + y0 + h - CN.convertToPixels(10) - safeBottom(), medium, Type.WHITE); + } + if (now - bannerAt < 2200 && now - stampAt >= STAMP_MILLIS) { + plate(g, banner, x0 + w / 2, y0 + h * 58 / 100, small, Type.WHITE); + } + bubbles(g, x0, y0, w, h); + replayBars(g, x0, y0, w, h); + stamp(g, now, x0, y0, w, h); + } + + /// While the view plays the shot back: cinema bars, and a plate naming + /// who did it, so a replay is never mistaken for the live match. + private void replayBars(Graphics g, int x0, int y0, int w, int h) { + int shooter = view == null ? -1 : view.replayingShooter(); + if (shooter < 0 || shooter >= match.playerCount()) { + if (view != null && view.isOverhead() && driver.interactive()) { + plate(g, "OVERHEAD", x0 + w / 2, y0 + h - safeBottom() - CN.convertToPixels(compact ? 40 : 8), + tiny, Type.WHITE); + } + return; + } + int bar = h / 9; + g.setColor(0); + g.setAlpha(230); + g.fillRect(x0, y0, w, bar); + g.fillRect(x0, y0 + h - bar, w, bar); + g.setAlpha(255); + int angle = view.replayAngle(); + String title = angle == ArenaView.ANGLE_SIDE ? "ANGLE 2/3 -- SUPER SLOW-MO" + : angle == ArenaView.ANGLE_VICTIM ? "ANGLE 3/3 -- THE PERFORMANCE" : "INSTANT REPLAY -- ANGLE 1/3"; + plate(g, title, x0 + w / 2, y0 + bar + CN.convertToPixels(1.5f), medium, Type.AMBER); + // a white flash on every cut, the way a broadcast marks a new angle + long cut = view.sinceCut(); + if (cut >= 0 && cut < 220) { + g.setColor(0xffffff); + g.setAlpha((int) (200 * (220 - cut) / 220)); + g.fillRect(x0, y0 + bar, w, h - bar * 2); + g.setAlpha(255); + } + plate(g, "PAINTED BY " + Type.caps(driver.name(shooter)), x0 + w / 2, + y0 + h - bar - small.getHeight() - CN.convertToPixels(2), small, Type.WHITE); + } + + /// Top left: the yard from above, north up, cover in steel, your squad in + /// blue and anyone on the other side who just fired in red. + private void minimap(Graphics g, int x, int y, int size) { + Arena a = match.arena(); + g.setColor(Type.INK); + g.setAlpha(170); + g.fillRect(x, y, size, size); + g.setAlpha(255); + g.setColor(Type.STEEL); + g.drawRect(x, y, size - 1, size - 1); + int span = Math.max(a.width, a.depth); + int ox = x + (size - (int) ((long) a.width * size / span)) / 2; + int oy = y + (size - (int) ((long) a.depth * size / span)) / 2; + List boxes = a.boxes(); + g.setColor(0x56606a); + for (int i = 0; i < boxes.size(); i++) { + Box b = boxes.get(i); + // Mirrored like the field: rules +x is screen left. + int bx0 = ox + mx(a.width - b.x1, size, span); + int bx1 = ox + mx(a.width - b.x0, size, span); + int bz0 = oy + mx(a.depth - b.z1, size, span); + int bz1 = oy + mx(a.depth - b.z0, size, span); + g.fillRect(bx0, bz0, Math.max(2, bx1 - bx0), Math.max(2, bz1 - bz0)); + } + if (match.mode() == Mode.SNACK_BAR) { + int r = Math.max(3, mx(a.hillRadius, size, span)); + g.setColor(Type.AMBER); + g.drawArc(ox + mx(a.width - a.hillX, size, span) - r, oy + mx(a.depth - a.hillZ, size, span) - r, + r * 2, r * 2, 0, 360); + } + int dot = Math.max(3, CN.convertToPixels(0.9f)); + int myTeam = match.player(me).team(); + for (int i = 0; i < match.playerCount(); i++) { + Player q = match.player(i); + boolean friend = q.team() == myTeam; + if (!q.alive() || (!friend && !q.input().has(com.codenameone.examples.splat.rules.Input.FIRE))) { + continue; + } + int px = ox + mx(a.width - q.x(), size, span); + int pz = oy + mx(a.depth - q.z(), size, span); + if (i == me) { + arrow(g, px, pz, q.yaw(), dot * 2, Type.AMBER); + } else { + g.setColor(friend ? Type.FRIENDLY : Type.HOSTILE); + g.fillArc(px - dot, pz - dot, dot * 2, dot * 2, 0, 360); + } + } + if (!compact) { + g.setFont(tiny); + g.setColor(Type.STEEL); + g.drawString(Type.caps(a.name), x, y + size + 2); + } + } + + private static int mx(int mm, int size, int span) { + return (int) ((long) mm * size / Math.max(1, span)); + } + + /// Your marker on the map: a chevron pointing where you look. The map is + /// drawn from above with both axes flipped, so +z is up. + private static void arrow(Graphics g, int cx, int cy, int yaw, int r, int color) { + double a = yaw * 2 * Math.PI / Trig.FULL; + float fx = (float) Math.sin(a); + float fy = (float) -Math.cos(a); + int[] xs = {cx + (int) (fx * r), cx + (int) ((-fx * 0.6f - fy * 0.7f) * r), cx + (int) ((-fx * 0.6f + fy * 0.7f) * r)}; + int[] ys = {cy + (int) (fy * r), cy + (int) ((-fy * 0.6f + fx * 0.7f) * r), cy + (int) ((-fy * 0.6f - fx * 0.7f) * r)}; + g.setColor(color); + g.fillPolygon(xs, ys, 3); + } + + /// Top centre: your team's score in blue on the left, theirs in red on the + /// right, each with a bar toward the target, and the clock between them. + private void scoreBar(Graphics g, int cx, int y) { + int mine = match.player(me).team(); + int secs = match.ticksLeft() / Match.TICK_HZ; + String clock = (secs / 60) + ":" + (secs % 60 < 10 ? "0" : "") + (secs % 60); + Font num = compact ? medium : big; + Font clockFont = compact ? small : medium; + int cell = CN.convertToPixels(compact ? 9 : 13); + int ch = num.getHeight(); + int clockW = clockFont.stringWidth("00:00") + CN.convertToPixels(compact ? 2 : 4); + int target = Math.max(1, match.mode().target); + for (int k = 0; k < 2; k++) { + int team = k == 0 ? mine : 1 - mine; + int bx = k == 0 ? cx - clockW / 2 - cell : cx + clockW / 2; + int color = k == 0 ? Type.FRIENDLY : Type.HOSTILE; + g.setColor(Type.INK); + g.setAlpha(185); + g.fillRect(bx, y, cell, ch); + g.setAlpha(255); + g.setColor(color); + g.fillRect(bx, y, cell, 3); + int fill = (int) ((long) Math.min(target, match.score(team)) * cell / target); + g.fillRect(k == 0 ? bx + cell - fill : bx, y + ch - 4, fill, 4); + g.setFont(num); + String s = String.valueOf(match.score(team)); + g.setColor(Type.WHITE); + g.drawString(s, bx + (cell - num.stringWidth(s)) / 2, y + 1); + } + g.setColor(Type.GUNMETAL); + g.setAlpha(215); + g.fillRect(cx - clockW / 2, y, clockW, ch); + g.setAlpha(255); + g.setFont(clockFont); + g.setColor(secs <= 10 ? Type.HOSTILE : Type.WHITE); + g.drawString(clock, cx - clockFont.stringWidth(clock) / 2, y + (ch - clockFont.getHeight()) / 2); + if (compact) { + return; + } + g.setFont(tiny); + String goal = Type.caps(match.mode().title) + " // " + target; + g.setColor(Type.AMBER); + g.drawString(goal, cx - tiny.stringWidth(goal) / 2, y + ch + 3); + } + + /// Top right, newest at the bottom: who, what, whom, each name in the + /// colour of their side. + private void killFeed(Graphics g, int right, int y) { + if (feedHidden) { + return; + } + long now = System.currentTimeMillis(); + Font name = compact ? tiny : small; + g.setFont(name); + int lh = name.getHeight() + (compact ? 4 : 6); + // a phone shows the newest two, without the excuses + int lines = compact ? 2 : FEED_LINES; + int first = 0; + int live = 0; + for (int i = feed.size() - 1; i >= 0; i--) { + if (now - feed.get(i).at <= FEED_MILLIS && ++live == lines) { + first = i; + break; + } + } + for (int i = first; i < feed.size(); i++) { + FeedLine f = feed.get(i); + long age = now - f.at; + if (age > FEED_MILLIS) { + continue; + } + int alpha = age > FEED_MILLIS - 800 ? (int) (255 * (FEED_MILLIS - age) / 800) : 255; + String verb = " " + f.verb + " "; + String left = compact ? brief(f.left) : f.left; + String right2 = compact ? brief(f.right) : f.right; + int lw = name.stringWidth(left); + int vw = tiny.stringWidth(verb); + int rw = name.stringWidth(right2); + int x = right - lw - vw - rw - 12; + g.setColor(Type.INK); + g.setAlpha(alpha * 150 / 255); + g.fillRect(x - 6, y, lw + vw + rw + 18, lh - 2); + g.setAlpha(alpha); + g.setColor(f.leftColor); + g.drawString(left, x, y + 2); + g.setFont(tiny); + g.setColor(Type.AMBER); + g.drawString(verb, x + lw, y + 2 + (name.getHeight() - tiny.getHeight()) / 2); + g.setFont(name); + g.setColor(f.rightColor); + g.drawString(right2, x + lw + vw, y + 2); + y += lh; + } + g.setAlpha(255); + } + + /// Hides the killfeed while something else, the comms list, is drawn + /// where it goes. + /// + /// #### Parameters + /// + /// - `hidden`: true to hide it + public void setFeedHidden(boolean hidden) { + feedHidden = hidden; + } + + /// Shows a line someone said, under the minimap, for a few seconds. + /// + /// #### Parameters + /// + /// - `from`: who said it + /// + /// - `text`: what they said + public void chat(String from, String text) { + chat.add(from + ": " + text); + chatAt.add(Long.valueOf(System.currentTimeMillis())); + while (chat.size() > 2) { + chat.remove(0); + chatAt.remove(0); + } + } + + private void chatLines(Graphics g, long now, int x, int y, int max) { + g.setFont(tiny); + for (int i = 0; i < chat.size(); i++) { + long age = now - chatAt.get(i).longValue(); + if (age > FEED_MILLIS) { + continue; + } + String line = chat.get(i); + while (line.length() > 4 && tiny.stringWidth(line) > max) { + line = line.substring(0, line.length() - 4) + "..."; + } + int alpha = age > FEED_MILLIS - 800 ? (int) (255 * (FEED_MILLIS - age) / 800) : 255; + g.setColor(Type.INK); + g.setAlpha(alpha * 150 / 255); + g.fillRect(x, y, tiny.stringWidth(line) + 8, tiny.getHeight() + 2); + g.setAlpha(alpha); + g.setColor(Type.WHITE); + g.drawString(line, x + 4, y + 1); + y += tiny.getHeight() + 4; + } + g.setAlpha(255); + } + + /// Stops drawing once the result card is up, so nothing shows through it. + /// + /// #### Parameters + /// + /// - `ended`: true once the match is over and its card is shown + public void setEnded(boolean ended) { + this.ended = ended; + repaint(); + } + + /// A feed name without its parenthesised aside or trailing sentence, for + /// a phone: "Uncle Ray (disputes this)" is "Uncle Ray". + private static String brief(String s) { + int cut = s.indexOf(" ("); + if (cut < 0) { + cut = s.indexOf(" wiping"); + } + return cut > 0 ? s.substring(0, cut) : s; + } + + /// Bottom right: the hopper count big, what is left in the pod after it, + /// a row of pips and the marker's name. + private void ammo(Graphics g, Player p, int right, int bottom) { + boolean reloading = p.reloading(match.tick()); + int mag = p.marker.hopper; + String count = reloading ? "--" : String.valueOf(p.ammo()); + String reserve = " / " + mag; + if (compact) { + // a small dark tab above the fire button, so the count reads over + // a bright sky and over the marker in your hands alike + int cw = big.stringWidth(count); + int rw = small.stringWidth(reserve); + int pad = CN.convertToPixels(1.2f); + int th = big.getHeight() + (p.hasGrenade() ? tiny.getHeight() : 0); + int x = right - cw - rw - pad; + int y = bottom - th - pad; + g.setColor(Type.INK); + g.setAlpha(165); + g.fillRect(x - pad, y - pad / 2, cw + rw + pad * 3, th + pad + pad / 2); + g.setAlpha(255); + g.setColor(Type.AMBER); + g.fillRect(x - pad, y - pad / 2, CN.convertToPixels(0.5f), th + pad + pad / 2); + g.setFont(big); + g.setColor(p.ammo() == 0 || reloading ? Type.HOSTILE : Type.WHITE); + g.drawString(count, x, y); + g.setFont(small); + g.setColor(Type.STEEL); + g.drawString(reserve, x + cw, y + big.getHeight() - small.getHeight() - 2); + if (p.hasGrenade()) { + g.setFont(tiny); + g.setColor(Type.AMBER); + g.drawString("NADE x1", x, y + big.getHeight()); + } + return; + } + g.setFont(huge); + int cw = huge.stringWidth(count); + int rw = medium.stringWidth(reserve); + int y = bottom - huge.getHeight() - small.getHeight() - CN.convertToPixels(2); + int x = right - cw - rw; + g.setColor(0x000000); + g.setAlpha(120); + g.drawString(count, x + 2, y + 2); + g.setAlpha(255); + g.setColor(p.ammo() == 0 || reloading ? Type.HOSTILE : Type.WHITE); + g.drawString(count, x, y); + g.setFont(medium); + g.setColor(Type.STEEL); + g.drawString(reserve, x + cw, y + huge.getHeight() - medium.getHeight() - 4); + // Pips, one per ball, right aligned under the count. + int pip = Math.max(2, CN.convertToPixels(0.5f)); + int shown = Math.min(mag, 40); + int py = y + huge.getHeight() + 2; + for (int i = 0; i < shown; i++) { + boolean full = !reloading && i < p.ammo() * shown / Math.max(1, mag); + g.setColor(full ? Type.WHITE : 0x3a4048); + g.fillRect(right - (i + 1) * (pip + 2), py, pip, pip * 3); + } + g.setFont(small); + String name = Type.caps(p.marker.name) + (p.hasGrenade() ? " [G] NADE x1" : ""); + g.setColor(Type.AMBER); + g.drawString(name, right - small.stringWidth(name), py + pip * 3 + 4); + } + + private void crosshair(Graphics g, long now, int cx, int cy, boolean show) { + if (!show) { + g.setColor(Type.WHITE); + g.setAlpha(200); + g.fillRect(cx - 1, cy - 1, 3, 3); + g.setAlpha(255); + return; + } + int r = CN.convertToPixels(1.4f); + int gap = CN.convertToPixels(0.9f); + g.setColor(Type.WHITE); + g.setAlpha(220); + g.fillRect(cx - r - gap, cy, r, 2); + g.fillRect(cx + gap, cy, r, 2); + g.fillRect(cx, cy - r - gap, 2, r); + g.fillRect(cx, cy + gap, 2, r); + g.setAlpha(255); + } + + /// An alpha from a fade. The event can be stamped a moment after the + /// frame read its clock, so the age may be negative; Graphics rejects + /// anything outside 0..255. + private static int fade(long a) { + return (int) Math.max(0, Math.min(255, a)); + } + + /// The X that says a ball landed: white for a hit, red for a kill. + private void hitMarker(Graphics g, long now, int cx, int cy) { + long age = now - hitAt; + if (age > 260) { + return; + } + int r0 = CN.convertToPixels(1.2f); + int r1 = CN.convertToPixels(hitKill ? 3.2f : 2.4f); + g.setColor(hitKill ? Type.HOSTILE : Type.WHITE); + g.setAlpha(fade(255 * (260 - age) / 260)); + for (int k = 0; k < 3; k++) { + int o = k - 1; + g.drawLine(cx - r1 + o, cy - r1, cx - r0 + o, cy - r0); + g.drawLine(cx + r1 + o, cy - r1, cx + r0 + o, cy - r0); + g.drawLine(cx - r1 + o, cy + r1, cx - r0 + o, cy + r0); + g.drawLine(cx + r1 + o, cy + r1, cx + r0 + o, cy + r0); + } + g.setAlpha(255); + } + + /// A red arc on the ring around the crosshair, on the side the shot came + /// from, turning as you turn. + private void damageArc(Graphics g, long now, int cx, int cy, int r) { + long age = now - arcAt; + if (age > ARC_MILLIS) { + return; + } + Player p = match.player(me); + double toward = com.codename1.util.MathUtil.atan2(-(arcX - p.x()), arcZ - p.z()); + double facing = -p.yaw() * 2 * Math.PI / Trig.FULL; + // 0 is straight ahead (screen up), clockwise positive. + double rel = toward - facing; + int deg = 90 - (int) Math.toDegrees(rel); + g.setColor(Type.HOSTILE); + g.setAlpha(fade(220 * (ARC_MILLIS - age) / ARC_MILLIS)); + for (int k = 0; k < 5; k++) { + g.drawArc(cx - r - k, cy - r - k, (r + k) * 2, (r + k) * 2, deg - 20, 40); + } + g.setAlpha(255); + } + + /// "+100 PAINTED", rising and fading beside the crosshair. + private void xpPop(Graphics g, long now, int x, int y) { + long age = now - popAt; + if (age > POP_MILLIS) { + return; + } + g.setFont(medium); + g.setColor(Type.AMBER); + g.setAlpha(age > POP_MILLIS - 400 ? fade(255 * (POP_MILLIS - age) / 400) : 255); + g.drawString(pop, x, y - (int) (age * CN.convertToPixels(4) / POP_MILLIS)); + g.setAlpha(255); + } + + /// Your squad's names over their heads, projected through the camera. + private void nameTags(Graphics g, int x0, int y0, int w, int h) { + if (view == null) { + return; + } + com.codename1.gaming.GameCamera c = view.getCamera(); + float[] look = com.codename1.gpu.Matrix4.lookAt(c.getEyeX(), c.getEyeY(), c.getEyeZ(), + c.getTargetX(), c.getTargetY(), c.getTargetZ(), 0f, 1f, 0f); + float[] proj = com.codename1.gpu.Matrix4.perspective((float) Math.toRadians(c.getFov()), + w / (float) Math.max(1, h), 0.03f, 400f); + float[] vp = new float[16]; + com.codename1.gpu.Matrix4.multiply(proj, look, vp); + int myTeam = match.player(me).team(); + g.setFont(tiny); + for (int i = 0; i < match.playerCount(); i++) { + Player q = match.player(i); + if (i == me || q.team() != myTeam || !q.alive()) { + continue; + } + float wx = -q.x() * 0.001f; + float wy = q.y() * 0.001f + 2.1f; + float wz = q.z() * 0.001f; + float cw = vp[3] * wx + vp[7] * wy + vp[11] * wz + vp[15]; + if (cw <= 0.1f) { + continue; + } + float nx = (vp[0] * wx + vp[4] * wy + vp[8] * wz + vp[12]) / cw; + float ny = (vp[1] * wx + vp[5] * wy + vp[9] * wz + vp[13]) / cw; + if (nx < -1f || nx > 1f || ny < -1f || ny > 1f) { + continue; + } + int sx = x0 + (int) ((nx + 1f) * 0.5f * w); + int sy = y0 + (int) ((1f - ny) * 0.5f * h); + String n = Type.caps(driver.name(i)); + g.setColor(0x000000); + g.setAlpha(140); + g.drawString(n, sx - tiny.stringWidth(n) / 2 + 1, sy + 1); + g.setAlpha(255); + g.setColor(Type.FRIENDLY); + g.drawString(n, sx - tiny.stringWidth(n) / 2, sy); + } + } + + /// Speech over whoever was just painted and is making a scene about it, + /// for three and a half seconds, live and in the replay alike. + private void bubbles(Graphics g, int x0, int y0, int w, int h) { + if (view == null) { + return; + } + Match m = view.drawnMatch(); + float[] vp = viewProjection(w, h); + for (int i = 0; i < m.playerCount(); i++) { + Player q = m.player(i); + if (q.alive() || q.input().has(com.codenameone.examples.splat.rules.Input.WIPE)) { + continue; + } + int since = m.tick() - q.paintedAt(); + if (since < Match.TICK_HZ / 2 || since > Match.TICK_HZ * 4) { + continue; + } + String line = ArenaView.complaint(i, q.paintedAt()); + if (line == null) { + continue; + } + int[] at = project(vp, -q.x() * 0.001f, q.y() * 0.001f + 2.0f, q.z() * 0.001f, x0, y0, w, h); + if (at == null) { + continue; + } + Font f = compact ? tiny : small; + g.setFont(f); + int tw = f.stringWidth(line); + int pad = CN.convertToPixels(1.2f); + int bx = Math.max(x0 + pad, Math.min(x0 + w - tw - pad * 3, at[0] - tw / 2 - pad)); + int by = at[1] - f.getHeight() - pad * 3; + g.setColor(0xffffff); + g.fillRoundRect(bx, by, tw + pad * 2, f.getHeight() + pad, pad * 2, pad * 2); + int[] tx = {at[0] - pad, at[0] + pad, at[0]}; + int[] ty = {by + f.getHeight() + pad - 1, by + f.getHeight() + pad - 1, by + f.getHeight() + pad * 3}; + g.fillPolygon(tx, ty, 3); + g.setColor(0x111111); + g.drawString(line, bx + pad, by + pad / 2); + } + } + + private float[] viewProjection(int w, int h) { + com.codename1.gaming.GameCamera c = view.getCamera(); + float[] look = com.codename1.gpu.Matrix4.lookAt(c.getEyeX(), c.getEyeY(), c.getEyeZ(), + c.getTargetX(), c.getTargetY(), c.getTargetZ(), 0f, 1f, 0f); + float[] proj = com.codename1.gpu.Matrix4.perspective((float) Math.toRadians(c.getFov()), + w / (float) Math.max(1, h), 0.03f, 400f); + float[] vp = new float[16]; + com.codename1.gpu.Matrix4.multiply(proj, look, vp); + return vp; + } + + /// A world point on the screen, or null when it is behind or off it. + private static int[] project(float[] vp, float wx, float wy, float wz, int x0, int y0, int w, int h) { + float cw = vp[3] * wx + vp[7] * wy + vp[11] * wz + vp[15]; + if (cw <= 0.1f) { + return null; + } + float nx = (vp[0] * wx + vp[4] * wy + vp[8] * wz + vp[12]) / cw; + float ny = (vp[1] * wx + vp[5] * wy + vp[9] * wz + vp[13]) / cw; + if (nx < -1f || nx > 1f || ny < -1f || ny > 1f) { + return null; + } + return new int[] {x0 + (int) ((nx + 1f) * 0.5f * w), y0 + (int) ((1f - ny) * 0.5f * h)}; + } + + /// Text on a dark plate, centred on `cx`. + private void plate(Graphics g, String s, int cx, int y, Font f, int color) { + g.setFont(f); + int tw = f.stringWidth(s); + int pad = CN.convertToPixels(1.5f); + if (tw > getWidth() - pad * 6 && f != narrow) { + // too wide for a phone held upright: fall back to the narrowest size + plate(g, s, cx, y, narrow, color); + return; + } + g.setColor(Type.INK); + g.setAlpha(170); + g.fillRect(cx - tw / 2 - pad, y - pad / 2, tw + pad * 2, f.getHeight() + pad); + g.setAlpha(255); + g.setColor(color); + g.drawString(s, cx - tw / 2, y); + } + + /// The mission briefing: letterboxed, a classified header, the location + /// stamped like a satellite feed, and the orders typed out a line at a + /// time, read in the gravest voice available. + private void briefing(Graphics g, int x0, int y0, int w, int h, int ticks) { + g.setColor(0x07090b); + g.setAlpha(238); + g.fillRect(x0, y0, w, h); + g.setAlpha(255); + int bar = h / 9; + g.fillRect(x0, y0, w, bar); + g.fillRect(x0, y0 + h - bar, w, bar); + Mode mode = match.mode(); + int elapsed = Math.max(0, 3 * Match.TICK_HZ - ticks); + int left = x0 + Math.max(CN.convertToPixels(compact ? 4 : 6), w / 12); + int room = x0 + w - left - Math.max(CN.convertToPixels(4), w / 12); + int y = y0 + bar + CN.convertToPixels(4); + g.setFont(tiny); + g.setColor(Type.HOSTILE); + g.drawString("// CLASSIFIED -- EYES ONLY -- DO NOT SHOW MUM", left, y); + y += tiny.getHeight() + CN.convertToPixels(2); + // a player-built mission brings its own name and orders + Mission custom = Mission.find(match.arena().id); + String[] title = wrap(new String[] {Type.caps(custom != null ? custom.name : mode.operation)}, room, big); + g.setFont(big); + g.setColor(Type.AMBER); + for (int i = 0; i < title.length; i++) { + g.drawString(title[i], left, y); + y += big.getHeight(); + } + g.setFont(small); + g.setColor(Type.STEEL); + Arena a = match.arena(); + String grid = Type.caps(a.name) + " GRID " + (a.width / 100) + "-" + (a.depth / 100) + " 0" + (match.seed() & 7) + + "00 HRS LOCAL"; + String[] gl = wrap(new String[] {grid}, room, small); + for (int i = 0; i < gl.length; i++) { + g.drawString(gl[i], left, y); + y += small.getHeight(); + } + y += CN.convertToPixels(3); + String[] lines = { + "OBJECTIVE: " + Type.caps(mode.title) + ". FIRST TO " + mode.target + ".", + custom != null && custom.briefing.trim().length() > 0 ? Type.caps(custom.briefing.trim()) + : "INTEL SUGGESTS THE ENEMY HAS BROUGHT [REDACTED] AND A COOLER.", + "RULES OF ENGAGEMENT: NO BLIND FIRING. NO WIPING. NO CRYING.", + "YOU ARE " + (driver.interactive() ? Type.caps(ArenaView.TEAM_NAMES[match.player(me).team()]) + : "ON THE SIDELINES") + ". GODSPEED.", + }; + g.setFont(medium); + lines = wrap(lines, room, medium); + int chars = elapsed * 3; + for (int i = 0; i < lines.length && chars > 0; i++) { + String line = lines[i].substring(0, Math.min(lines[i].length(), chars)); + chars -= lines[i].length(); + int at = line.indexOf("[REDACTED]"); + g.setColor(Type.WHITE); + if (at >= 0) { + g.drawString(line.substring(0, at), left, y); + int rx = left + medium.stringWidth(line.substring(0, at)); + g.fillRect(rx, y + 3, medium.stringWidth("[REDACTED]"), medium.getHeight() - 6); + if (line.length() > at + 10) { + g.drawString(line.substring(at + 10), rx + medium.stringWidth("[REDACTED]"), y); + } + } else { + g.drawString(line, left, y); + } + y += medium.getHeight() + 4; + } + g.setFont(huge); + String n = String.valueOf(ticks / Match.TICK_HZ + 1); + g.setColor(Type.AMBER); + g.drawString(n, x0 + w - left + x0 - huge.stringWidth(n), y0 + h - bar - huge.getHeight() - CN.convertToPixels(2)); + } + + /// Breaks each line at spaces so none is wider than `max`; a phone in + /// portrait would otherwise clip the briefing at the right edge. + private static String[] wrap(String[] lines, int max, Font f) { + java.util.ArrayList out = new java.util.ArrayList(); + for (String line : lines) { + String rest = line; + while (f.stringWidth(rest) > max) { + int cut = rest.lastIndexOf(' '); + while (cut > 0 && f.stringWidth(rest.substring(0, cut)) > max) { + cut = rest.lastIndexOf(' ', cut - 1); + } + if (cut <= 0) { + break; + } + out.add(rest.substring(0, cut)); + rest = rest.substring(cut + 1); + } + out.add(rest); + } + return out.toArray(new String[out.size()]); + } + + /// Paint on the goggles: big blobs in the shooter's paint that drip and + /// fade. + private void splatter(Graphics g, long now, int x0, int y0, int w, int h) { + long age = now - splatAt; + if (age > 3500 || match == null) { + return; + } + int alpha = fade(190 * (3500 - age) / 3500); + int color = 0xff7a1f; + Player p = match.player(me); + if (p.paintedBy() >= 0 && p.paintedBy() < match.playerCount()) { + color = ArenaView.TEAM_COLORS[match.player(p.paintedBy()).team()] & 0xffffff; + } + int s = splatSeed * 2654435 + 7; + int unit = Math.min(w, h); + // A few real splats hugging the edges of the visor, running down as + // they age: the middle stays clear so you can see who did it. + for (int i = 0; i < 4; i++) { + s = s * 1103515245 + 12345; + int along = ((s >>> 8) & 0x7fff) % Math.max(1, unit); + int edge = (s >>> 3) & 3; + int bx = edge == 0 ? x0 + unit / 14 : edge == 1 ? x0 + w - unit / 14 : x0 + along * w / Math.max(1, unit); + int by = edge >= 2 ? (edge == 2 ? y0 + h / 7 : y0 + h - h / 7) : y0 + along * h / Math.max(1, unit); + int r = unit / 6 + (i % 3) * unit / 20; + int drip = (int) Math.min(r / 2, age * r / 2600); + g.setAlpha(alpha); + g.drawImage(visorSplat(color, (splatSeed + i) & 3), bx - r, by - r + drip / 2, r * 2, r * 2 + drip); + } + g.setAlpha(255); + } + + private final java.util.Map visorSplats = new java.util.HashMap(); + + /// A dripping splat in `rgb`, one of four shapes, built once and kept. + private Image visorSplat(int rgb, int variant) { + Integer key = Integer.valueOf((rgb << 2) | variant); + Image img = visorSplats.get(key); + if (img == null) { + int[] px = PaintDecals.splat(9001L + variant * 7919L, true); + for (int i = 0; i < px.length; i++) { + px[i] = (px[i] & 0xff000000) | rgb; + } + img = Image.createImage(px, PaintDecals.SIZE, PaintDecals.SIZE); + visorSplats.put(key, img); + } + return img; + } + + /// The stamp: slammed on at twice the size and settling, a little crooked, + /// like a rubber stamp in a hurry. + private void stamp(Graphics g, long now, int x0, int y0, int w, int h) { + long age = now - stampAt; + if (age > STAMP_MILLIS) { + return; + } + float k = age < 160 ? 2f - age / 160f : 1f; + int alpha = age > STAMP_MILLIS - 400 ? fade(255 * (STAMP_MILLIS - age) / 400) : 255; + int tw = stampFont.stringWidth(stampText); + int th = stampFont.getHeight(); + int cx = x0 + w / 2; + // high, so the line under it clears the buttons on a phone + int cy = y0 + h * 36 / 100 - th / 2; + boolean rotate = g.isAffineSupported(); + if (rotate) { + g.rotateRadians(-0.18f, cx, cy); + g.scale(k, k); + } + float inv = rotate ? 1f / k : 1f; + int sx = (int) (cx * inv) - tw / 2; + int sy = (int) (cy * inv) - th / 2; + g.setAlpha(alpha); + g.setColor(Type.STAMP); + int pad = CN.convertToPixels(2); + for (int b = 0; b < 4; b++) { + g.drawRect(sx - pad - b * 2, sy - pad / 2 - b * 2, tw + pad * 2 + b * 4, th + pad + b * 4); + } + g.setFont(stampFont); + g.drawString(stampText, sx, sy); + if (rotate) { + g.resetAffine(); + } + plate(g, stampSub, x0 + w / 2, cy + th + CN.convertToPixels(2), medium, Type.WHITE); + g.setAlpha(255); + } + + private int safeTop() { + if (getComponentForm() == null) { + return 0; + } + com.codename1.ui.geom.Rectangle r = getComponentForm().getSafeArea(); + return r == null ? 0 : Math.max(0, r.getY() - getAbsoluteY()); + } + + private int safeBottom() { + if (getComponentForm() == null) { + return 0; + } + com.codename1.ui.geom.Rectangle r = getComponentForm().getSafeArea(); + if (r == null || r.getHeight() <= 0) { + return 0; + } + return Math.max(0, getAbsoluteY() + getHeight() - (r.getY() + r.getHeight())); + } +} +// end::splat-hud[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Models.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Models.java new file mode 100644 index 00000000000..85ae7a9fa9d --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Models.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.gaming.AnimatedModel; +import com.codename1.gaming.Model; +import com.codename1.gpu.GltfLoader; +import com.codename1.gpu.GltfScene; +import com.codename1.gpu.GraphicsDevice; +import com.codename1.gpu.Material; +import com.codename1.io.Log; +import com.codename1.ui.CN; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; + +/// Every model the field is drawn with, and which one plays what. +/// +/// The operators are MakeHuman characters animated with Quaternius' Universal +/// Animation Library (`tools/make-operators.py`), the markers Kenney's Blaster Kit +/// and the cover Quaternius' Toon Shooter and Kenney's Survival Kit, all CC0, +/// packed into `src/main/resources/model_*.glb` by the scripts in `tools/` (see +/// `ART.md`). Re-skin the game here: swap a name, or drop in any other skinned +/// glTF with the same clip names. +// tag::splat-models[] +public final class Models { + /// The operators each team fields, in slot order. + static final String[][] OPERATORS = { + {"op-swat", "op-worker", "op-farmer", "op-punk", "op-ranger", "op-hoodie"}, + {"op-dad", "op-suit", "op-hoodie", "op-ranger", "op-punk", "op-worker"}, + }; + /// Clips every operator carries (`tools/make-operators.py` retargets them + /// from Quaternius' Universal Animation Library onto the MakeHuman rig). + static final String IDLE = "Idle"; + /// Standing, marker up and aimed: the pose a player holds. + static final String AIM = "Aim"; + /// The aim pose pitched up and down, mixed into the spine by look pitch. + static final String AIM_UP = "AimUp"; + static final String AIM_DOWN = "AimDown"; + static final String WALK = "Walk"; + static final String RUN = "Run"; + static final String CROUCH = "CrouchIdle"; + static final String CROUCH_WALK = "CrouchWalk"; + static final String DEATH = "Death"; + static final String HIT = "HitChest"; + static final String HIT2 = "Knockback"; + static final String ROLL = "Roll"; + /// Shaking the head: "it was a bounce", and the paint wiped off. + static final String DISPUTE = "No"; + static final String DANCE = "Dance"; + /// The bone a marker is held by, the head, and where the aim layer starts. + static final String HAND = "hand_r"; + static final String HEAD = "head"; + static final String SPINE = "spine_01"; + + private static final Map SCENES = new HashMap(); + + private Models() { + } + + /// The operator for the `slot`th player of `team`. + static String operator(int team, int slot) { + String[] list = OPERATORS[team & 1]; + return list[Math.abs(slot) % list.length]; + } + + /// The model of marker `id` (`pump`, `semi`, `ramp`). + static String marker(String id) { + return "mk-" + id; + } + + /// The parsed `/model_.glb`, loaded once. + public static synchronized GltfScene scene(String name) { + GltfScene s = SCENES.get(name); + if (s == null) { + InputStream in = CN.getResourceAsStream("/model_" + name + ".glb"); + if (in == null) { + throw new IllegalArgumentException("No model " + name); + } + try { + s = GltfLoader.loadScene(in); + } catch (java.io.IOException e) { + Log.e(e); + throw new IllegalStateException("Could not read model " + name); + } finally { + try { + in.close(); + } catch (java.io.IOException ignored) { + // read already + } + } + SCENES.put(name, s); + } + return s; + } + + /// A new instance of `name`. + static AnimatedModel create(GraphicsDevice d, String name) { + AnimatedModel m = new AnimatedModel(d, scene(name)); + m.setMaterialType(Material.Type.PBR); + return m; + } + + /// The bounds of the current pose, node transforms applied: + /// `minX, minY, minZ, maxX, maxY, maxZ`. + static float[] bounds(AnimatedModel m) { + m.pose(); + float[] b = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE, + -Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE}; + Model[] parts = m.getParts(); + for (int p = 0; p < parts.length; p++) { + if (!parts[p].isVisible()) { + continue; + } + float[] d = parts[p].getMesh().getVertices().getData(); + for (int i = 0; i + 2 < d.length; i += 8) { + for (int k = 0; k < 3; k++) { + b[k] = Math.min(b[k], d[i + k]); + b[k + 3] = Math.max(b[k + 3], d[i + k]); + } + } + } + if (b[0] > b[3]) { + return new float[] {-0.5f, 0f, -0.5f, 0.5f, 1f, 0.5f}; + } + return b; + } +} +// end::splat-models[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Music.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Music.java new file mode 100644 index 00000000000..d8f3e5da4a4 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Music.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.media.Media; +import com.codename1.media.MediaManager; +import com.codename1.ui.CN; +import java.io.ByteArrayInputStream; + +/// The menu theme and the sound of the field, both rendered by [Synth] and +/// looped through a [Media] player: a long clip is a media player's job on +/// every platform, where a sound pool is for short effects. +/// +/// The theme is a march that takes itself far too seriously, which is the +/// game's whole joke. On the field there is no music, only wind, birds and the +/// pop of markers on the next field over. +/// +/// Both are built on a background thread the first time they are wanted, so a +/// slow phone starts no later for them. +public final class Music { + private static final int MENU = 1; + private static final int FIELD = 2; + private static final int MENU_VOLUME = 45; + private static final int FIELD_VOLUME = 70; + + private static byte[] menu; + private static byte[] field; + private static boolean building; + private static boolean muted; + private static int wanted; + private static int playing; + private static Media media; + /// Set once a player ended almost as soon as it started. A player that + /// cannot read its clip reports completion at once, and looping on that + /// built a new player several times a second until the simulator's + /// browser engine crashed; a player that failed once stays quiet. + private static boolean broken; + /// A clip shorter than this never loops: both run for many seconds. + private static final long SHORTEST_LOOP = 2000; + + private Music() { + } + + public static void setMuted(boolean m) { + muted = m; + if (m) { + halt(); + } else { + apply(); + } + } + + public static boolean isMuted() { + return muted; + } + + /// The theme, for the screens around a match. + public static void menu() { + want(MENU); + } + + /// The field's ambience, for a match. + public static void field() { + want(FIELD); + } + + /// Silence, for a stinger that should be heard alone. + public static void stop() { + want(0); + } + + private static void want(final int what) { + // A GameView's update can run on the renderer's thread (the AWT one in + // the simulator), and creating a Media there waits for the EDT while + // the EDT waits on AWT to blit: a deadlock. Media is the EDT's. + if (!CN.isEdt()) { + CN.callSerially(() -> want(what)); + return; + } + wanted = what; + apply(); + } + + private static void apply() { + if (muted || broken || wanted == playing && media != null) { + return; + } + halt(); + if (wanted == 0) { + return; + } + if (menu == null || field == null) { + build(); + return; + } + final int what = wanted; + final byte[] wav = what == MENU ? menu : field; + try { + final Media[] self = new Media[1]; + final long started = System.currentTimeMillis(); + self[0] = MediaManager.createMedia(new ByteArrayInputStream(wav), "audio/wav", () -> CN.callSerially(() -> { + // At the end, go round again: the clips are written to loop. + // halt() releases the finished player before the next starts. + if (media == self[0] && playing == what) { + if (System.currentTimeMillis() - started < SHORTEST_LOOP) { + broken = true; + halt(); + return; + } + playing = 0; + apply(); + } + })); + media = self[0]; + playing = what; + media.setVolume(what == MENU ? MENU_VOLUME : FIELD_VOLUME); + media.play(); + } catch (Throwable unavailable) { + // No media player (a CI runner without audio): stay quiet. + media = null; + playing = 0; + } + } + + private static void halt() { + Media m = media; + media = null; + playing = 0; + if (m != null) { + try { + m.cleanup(); + } catch (Throwable ignored) { + // already gone + } + } + } + + private static void build() { + if (building) { + return; + } + building = true; + new Thread(() -> { + byte[] m = null; + byte[] f = null; + try { + m = theme().wav(); + f = ambience().wav(); + } catch (Throwable outOfMemory) { + // Leave both null: the next screen that wants music tries again. + } + final byte[] menuWav = m; + final byte[] fieldWav = f; + CN.callSerially(() -> { + building = false; + if (menuWav != null && fieldWav != null) { + menu = menuWav; + field = fieldWav; + apply(); + } + }); + }, "splat-music").start(); + } + + // ------------------------------------------------------------ the theme + + private static final double BEAT = 60.0 / 104; + private static final int BARS = 8; + private static final double TAIL = 0.6; + + /// The bass root of each bar, in semitones from A 440. + private static final int[] ROOTS = {-31, -31, -35, -33, -31, -31, -35, -36}; + + /// Each bar's chord, as three notes in semitones from A 440. + private static final int[][] CHORDS = { + {-7, -4, 0}, {-7, -4, 0}, {-11, -7, -4}, {-9, -5, -2}, + {-7, -4, 0}, {-7, -4, 0}, {-11, -7, -4}, {-12, -8, -5}, + }; + + /// The tune of the second half: start beat, length in beats, note. + private static final double[][] TUNE = { + {16, 1, 0}, {17, 1, 5}, {18, 0.5, 7}, {18.5, 0.5, 8}, {19, 1, 7}, + {20, 1.5, 5}, {21.5, 0.5, 3}, {22, 2, 5}, + {24, 1, 1}, {25, 1, 5}, {26, 0.5, 8}, {26.5, 0.5, 10}, {27, 1, 8}, + {28, 1, 7}, {29, 1, 4}, {30, 2, 7}, + }; + + /// "Operation Weekend": eight bars of a very serious march. + static Synth theme() { + Synth s = new Synth(BARS * 4 * BEAT + TAIL, 104); + for (int bar = 0; bar < BARS; bar++) { + double b0 = bar * 4 * BEAT; + // the drums: kick on one and three, snare on two and four, hats in eighths + for (int beat = 0; beat < 4; beat++) { + double t = b0 + beat * BEAT; + if (beat % 2 == 0) { + s.tone(t, 0.14, 110, 45, Synth.SINE, 0.5, 0.002, 0.045); + } else { + snare(s, t, 0.2); + } + s.noise(t, 0.03, 0.05, 11000, 11000, 6000, 0, 0.01); + s.noise(t + BEAT / 2, 0.03, 0.035, 11000, 11000, 6000, 0, 0.01); + } + if (bar % 4 == 3) { + // the march roll into the next phrase + for (int i = 0; i < 8; i++) { + snare(s, b0 + 3 * BEAT + i * BEAT / 8, 0.05 + i * 0.012); + } + } + // the bass, in eighths between the root and its octave + double root = Synth.note(ROOTS[bar]); + for (int i = 0; i < 8; i++) { + double f = i % 2 == 0 ? root : root * 2; + s.tone(b0 + i * BEAT / 2, BEAT / 2, f, f, Synth.TRIANGLE, 0.22, 0.004, 0.14); + s.tone(b0 + i * BEAT / 2, BEAT / 2, f, f, Synth.SQUARE, 0.05, 0.004, 0.1, 0, 0, 900); + } + // a brass stab on the downbeat, and again on three + for (int n : CHORDS[bar]) { + brass(s, b0, 0.42, Synth.note(n), 0.055); + brass(s, b0 + 2 * BEAT, 0.25, Synth.note(n), 0.04); + } + if (bar < 4) { + // the first half: plucked chord tones, a step up each eighth + for (int i = 0; i < 8; i++) { + double f = Synth.note(CHORDS[bar][i % 3] + 12); + s.tone(b0 + i * BEAT / 2, 0.16, f, f, Synth.TRIANGLE, 0.07, 0.002, 0.05); + } + } + } + // the second half: the tune + for (double[] n : TUNE) { + double f = Synth.note((int) n[2] + 12); + double len = n[1] * BEAT; + s.tone(n[0] * BEAT, len * 0.95, f, f, Synth.SQUARE, 0.07, 0.01, len, 5, len > BEAT ? 0.006 : 0, 2200); + s.tone(n[0] * BEAT, len * 0.95, f / 2, f / 2, Synth.SAW, 0.05, 0.01, len, 5, 0, 1500); + } + return s.loop(TAIL); + } + + private static void snare(Synth s, double t, double amp) { + s.noise(t, 0.16, amp, 7000, 4000, 1200, 0.001, 0.05); + s.tone(t, 0.08, 200, 160, Synth.TRIANGLE, amp * 0.8, 0.001, 0.03); + } + + private static void brass(Synth s, double at, double len, double f, double amp) { + for (int d = -1; d <= 1; d++) { + double g = f * (1 + d * 0.005); + s.tone(at, len, g, g, Synth.SAW, amp, 0.015, len * 0.6, 5, 0.003, 2400); + } + } + + // ------------------------------------------------------------ the field + + private static final double FIELD_SECONDS = 14; + + /// Wind over an empty lot, birds, and somebody else's match next door. + static Synth ambience() { + Synth s = new Synth(FIELD_SECONDS + 1.5, 7); + // wind: long gusts that overlap, each a slow sweep of filtered noise + for (double t = -1; t < FIELD_SECONDS + 1.5; t += 1.8) { + double from = 220 + s.random() * 380; + double to = 220 + s.random() * 380; + s.noise(Math.max(0, t), 3.4, 0.2 + s.random() * 0.1, from, to, 60, 1.2, 1.4); + } + // birds: a few chirps in twos and threes + for (int i = 0; i < 7; i++) { + double at = s.random() * FIELD_SECONDS; + double base = 2400 + s.random() * 1200; + int chirps = 2 + (int) (s.random() * 2); + for (int c = 0; c < chirps; c++) { + s.tone(at + c * 0.11, 0.06, base, base * 1.35, Synth.SINE, 0.08, 0.005, 0.02); + } + } + // the next field over: distant pops, a whistle once + for (int i = 0; i < 12; i++) { + double at = s.random() * FIELD_SECONDS; + s.tone(at, 0.08, 120, 60, Synth.SINE, 0.09, 0.002, 0.025); + s.noise(at, 0.1, 0.03, 900, 400, 150, 0.001, 0.03); + } + double w = 3 + s.random() * 8; + s.tone(w, 0.4, 2850, 2800, Synth.SINE, 0.025, 0.02, 0, 31, 0.035, 0); + return s.loop(1.5); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OnlineDriver.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OnlineDriver.java new file mode 100644 index 00000000000..9959f9bf13b --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OnlineDriver.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.net.GameLink; +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.Event; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Slot; +import com.codenameone.examples.splat.rules.Text; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// A match the server is the authority on. +/// +/// Every tick the player's input goes to the server with a sequence number and +/// is applied here at once, so the marker fires and the legs move with no wait. +/// Every few ticks a snapshot arrives with the newest sequence number the server +/// used: the match is put back exactly where the server says it is, and the +/// inputs the server has not seen yet are played over it again. When the guess +/// was right nothing moves; when it was wrong -- someone else's ball got there +/// first -- the correction is one snapshot old and small. +/// +/// Other players run on the last input the server knew for them, which is what +/// the server itself does until a newer one arrives. +// tag::splat-prediction[] +public final class OnlineDriver extends Driver { + public interface Listener { + void emote(String from, String id); + + void chat(String from, String text); + + void ended(int[] places, int[] scores, int trophies, int coins, String matchId); + + /// The match is gone for this player: removed by the ref, or the + /// connection to the server dropped. `why` is said to the player. + void lost(String why); + } + + /// The most inputs the phone runs ahead of the last snapshot. Past this the + /// server has gone quiet, and guessing further only makes the snap back worse. + static final int MAX_AHEAD = 20; + + private GameLink link; + private int reconnects; + private final boolean spectator; + private Listener listener; + private String matchId; + private String level; + private long startsAt; + private int seq; + private int serverTick; + private final List pendingSeq = new ArrayList(); + private final List pending = new ArrayList(); + private Input[] frame = new Input[0]; + + public OnlineDriver(GameLink link, Map start, boolean spectator) { + this.link = link; + this.spectator = spectator; + restart(start); + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + public String matchId() { + return matchId; + } + + public String level() { + return level; + } + + private void restart(Map m) { + matchId = GameLink.text(m, "match"); + level = GameLink.text(m, "level"); + me = spectator ? 0 : GameLink.number(m, "you"); + List slots = new ArrayList(); + Object list = m.get("slots"); + if (list instanceof List) { + for (Object o : (List) list) { + slots.add(Slot.decode(String.valueOf(o))); + } + } + names = new String[slots.size()]; + for (int i = 0; i < names.length; i++) { + names[i] = slots.get(i).name; + } + int colon = level.indexOf(':'); + match = new Match(Long.parseLong(GameLink.text(m, "seed")), Catalog.arena(level.substring(0, colon)), + Mode.byName(level.substring(colon + 1)), slots); + frame = new Input[slots.size()]; + pending.clear(); + pendingSeq.clear(); + String snap = GameLink.text(m, "snap"); + if (snap.length() > 0) { + match.restore(ints(snap)); + } + serverTick = match.tick(); + startsAt = System.currentTimeMillis() + GameLink.number(m, "startIn"); + } + + private long lastSnapAt; + + public void message(Map m) { + String type = GameLink.text(m, "type"); + if (GameProtocol.SNAP.equals(type)) { + lastSnapAt = System.currentTimeMillis(); + snap(GameLink.number(m, "t"), GameLink.number(m, "ack"), GameLink.text(m, "s"), GameLink.text(m, "e")); + } else if (GameProtocol.START.equals(type)) { + restart(m); + } else if (listener == null) { + return; + } else if (GameProtocol.EMOTE.equals(type)) { + listener.emote(GameLink.text(m, "from"), GameLink.text(m, "id")); + } else if (GameProtocol.CHAT.equals(type)) { + listener.chat(GameLink.text(m, "from"), GameLink.text(m, "text")); + } else if (GameProtocol.END.equals(type)) { + if (matchId != null && !matchId.equals(GameLink.text(m, "match"))) { + // the end of a match this socket has already left + return; + } + listener.ended(ints(GameLink.text(m, "places")), ints(GameLink.text(m, "scores")), + GameLink.number(m, "trophies"), GameLink.number(m, "coins"), GameLink.text(m, "match")); + } else if (GameProtocol.KICKED.equals(type)) { + listener.lost("The ref removed you from the field."); + } + } + + /// Reconciliation: back to the server's state, then the unacknowledged + /// inputs again on top of it. + private void snap(int tick, int ack, String state, String events) { + if (state.length() == 0 || tick < serverTick) { + return; + } + serverTick = tick; + match.restore(ints(state)); + while (!pendingSeq.isEmpty() && pendingSeq.get(0)[0] <= ack) { + pendingSeq.remove(0); + pending.remove(0); + } + for (int i = 0; i < pending.size() && !match.over(); i++) { + stepWith(pending.get(i)); + } + // The server's events are the ones that count: a PAINTED the phone + // guessed and the server did not agree with is never shown. + List items = Text.split(events, ';'); + for (int i = 0; i < items.size(); i++) { + int[] f = ints(items.get(i)); + if (f.length == 7) { + emit(new Event(f[0], f[1], f[2], f[3], f[4], f[5], f[6])); + } + } + } + + /// Where the link stands, for a test that has to say why a match stalled. + public String describe() { + return "server tick " + serverTick + " (" + (lastSnapAt == 0 ? "no snapshot" + : (System.currentTimeMillis() - lastSnapAt) + " ms ago") + "), " + pending.size() + + " inputs unacknowledged, countdown " + countdownTicks() + ", " + reconnects + " reconnects, " + + link.describe(); + } + + @Override + public boolean canStep() { + return countdownTicks() == 0 && !match.over() && (spectator || pending.size() < MAX_AHEAD); + } + + @Override + public void step(Input in) { + if (spectator) { + stepWith(null); + return; + } + // The tick the player is looking at is the newest snapshot, which is what + // the server rewinds a shot to. + Input stamped = new Input(in.strafe, in.forward, in.yaw, in.pitch, in.buttons, serverTick); + seq++; + link.send("{\"type\":\"" + GameProtocol.INPUT + "\",\"s\":" + seq + ",\"i\":\"" + stamped.encode() + "\"}"); + pendingSeq.add(new int[] {seq}); + pending.add(stamped); + stepWith(stamped); + // Your own shot is heard at once; everything else waits for the server. + List happened = match.events(); + for (int i = 0; i < happened.size(); i++) { + Event e = happened.get(i); + if (e.kind == Event.SHOT && e.a == me) { + emit(e); + } + } + } + + private void stepWith(Input in) { + for (int i = 0; i < frame.length; i++) { + frame[i] = null; + } + if (in != null && me < frame.length) { + frame[me] = in; + } + match.step(frame); + } + + @Override + public boolean interactive() { + return !spectator; + } + + @Override + public int countdownTicks() { + long left = startsAt - System.currentTimeMillis(); + return left <= 0 ? 0 : (int) (left * Match.TICK_HZ / 1000) + 1; + } + + public void emote(String id) { + link.send("{\"type\":\"" + GameProtocol.EMOTE + "\",\"id\":\"" + id + "\"}"); + } + + public void leave() { + link.send("{\"type\":\"" + GameProtocol.LEAVE + "\"}"); + } + + /// Carries on over a new socket after the old one dropped. The server + /// seats it in the same place and answers with the whole match so far, + /// which arrives as a start and rebuilds the field from it. + public void relink(GameLink fresh) { + link = fresh; + reconnects++; + } + + /// How many times the match had to carry on over a new socket. + public int reconnects() { + return reconnects; + } + + public GameLink link() { + return link; + } + + /// Tells the match screen the match is gone, when the lobby that owns the + /// socket learns it first. + public void lost(String why) { + if (listener != null) { + listener.lost(why); + } + } + + @Override + public void dispose() { + link.close(); + } + + static int[] ints(String csv) { + if (csv == null || csv.length() == 0) { + return new int[0]; + } + List f = Text.split(csv, ','); + int[] out = new int[f.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = f.get(i).length() == 0 ? 0 : Integer.parseInt(f.get(i)); + } + return out; + } +} +// end::splat-prediction[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OperatorStage.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OperatorStage.java new file mode 100644 index 00000000000..ff95e93bf31 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/OperatorStage.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.gaming.AnimatedModel; +import com.codename1.gaming.GameView; +import com.codename1.gaming.Model; +import com.codename1.gpu.GraphicsDevice; +import com.codename1.gpu.Material; +import com.codename1.gpu.PostProcess; +import com.codename1.gpu.Primitives; + +/// An operator standing in a pool of light in a dark storage yard, marker in +/// hand, shifting their weight: the backdrop of the main menu and the loadout. +/// The menu is laid over the left of it, so the operator stands right of +/// centre. +// tag::splat-operator-stage[] +public class OperatorStage extends GameView { + private final String operator; + private String marker; + private AnimatedModel op; + private AnimatedModel gun; + private float scale; + private final Grip grip = new Grip(); + private final float[] turn = new float[16]; + private GraphicsDevice device; + private float time; + private final float offset; + + /// `operator` is a model name from [Models]; `marker` a marker id, or null + /// for empty hands. + public OperatorStage(String operator, String marker, float offset) { + this.operator = operator; + this.marker = marker; + this.offset = offset; + setName("operatorStage"); + } + + /// The operator the menu shows for a player's chosen look: the same + /// player always gets the same one. + public static String operatorFor(String look) { + int h = look == null ? 0 : look.hashCode(); + return Models.operator(0, (h & 0x7fffffff) % Models.OPERATORS[0].length); + } + + /// Swaps the marker in the operator's hand. + public void setMarker(String id) { + marker = id; + if (device != null && gun != null) { + gun.removeFrom(this); + gun = null; + } + if (device != null && id != null) { + gun = Models.create(device, Models.marker(id)); + float[] b = Models.bounds(gun); + gun.setScale(0.34f / Math.max(0.01f, Math.max(b[3] - b[0], Math.max(b[4] - b[1], b[5] - b[2])))); + gun.addTo(this); + if (op != null) { + holdMarker(); + } + } + } + + private static final int DUSK = 0xff3a3f4a; + + @Override + protected void onSetup(GraphicsDevice d) { + device = d; + setClearColor(0xff0d1014); + getLight().setDirection(0.55f, -0.8f, -0.45f).setColor(0xffffd2a0).setAmbientColor(0xff4a525c) + .setShadows(true).setShadowMap(1024, 6f).setShadowCenter(0f, 0f, -1f); + // the arena's atmosphere at dusk: the same engine look as a match, + // kept dark enough that the menu over it always reads + getEnvironment().setFog(DUSK, 0.03f, 0.08f).setHemisphere(0xff55657a, 0xff2a2620) + .setToneMapping(true).setExposure(1.05f) + .setSky(0xff141d2c, DUSK, 0xffff9a50, 0.35f); + setPostProcess(new PostProcess().setBloom(0.8f, 0.45f) + .setGrade(1.1f, 0.85f, 0xff0a121a, 0xfffff0dc).setVignette(0.55f).setGrain(0.03f)); + getCamera().setPerspective(34f, 0.1f, 400f); + Model sky = new Model(Primitives.sphere(d, 300f, 16, 24, true), new Material(Material.Type.SKYBOX)); + sky.getMaterial().getRenderState().setDepthWrite(false); + sky.setPosition(0f, 1.35f, 5.2f); + addModel(sky); + getCamera().setPosition(0f, 1.35f, 5.2f); + getCamera().setTarget(offset * 0.55f, 1.0f, 0f); + Model floor = new Model(Primitives.quad(d, 1f), new Material(Material.Type.LAMBERT).setColor(0xff24282d)); + floor.setRotation(-90f, 0f, 0f); + floor.setScale(40f, 40f, 1f); + addModel(floor); + prop("container", 2.13f, -1.2f, -3.4f, 8f); + prop("container-small", 2.13f, 3.4f, -3.0f, -14f); + prop("sandbags", 1.0f, -2.6f, -0.6f, 25f); + prop("barrel", 1.0f, 2.2f, -1.2f, 0f); + prop("streetlight", 7f, 3.8f, -4.5f, 200f); + op = Models.create(d, operator); + float[] b = Models.bounds(op); + scale = 1.8f / Math.max(0.01f, b[4] - b[1]); + op.setScale(scale); + op.setPosition(offset, 0f, 0f); + op.setRotation(0f, -18f, 0f); + op.play(Models.IDLE, true); + op.addTo(this); + setMarker(marker); + // pose now: the first frame can be drawn (and captured) before the + // first update, and the rig's rest pose is an A with arms out + op.update(0f); + holdMarker(); + } + + private void prop(String name, float height, float x, float z, float deg) { + AnimatedModel m = Models.create(device, name); + float[] b = Models.bounds(m); + float s = height / Math.max(0.01f, b[4] - b[1]); + m.setScale(s); + m.setRotation(0f, deg, 0f); + m.setPosition(x, -b[1] * s, z); + m.addTo(this); + } + + @Override + protected void update(double dt) { + if (op == null) { + return; + } + time += (float) dt; + op.update((float) dt); + // A slow drift of the camera, so the stage never looks like a still. + getCamera().setPosition((float) Math.sin(time * 0.25f) * 0.25f, 1.35f, 5.2f); + holdMarker(); + } + + private void holdMarker() { + if (gun != null) { + float[] t = turn; + double r = Math.toRadians(op.getRotationY()); + t[0] = (float) Math.cos(r); + t[2] = (float) -Math.sin(r); + t[8] = (float) Math.sin(r); + t[10] = (float) Math.cos(r); + t[5] = 1f; + t[15] = 1f; + grip.place(op, scale, t, op.getX(), op.getY(), op.getZ(), gun, null); + } + } +} +// end::splat-operator-stage[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/PaintDecals.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/PaintDecals.java new file mode 100644 index 00000000000..9e532128c88 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/PaintDecals.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import java.util.Random; + +/// The shapes paint leaves behind, drawn once at start-up into textures: a +/// splat is an irregular blob with lobes thrown out from where the ball burst, +/// a scatter of droplets around it and, on a wall, runs of paint dripping down. +/// +/// Each texture is white with the shape in its alpha, so one set serves both +/// teams: the material's colour tints it. +final class PaintDecals { + static final int SIZE = 128; + /// How many different shapes there are of each kind, so no two splats in + /// a row look alike. + static final int VARIANTS = 4; + + private PaintDecals() { + } + + /// One splat as ARGB pixels. `drips` adds runs toward the bottom of the + /// texture, which a decal on a wall points down. + static int[] splat(long seed, boolean drips) { + Random r = new Random(seed); + float[] a = new float[SIZE * SIZE]; + float c = SIZE / 2f; + // the body: a radius that wobbles with the angle, plus a few long lobes + int lobes = 5 + r.nextInt(4); + float[] lobeAt = new float[lobes]; + float[] lobeLen = new float[lobes]; + for (int i = 0; i < lobes; i++) { + lobeAt[i] = (float) (r.nextDouble() * Math.PI * 2); + lobeLen[i] = 0.15f + (float) r.nextDouble() * 0.35f; + } + float p1 = (float) (r.nextDouble() * 6.28); + float p2 = (float) (r.nextDouble() * 6.28); + float base = SIZE * 0.24f; + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) { + float dx = x - c; + float dy = y - c; + float d = (float) Math.sqrt(dx * dx + dy * dy); + float ang = atan2(dy, dx); + float rad = base * (1 + 0.12f * (float) Math.sin(ang * 5 + p1) + 0.07f * (float) Math.sin(ang * 11 + p2)); + for (int i = 0; i < lobes; i++) { + float diff = angleDiff(ang, lobeAt[i]); + // a narrow lobe that tapers to a round end + float w = 0.22f; + if (diff < w) { + float k = 1 - diff / w; + rad = Math.max(rad, base * (1 + lobeLen[i] * 1.6f * k * k)); + } + } + a[y * SIZE + x] = edge(rad - d); + } + } + // droplets flung outward, bigger the closer in + int drops = 10 + r.nextInt(10); + for (int i = 0; i < drops; i++) { + float ang = (float) (r.nextDouble() * Math.PI * 2); + float dist = base * (1.3f + (float) r.nextDouble() * 1.2f); + float size = SIZE * (0.012f + (float) r.nextDouble() * 0.03f) * (2.6f * base / (dist + base)); + disc(a, c + (float) Math.cos(ang) * dist, c + (float) Math.sin(ang) * dist, size); + } + if (drips) { + int runs = 2 + r.nextInt(3); + for (int i = 0; i < runs; i++) { + float x = c + (float) (r.nextDouble() - 0.5) * base * 1.4f; + float len = SIZE * (0.15f + (float) r.nextDouble() * 0.3f); + float w = SIZE * (0.012f + (float) r.nextDouble() * 0.012f); + float start = c + base * 0.5f; + for (float y = start; y < Math.min(SIZE - 4, start + len); y += 0.5f) { + disc(a, x, y, w); + } + // a bead where the run stops + disc(a, x, Math.min(SIZE - 6, start + len), w * 1.6f); + } + } + int[] argb = new int[SIZE * SIZE]; + for (int i = 0; i < argb.length; i++) { + int al = (int) (Math.max(0, Math.min(1, a[i])) * 255); + // a touch darker at the rim, where paint pools as it dries + int shade = 255 - (int) (40 * Math.max(0, Math.min(1, 1 - a[i]) * Math.min(1, a[i] * 2))); + argb[i] = (al << 24) | (shade << 16) | (shade << 8) | shade; + } + return argb; + } + + private static float edge(float inside) { + return Math.max(0, Math.min(1, inside * 0.7f + 0.5f)); + } + + private static void disc(float[] a, float cx, float cy, float rad) { + int x0 = Math.max(0, (int) (cx - rad - 2)); + int x1 = Math.min(SIZE - 1, (int) (cx + rad + 2)); + int y0 = Math.max(0, (int) (cy - rad - 2)); + int y1 = Math.min(SIZE - 1, (int) (cy + rad + 2)); + for (int y = y0; y <= y1; y++) { + for (int x = x0; x <= x1; x++) { + float dx = x - cx; + float dy = y - cy; + float v = edge(rad - (float) Math.sqrt(dx * dx + dy * dy)); + int i = y * SIZE + x; + if (v > a[i]) { + a[i] = v; + } + } + } + } + + private static float angleDiff(float a, float b) { + float d = Math.abs(a - b) % (float) (Math.PI * 2); + return d > Math.PI ? (float) (Math.PI * 2) - d : d; + } + + /// atan2 without java.lang.Math.atan2, which a device's class library lacks. + private static float atan2(float y, float x) { + return (float) com.codename1.util.MathUtil.atan2(y, x); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/PracticeDriver.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/PracticeDriver.java new file mode 100644 index 00000000000..5d1334ea3a4 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/PracticeDriver.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Slot; +import java.util.ArrayList; +import java.util.List; + +/// Offline against the rules' own bots: no server, no account, works on a plane. +/// The bots are the ones the server fills a quick match with, so practice is +/// honest about what online feels like. +public final class PracticeDriver extends Driver { + /// The weekend league's regulars. Original characters, all of them sure they + /// were not hit. + static final String[] BOTS = { + "Gary (Tactical)", "Kevin From Accounts", "Sgt. Barbecue", "Linda, Team Mom", + "Big Doug", "The Intern", "Coach Pete", "Uncle Ray", "Brenda (Ref In Training)", + "Dale, Still Arguing", "Todd With A Drone", "Marcy Ninja", + }; + private static final long BRIEFING_MILLIS = 3000; + + private final Input[] frame; + private final long startsAt = System.currentTimeMillis() + BRIEFING_MILLIS; + + public PracticeDriver(String arena, Mode mode, String marker, int players, int skill, long seed) { + List slots = new ArrayList(); + names = new String[players]; + for (int i = 0; i < players; i++) { + String name = i == 0 ? "You" : BOTS[(int) (((seed & 0xffffff) + i) % BOTS.length)]; + String m = i == 0 ? marker : Catalog.MARKERS[(int) (((seed & 0xffffff) / 7 + i) % Catalog.MARKERS.length)].id; + // Your own team is a notch worse, so carrying it is the job. + int s = i == 0 ? 0 : i % 2 == 0 ? Math.max(10, skill - 10) : skill; + slots.add(new Slot(name, i % 2, m, s)); + names[i] = name; + } + match = new Match(seed, Catalog.arena(arena), mode, slots); + frame = new Input[players]; + me = 0; + } + + @Override + public boolean canStep() { + return countdownTicks() == 0 && !match.over(); + } + + @Override + public void step(Input in) { + frame[0] = in; + match.step(frame); + emit(match.events()); + } + + @Override + public int countdownTicks() { + long left = startsAt - System.currentTimeMillis(); + return left <= 0 ? 0 : (int) (left * Match.TICK_HZ / 1000) + 1; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ReplayDriver.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ReplayDriver.java new file mode 100644 index 00000000000..6bfd140ffe0 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/ReplayDriver.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Replay; +import com.codenameone.examples.splat.rules.Slot; + +/// A recorded match, simulated again from its seed and its human inputs. The +/// bots are not in the file at all: they think inside the rules, so they make the +/// same choices again. The camera can follow anyone. +public final class ReplayDriver extends Driver { + private final Replay replay; + private Replay.Cursor cursor; + private int speed = 100; + private boolean paused; + + public ReplayDriver(Replay replay, int follow) { + this.replay = replay; + names = new String[replay.slots().size()]; + for (int i = 0; i < names.length; i++) { + Slot s = replay.slots().get(i); + names[i] = s.name; + } + restart(); + me = Math.max(0, Math.min(follow, names.length - 1)); + } + + /// Back to the first tick. + public void restart() { + cursor = replay.cursor(); + match = cursor.match(); + } + + public void follow(int player) { + me = ((player % names.length) + names.length) % names.length; + } + + public void setSpeedPercent(int percent) { + speed = percent; + } + + public void setPaused(boolean paused) { + this.paused = paused; + } + + public boolean paused() { + return paused; + } + + /// Jumps ahead `ticks` without drawing them. + public void skip(int ticks) { + for (int i = 0; i < ticks && cursor.step(); i++) { + match.events(); + } + } + + @Override + public boolean canStep() { + return !paused && !match.over(); + } + + @Override + public void step(Input ignored) { + if (cursor.step()) { + emit(match.events()); + } + } + + @Override + public boolean interactive() { + return false; + } + + @Override + public int speedPercent() { + return paused ? 0 : speed; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Sfx.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Sfx.java new file mode 100644 index 00000000000..f3de190ea7a --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Sfx.java @@ -0,0 +1,485 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.gaming.SoundEffect; +import com.codename1.gaming.SoundPool; +import java.io.ByteArrayInputStream; + +/// The match's sound effects, synthesized at start-up by [Synth] and played +/// through a [SoundPool], which mixes overlapping voices with low latency where +/// the platform has a native mixer. +/// +/// Sounds from elsewhere on the field take a volume and a pan from where they +/// happened (`ArenaView.place`), so a firefight to your left is heard on the +/// left and a far one barely at all. +public final class Sfx { + private static SoundPool pool; + private static SoundEffect fire; + private static SoundEffect[] splats; + private static SoundEffect hit; + private static SoundEffect painted; + private static SoundEffect whistle; + private static SoundEffect whistleTwice; + private static SoundEffect grenade; + private static SoundEffect toss; + private static SoundEffect reload; + private static SoundEffect[] steps; + private static SoundEffect land; + private static SoundEffect flagTaken; + private static SoundEffect flagLost; + private static SoundEffect capture; + private static SoundEffect point; + private static SoundEffect beep; + private static SoundEffect horn; + private static SoundEffect wipe; + private static SoundEffect buzzer; + private static SoundEffect boing; + private static SoundEffect win; + private static SoundEffect lose; + private static SoundEffect click; + private static boolean muted; + private static long lastFire; + private static int step; + + private Sfx() { + } + + public static void setMuted(boolean m) { + muted = m; + if (m && pool != null) { + pool.stopAll(); + } + } + + public static boolean isMuted() { + return muted; + } + + /// Builds and loads every clip; quietly does nothing where audio is + /// unavailable. + /// + /// Only a native mixer is used. Without one, a pool falls back to a media + /// player per voice, which a marker firing ten times a second would drown + /// in, so the game stays silent instead (a CI runner with no audio device). + public static void load() { + if (pool != null) { + return; + } + try { + SoundPool created = SoundPool.create(16); + if (!created.isNativeAccelerated()) { + created.release(); + return; + } + pool = created; + fire = clip(makeFire()); + splats = new SoundEffect[] {clip(makeSplat(1, 1)), clip(makeSplat(2, 1)), clip(makeSplat(3, 1))}; + hit = clip(makeHit()); + painted = clip(makePainted()); + whistle = clip(makeWhistle(false)); + whistleTwice = clip(makeWhistle(true)); + grenade = clip(makeGrenade()); + toss = clip(makeToss()); + reload = clip(makeReload()); + steps = new SoundEffect[] {clip(makeStep(1)), clip(makeStep(2)), clip(makeStep(3)), clip(makeStep(4))}; + land = clip(makeLand()); + flagTaken = clip(makeArpeggio(new int[] {15, 19, 22}, 0.08, 0.3, Synth.SQUARE)); + flagLost = clip(makeArpeggio(new int[] {10, 6, 3}, 0.1, 0.4, Synth.SQUARE)); + capture = clip(makeCapture()); + point = clip(makePoint()); + beep = clip(makeBeep()); + horn = clip(makeHorn()); + wipe = clip(makeWipe()); + buzzer = clip(makeBuzzer()); + boing = clip(makeBoing()); + win = clip(makeWin()); + lose = clip(makeLose()); + click = clip(makeClick()); + } catch (Throwable unavailable) { + pool = null; + } + } + + private static SoundEffect clip(Synth s) throws java.io.IOException { + return pool.load(new ByteArrayInputStream(s.wav()), "audio/wav"); + } + + // ------------------------------------------------------------ playing + + /// Your own marker. A ramping marker fires every third tick; one shot + /// sound per 60 ms is plenty. + public static void fire() { + long now = System.currentTimeMillis(); + if (now - lastFire > 60) { + lastFire = now; + play(fire, 0.9f, 0, 0.94f + (float) Math.random() * 0.12f); + } + } + + /// Somebody else's marker. + public static void fireAt(float volume, float pan) { + play(fire, volume * 0.6f, pan, 0.85f + (float) Math.random() * 0.1f); + } + + public static void splat(float volume, float pan) { + play(any(splats), volume, pan, 0.9f + (float) Math.random() * 0.2f); + } + + /// The tick that says your ball landed on somebody. + public static void hit() { + play(hit, 0.9f, 0, 1); + } + + /// You were the somebody. + public static void painted() { + play(painted, 1, 0, 1); + } + + /// The referee. Every PAINTED, and every wipe the referee catches. + public static void whistle(float volume, float pan) { + play(whistle, volume, pan, 0.97f + (float) Math.random() * 0.06f); + } + + /// The two blasts that start and end a match. + public static void whistleTwice() { + play(whistleTwice, 1, 0, 1); + } + + public static void grenade(float volume, float pan) { + play(grenade, volume, pan, 1); + } + + public static void toss() { + play(toss, 0.8f, 0, 1); + } + + public static void reload() { + play(reload, 0.8f, 0, 1); + } + + /// One footstep; the four takes go round so a run never sounds like a loop. + public static void step(float volume) { + if (steps == null) { + return; + } + step = (step + 1 + (int) (Math.random() * 2)) % steps.length; + play(steps[step], volume, 0, 0.92f + (float) Math.random() * 0.16f); + } + + public static void land() { + play(land, 0.8f, 0, 1); + } + + /// A flag was picked up: bright if it is theirs, a warning if it is ours. + public static void flag(boolean good) { + play(good ? flagTaken : flagLost, 0.8f, 0, 1); + } + + public static void capture(boolean ours) { + play(ours ? capture : flagLost, 0.9f, 0, ours ? 1 : 0.8f); + } + + public static void point(boolean ours) { + play(point, 0.6f, 0, ours ? 1 : 0.8f); + } + + public static void beep() { + play(beep, 0.7f, 0, 1); + } + + /// The air horn at the start, the way every weekend league opens. + public static void horn() { + play(horn, 0.9f, 0, 1); + } + + public static void wipe() { + play(wipe, 0.8f, 0, 1); + } + + public static void buzzer() { + play(buzzer, 0.8f, 0, 1); + } + + /// Hit by your own team. + public static void boing() { + play(boing, 0.8f, 0, 1); + } + + public static void win() { + play(win, 0.9f, 0, 1); + } + + public static void lose() { + play(lose, 0.9f, 0, 1); + } + + public static void click() { + play(click, 0.6f, 0, 1); + } + + /// One of `clips` at random, or null while there is no mixer: `load` + /// leaves every clip unset on a device without one. + private static SoundEffect any(SoundEffect[] clips) { + return clips == null ? null : clips[(int) (Math.random() * clips.length)]; + } + + private static void play(SoundEffect e, float volume, float pan, float rate) { + if (!muted && pool != null && e != null && volume > 0.02f) { + try { + pool.play(e, Math.min(1, volume), Math.max(-1, Math.min(1, pan)), rate, 0); + } catch (Throwable ignored) { + // Sound is decoration; a failure must never stop a match. + } + } + } + + // ------------------------------------------------------------ the sounds + + /// A paintball marker: a soft low thump of air and the hiss of the barrel. + private static Synth makeFire() { + Synth s = new Synth(0.16, 1); + s.tone(0, 0.09, 150, 55, Synth.SINE, 0.55, 0.002, 0.03); + s.noise(0, 0.14, 0.4, 3200, 700, 300, 0.001, 0.035); + s.tone(0, 0.01, 1800, 1500, Synth.SQUARE, 0.08, 0, 0.004); + return s; + } + + /// A ball bursting: a wet slap, a thud and a drip. + private static Synth makeSplat(long seed, double size) { + Synth s = new Synth(0.3, seed * 31); + s.noise(0, 0.24, 0.6 * size, 2600, 350, 0, 0.001, 0.05); + s.tone(0, 0.1, 95, 50, Synth.SINE, 0.4 * size, 0.001, 0.03); + double drip = 0.05 + s.random() * 0.04; + s.tone(drip, 0.05, 950 + s.random() * 300, 500, Synth.SINE, 0.12, 0.002, 0.018); + return s; + } + + /// The hit marker: one dry, bright tick. + private static Synth makeHit() { + Synth s = new Synth(0.07, 2); + s.tone(0, 0.05, 2500, 2300, Synth.SINE, 0.32, 0.001, 0.012); + s.noise(0, 0.02, 0.15, 9000, 9000, 3000, 0, 0.006); + return s; + } + + /// Hit: a big splat right on you, and the sinking feeling that comes with it. + private static Synth makePainted() { + Synth s = new Synth(0.5, 7); + s.noise(0, 0.32, 0.75, 3000, 300, 0, 0.001, 0.07); + s.tone(0, 0.3, 75, 38, Synth.SINE, 0.6, 0.002, 0.09); + s.tone(0.12, 0.3, 330, 165, Synth.TRIANGLE, 0.12, 0.01, 0.12); + return s; + } + + /// A referee's pea whistle: the pea rattles the pitch thirty times a second. + private static Synth makeWhistle(boolean twice) { + Synth s = new Synth(twice ? 0.85 : 0.5, 3); + if (twice) { + blast(s, 0, 0.17); + blast(s, 0.27, 0.55); + } else { + blast(s, 0, 0.48); + } + return s; + } + + private static void blast(Synth s, double at, double len) { + s.tone(at, len, 2850, 2800, Synth.SINE, 0.22, 0.015, 0, 31, 0.035, 0); + s.tone(at, len, 5700, 5600, Synth.SINE, 0.03, 0.015, 0, 31, 0.035, 0); + s.noise(at, len, 0.07, 7000, 7000, 2000, 0.01, 0); + } + + /// A paint grenade: a pop, a boom and paint landing everywhere. + private static Synth makeGrenade() { + Synth s = new Synth(0.9, 4); + s.tone(0, 0.5, 85, 32, Synth.SINE, 0.75, 0.002, 0.13); + s.noise(0, 0.7, 0.7, 2000, 180, 0, 0.002, 0.14); + for (int i = 0; i < 6; i++) { + double at = 0.08 + s.random() * 0.35; + s.noise(at, 0.12, 0.25, 2400, 500, 0, 0.001, 0.03); + } + return s; + } + + /// The whoosh of a throw. + private static Synth makeToss() { + Synth s = new Synth(0.32, 5); + s.noise(0, 0.3, 0.35, 400, 2600, 250, 0.18, 0.06); + return s; + } + + /// Balls rattling down from the hopper, and the bolt closing. + private static Synth makeReload() { + Synth s = new Synth(0.6, 6); + for (int i = 0; i < 9; i++) { + double at = 0.02 + s.random() * 0.42; + s.noise(at, 0.014, 0.3, 6500, 6500, 1400, 0, 0.004); + s.tone(at, 0.02, 700 + s.random() * 400, 500, Synth.SINE, 0.08, 0, 0.006); + } + s.tone(0.5, 0.05, 520, 380, Synth.SQUARE, 0.14, 0, 0.012, 0, 0, 2500); + s.noise(0.5, 0.04, 0.25, 5000, 2000, 600, 0, 0.01); + return s; + } + + /// A step on gravel and asphalt. + private static Synth makeStep(long seed) { + Synth s = new Synth(0.11, 40 + seed); + s.noise(0, 0.08, 0.45, 1000 + s.random() * 400, 450, 120, 0.004, 0.022); + s.tone(0, 0.05, 125, 80, Synth.SINE, 0.12, 0.002, 0.015); + return s; + } + + private static Synth makeLand() { + Synth s = new Synth(0.18, 8); + s.tone(0, 0.14, 105, 48, Synth.SINE, 0.45, 0.002, 0.04); + s.noise(0, 0.12, 0.3, 700, 300, 80, 0.002, 0.03); + return s; + } + + /// Notes one after another, semitones above A 440. + private static Synth makeArpeggio(int[] semis, double each, double last, int wave) { + Synth s = new Synth(each * (semis.length - 1) + last + 0.05, 9); + for (int i = 0; i < semis.length; i++) { + double f = Synth.note(semis[i]); + double len = i == semis.length - 1 ? last : each + 0.02; + s.tone(i * each, len, f, f, wave, 0.2, 0.005, len * 0.5, 0, 0, 3500); + } + return s; + } + + /// A capture: a little brass fanfare and a snare. + private static Synth makeCapture() { + Synth s = new Synth(1.0, 10); + int[] notes = {3, 7, 10, 15}; + for (int i = 0; i < notes.length; i++) { + double f = Synth.note(notes[i]); + double len = i == notes.length - 1 ? 0.6 : 0.13; + for (int d = -1; d <= 1; d++) { + s.tone(i * 0.11, len, f * (1 + d * 0.004), f * (1 + d * 0.004), Synth.SAW, 0.09, 0.012, len * 0.7, + 5, i == notes.length - 1 ? 0.006 : 0, 2600); + } + s.noise(i * 0.11, 0.08, 0.15, 8000, 3000, 1200, 0, 0.025); + } + return s; + } + + /// A snack bar point: two soft bell notes. + private static Synth makePoint() { + Synth s = new Synth(0.5, 11); + s.tone(0, 0.45, 1320, 1320, Synth.SINE, 0.18, 0.002, 0.12); + s.tone(0.09, 0.4, 1760, 1760, Synth.SINE, 0.15, 0.002, 0.12); + return s; + } + + private static Synth makeBeep() { + Synth s = new Synth(0.16, 12); + s.tone(0, 0.14, 880, 880, Synth.SQUARE, 0.18, 0.003, 0, 0, 0, 3000); + return s; + } + + /// An air horn: three detuned reeds, a dip in pitch as the can opens. + private static Synth makeHorn() { + Synth s = new Synth(1.0, 13); + double[] f = {349, 440, 523}; + for (int i = 0; i < f.length; i++) { + s.tone(0, 0.08, f[i] * 0.93, f[i], Synth.SAW, 0.12, 0.01, 0, 0, 0, 3200); + s.tone(0.08, 0.85, f[i], f[i] * 0.99, Synth.SAW, 0.12, 0, 0, 6, 0.004, 3200); + } + return s; + } + + /// Rubbing the paint off a sleeve, quickly, while nobody looks. + private static Synth makeWipe() { + Synth s = new Synth(0.55, 14); + for (int i = 0; i < 5; i++) { + s.noise(i * 0.1, 0.08, 0.5, 1400, 800, 300, 0.03, 0.03); + } + return s; + } + + /// Caught. The buzzer of a game show nobody wanted to be on. + private static Synth makeBuzzer() { + Synth s = new Synth(0.5, 15); + s.tone(0, 0.45, 110, 105, Synth.SQUARE, 0.13, 0.005, 0, 0, 0, 1600); + s.tone(0, 0.45, 117, 112, Synth.SQUARE, 0.13, 0.005, 0, 0, 0, 1600); + return s; + } + + private static Synth makeBoing() { + Synth s = new Synth(0.4, 16); + s.tone(0, 0.36, 180, 420, Synth.TRIANGLE, 0.3, 0.005, 0.15, 11, 0.06, 0); + return s; + } + + /// Victory: a brass run up to a held chord, a roll and a cymbal. + private static Synth makeWin() { + Synth s = new Synth(2.6, 17); + int[] run = {-7, -3, 0, 5}; + for (int i = 0; i < run.length; i++) { + brass(s, i * 0.14, 0.16, Synth.note(run[i]), 0.1); + } + int[] chord = {5, 9, 12, 17}; + for (int n : chord) { + brass(s, 0.56, 1.8, Synth.note(n), 0.07); + } + for (int i = 0; i < 10; i++) { + s.noise(0.06 * i, 0.05, 0.08 + i * 0.012, 7000, 3000, 1500, 0, 0.02); + } + s.noise(0.56, 2.0, 0.22, 11000, 7000, 4000, 0.002, 0.5); + s.tone(0.56, 0.4, 80, 45, Synth.SINE, 0.4, 0.002, 0.1); + return s; + } + + /// Defeat: four notes on a sad trombone, the last one wobbling. + private static Synth makeLose() { + Synth s = new Synth(2.4, 18); + int[] notes = {-11, -12, -13, -14}; + for (int i = 0; i < notes.length; i++) { + double f = Synth.note(notes[i]); + boolean last = i == notes.length - 1; + double len = last ? 1.3 : 0.36; + s.tone(i * 0.42, len, f * 1.02, f, Synth.SAW, 0.16, 0.06, last ? 0.9 : 0.4, last ? 5.5 : 0, + last ? 0.025 : 0, 1100); + s.tone(i * 0.42, len, f * 1.02, f, Synth.TRIANGLE, 0.14, 0.06, last ? 0.9 : 0.4, last ? 5.5 : 0, + last ? 0.025 : 0, 0); + } + return s; + } + + private static void brass(Synth s, double at, double len, double f, double amp) { + for (int d = -1; d <= 1; d++) { + s.tone(at, len, f * (1 + d * 0.005), f * (1 + d * 0.005), Synth.SAW, amp, 0.02, len * 0.8, 5, 0.004, + 2400); + } + } + + private static Synth makeClick() { + Synth s = new Synth(0.04, 19); + s.tone(0, 0.025, 1600, 1300, Synth.SINE, 0.25, 0.001, 0.006); + s.noise(0, 0.012, 0.12, 9000, 9000, 2500, 0, 0.004); + return s; + } + + /// The menu music and the field's ambience come from [Music]; the stingers + /// are effects, so they stop with the rest when sound is turned off. + static boolean ready() { + return pool != null && !muted; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Synth.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Synth.java new file mode 100644 index 00000000000..2a09d193661 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Synth.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.util.MathUtil; +import java.util.Random; + +/// A tiny synthesizer: every sound and every bar of music in the game is +/// rendered by it at start-up, into mono 16-bit WAV, so the sample ships no +/// audio files and nothing in it needs a licence. +/// +/// A clip is a buffer of the given length; [#tone] and [#noise] add a voice to +/// it, each with its own envelope (a linear attack, an exponential decay and a +/// short release so nothing clicks). [#wav] encodes the result. +/// +/// To use recorded sounds instead, load files into the `SoundPool` in [Sfx] +/// and drop the calls that build them here. +final class Synth { + /// Low enough to keep the clips small, high enough for a whistle. + static final int RATE = 22050; + + static final int SINE = 0; + static final int SQUARE = 1; + static final int SAW = 2; + static final int TRIANGLE = 3; + + private final float[] buf; + private final Random rnd; + + Synth(double seconds, long seed) { + buf = new float[Math.max(1, (int) (seconds * RATE))]; + rnd = new Random(seed); + } + + double random() { + return rnd.nextDouble(); + } + + double seconds() { + return (double) buf.length / RATE; + } + + /// Adds a pitched voice. + /// + /// - `start`, `dur`: where and how long, in seconds + /// - `f0`, `f1`: the pitch at the start and at the end, in Hz (a slide) + /// - `wave`: `SINE`, `SQUARE`, `SAW` or `TRIANGLE` + /// - `amp`: the peak level, 0..1 + /// - `attack`: seconds to full level + /// - `decay`: the time constant of the fall after it; 0 holds the level + /// - `vibHz`, `vibDepth`: a vibrato, as a rate and a fraction of the pitch + /// - `lowpass`: a cutoff in Hz that softens a bright wave; 0 for none + void tone(double start, double dur, double f0, double f1, int wave, double amp, double attack, + double decay, double vibHz, double vibDepth, double lowpass) { + int from = (int) (start * RATE); + int n = (int) (dur * RATE); + double phase = rnd.nextDouble(); + double lp = 0; + double a = lowpass > 0 ? alpha(lowpass) : 1; + for (int i = 0; i < n && from + i < buf.length; i++) { + if (from + i < 0) { + continue; + } + double t = (double) i / RATE; + double hz = f0 + (f1 - f0) * i / n; + if (vibDepth > 0) { + hz *= 1 + vibDepth * Math.sin(2 * Math.PI * vibHz * t); + } + phase += hz / RATE; + double p = phase - Math.floor(phase); + double s; + switch (wave) { + case SQUARE: + s = p < 0.5 ? 1 : -1; + break; + case SAW: + s = 2 * p - 1; + break; + case TRIANGLE: + s = p < 0.5 ? 4 * p - 1 : 3 - 4 * p; + break; + default: + s = Math.sin(2 * Math.PI * p); + break; + } + lp += a * (s - lp); + buf[from + i] += (float) (lp * amp * envelope(i, n, attack, decay)); + } + } + + /// The common case of [#tone]: no vibrato and no filter. + void tone(double start, double dur, double f0, double f1, int wave, double amp, double attack, double decay) { + tone(start, dur, f0, f1, wave, amp, attack, decay, 0, 0, 0); + } + + /// Adds filtered white noise: the breath of a whistle, the burst of a + /// paintball, wind. `lowFrom`/`lowTo` sweep the low-pass cutoff over the + /// voice; `high` is a fixed high-pass cutoff (0 for none). + void noise(double start, double dur, double amp, double lowFrom, double lowTo, double high, double attack, + double decay) { + int from = (int) (start * RATE); + int n = (int) (dur * RATE); + double lp = 0; + double hpIn = 0; + double hpOut = 0; + double ha = high > 0 ? MathUtil.exp(-2 * Math.PI * high / RATE) : 0; + for (int i = 0; i < n && from + i < buf.length; i++) { + if (from + i < 0) { + continue; + } + double cutoff = lowFrom + (lowTo - lowFrom) * i / n; + double w = rnd.nextDouble() * 2 - 1; + lp += alpha(cutoff) * (w - lp); + double s = lp; + if (high > 0) { + hpOut = ha * (hpOut + s - hpIn); + hpIn = s; + s = hpOut; + } + buf[from + i] += (float) (s * amp * envelope(i, n, attack, decay)); + } + } + + /// The level at sample `i` of `n`. + private static double envelope(int i, int n, double attack, double decay) { + double t = (double) i / RATE; + double e; + if (attack > 0 && t < attack) { + e = t / attack; + } else { + e = decay > 0 ? MathUtil.exp(-(t - attack) / decay) : 1; + } + // 4 ms of release, so a voice cut short never clicks + int tail = Math.min(n, RATE / 250); + if (i > n - tail) { + e *= (double) (n - i) / tail; + } + return e; + } + + private static double alpha(double cutoff) { + double c = Math.max(20, Math.min(RATE / 2.2, cutoff)); + return 1 - MathUtil.exp(-2 * Math.PI * c / RATE); + } + + /// Fades the last `seconds` into the start, so the clip loops without a + /// seam. The buffer is shortened by that much. + Synth loop(double seconds) { + int x = (int) (seconds * RATE); + int len = buf.length - x; + for (int i = 0; i < x; i++) { + double k = (double) i / x; + buf[i] = (float) (buf[i] * k + buf[len + i] * (1 - k)); + } + Synth out = new Synth((double) len / RATE, 0); + System.arraycopy(buf, 0, out.buf, 0, len); + return out; + } + + /// A mono 16-bit WAV of the buffer, clipped softly at full scale. + byte[] wav() { + int n = buf.length; + byte[] out = new byte[44 + n * 2]; + put(out, 0, "RIFF"); + le32(out, 4, 36 + n * 2); + put(out, 8, "WAVE"); + put(out, 12, "fmt "); + le32(out, 16, 16); + le16(out, 20, 1); + le16(out, 22, 1); + le32(out, 24, RATE); + le32(out, 28, RATE * 2); + le16(out, 32, 2); + le16(out, 34, 16); + put(out, 36, "data"); + le32(out, 40, n * 2); + for (int i = 0; i < n; i++) { + double v = buf[i]; + // a gentle knee instead of a hard clip when voices pile up + v = v > 0.8 ? 0.8 + (v - 0.8) / (1 + (v - 0.8) * 5) : v < -0.8 ? -0.8 + (v + 0.8) / (1 - (v + 0.8) * 5) : v; + int s = (int) (Math.max(-1, Math.min(1, v)) * 32767); + out[44 + i * 2] = (byte) (s & 0xff); + out[45 + i * 2] = (byte) ((s >> 8) & 0xff); + } + return out; + } + + private static void put(byte[] b, int at, String s) { + for (int i = 0; i < 4; i++) { + b[at + i] = (byte) s.charAt(i); + } + } + + private static void le32(byte[] b, int at, int v) { + b[at] = (byte) (v & 0xff); + b[at + 1] = (byte) ((v >> 8) & 0xff); + b[at + 2] = (byte) ((v >> 16) & 0xff); + b[at + 3] = (byte) ((v >> 24) & 0xff); + } + + private static void le16(byte[] b, int at, int v) { + b[at] = (byte) (v & 0xff); + b[at + 1] = (byte) ((v >> 8) & 0xff); + } + + /// The frequency of a note, `semis` semitones above A 440. + static double note(int semis) { + return 440 * MathUtil.pow(2, semis / 12.0); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Type.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Type.java new file mode 100644 index 00000000000..de2d10dcc95 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Type.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.ui.CN; +import com.codename1.ui.Font; + +/// The game's type and colours: Rajdhani (SIL Open Font License, see +/// `ART.md`) in capitals, over near-black and gunmetal, with one amber accent. +/// Friendly is always blue and hostile always red, whatever colour a team's +/// paint is. +public final class Type { + public static final int AMBER = 0xf2b632; + public static final int FRIENDLY = 0x4aa8ff; + public static final int HOSTILE = 0xf0443a; + public static final int INK = 0x0b0d10; + public static final int GUNMETAL = 0x1c2127; + public static final int STEEL = 0x8a949e; + public static final int WHITE = 0xf2f4f5; + public static final int STAMP = 0xe0141e; + + private static Font boldBase; + private static Font mediumBase; + + private Type() { + } + + /// Rajdhani Bold at `mm` millimetres. + public static Font bold(float mm) { + if (boldBase == null) { + boldBase = load("Rajdhani-Bold"); + } + return boldBase.derive(pixels(mm), Font.STYLE_BOLD); + } + + /// Rajdhani Medium at `mm` millimetres. + public static Font medium(float mm) { + if (mediumBase == null) { + mediumBase = load("Rajdhani-Medium"); + } + return mediumBase.derive(pixels(mm), Font.STYLE_PLAIN); + } + + /// A monitor reports far fewer pixels to the millimetre than a phone and is + /// read from further away, so the HUD's small print -- the kill feed, the + /// marker name -- came out at eight pixels. On a desktop each size has a + /// floor that keeps it readable at arm's length. + private static int pixels(float mm) { + int px = CN.convertToPixels(mm); + if (CN.isDesktop()) { + px = Math.max(px, Math.round(mm * 5.5f)); + } + return px; + } + + private static Font load(String name) { + if (Font.isTrueTypeFileSupported()) { + try { + return Font.createTrueTypeFont(name, name + ".ttf"); + } catch (RuntimeException e) { + com.codename1.io.Log.e(e); + } + } + return Font.createTrueTypeFont("native:MainBold", "native:MainBold"); + } + + /// `s` in capitals; the rules' names and operation titles are ASCII. + public static String caps(String s) { + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + b.append(c >= 'a' && c <= 'z' ? (char) (c - 32) : c); + } + return b.toString(); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Voice.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Voice.java new file mode 100644 index 00000000000..60c771e1c18 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/play/Voice.java @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.play; + +import com.codename1.io.Log; +import com.codename1.webrtc.IceCandidate; +import com.codename1.webrtc.IceServer; +import com.codename1.webrtc.LocalMedia; +import com.codename1.webrtc.MediaTrack; +import com.codename1.webrtc.PeerConnection; +import com.codename1.webrtc.PeerConnectionConfig; +import com.codename1.webrtc.SessionDescription; +import com.codename1.webrtc.WebRtc; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.net.GameLink; +import com.codenameone.examples.splat.rules.Json; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Party voice: a mesh of [PeerConnection]s, one per other member, audio +/// only. A party is at most [GameProtocol#MAX_RUNNERS], so each device sends +/// its voice a handful of times at most and no media server is needed. +/// +/// The game's own socket carries the signaling. A `voice` message names who +/// it is for, and the server relays it to that member only if both are in +/// the same party or race; the text inside -- an offer, an answer or an ICE +/// candidate -- is opaque to it. Of each pair the member with the smaller +/// username offers, so two members never call each other at once. +/// +/// Muting disables the local track and tells the party, so every screen can +/// show who is listening. The same instance carries over from the lobby into +/// the race, and is closed with the socket. +// tag::splat-voice[] +public final class Voice implements PeerConnection.Listener { + /// Who is talking, for the lobby to draw. + public interface Listener { + void peers(String[] connected); + + void muted(String username, boolean on); + } + + private static Voice current; + private final GameLink link; + private final String me; + private final Map peers = new HashMap(); + private final Map owners = new HashMap(); + private final List early = new ArrayList(); + private String[] members = new String[0]; + private LocalMedia media; + private boolean muted; + private boolean closed; + private Listener listener; + + private Voice(GameLink link, String me) { + this.link = link; + this.me = me; + } + + /// Whether this device can do voice at all. + public static boolean available() { + return WebRtc.isSupported(); + } + + /// Starts voice over `link` for the signed-in `username`, replacing any + /// earlier call. Asks for the microphone; nothing is sent until it is + /// granted. + public static Voice start(GameLink link, String username) { + stop(); + final Voice v = new Voice(link, username); + current = v; + WebRtc.openUserMedia(true, false, false).ready(m -> { + if (v.closed) { + m.release(); + return; + } + v.media = m; + v.connect(); + for (String[] s : v.early) { + v.signal(s[0], s[1]); + } + v.early.clear(); + }).except(err -> Log.p("Voice unavailable: " + err)); + return v; + } + + /// The call in progress, or null. + public static Voice current() { + return current; + } + + public static void stop() { + if (current != null) { + current.close(); + current = null; + } + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + /// The party or race now has `usernames`; empty entries are bots. + public void members(String[] usernames) { + members = usernames == null ? new String[0] : usernames; + if (media != null) { + connect(); + } + } + + private void connect() { + List keep = new ArrayList(); + for (String u : members) { + if (u == null || u.length() == 0 || u.equals(me)) { + continue; + } + keep.add(u); + if (!peers.containsKey(u) && me.compareTo(u) < 0) { + offer(u); + } + } + for (String u : new ArrayList(peers.keySet())) { + if (!keep.contains(u)) { + PeerConnection pc = peers.remove(u); + owners.remove(pc); + pc.close(); + } + } + notifyPeers(); + } + + private PeerConnection peer(String u) { + PeerConnection pc = peers.get(u); + if (pc == null) { + PeerConnectionConfig cfg = new PeerConnectionConfig() + .addIceServer(IceServer.stun("stun:stun.l.google.com:19302")); + pc = WebRtc.createPeerConnection(cfg, this); + MediaTrack audio = media.getAudio(); + if (audio != null) { + audio.setEnabled(!muted); + pc.addTrack(audio); + } + peers.put(u, pc); + owners.put(pc, u); + } + return pc; + } + + private void offer(final String u) { + final PeerConnection pc = peer(u); + pc.createOffer().ready(o -> pc.setLocalDescription(o).ready(ok -> send(u, "o\n" + o.getSdp()))); + } + + /// A `voice` message from `from`. + public void signal(String from, String data) { + if (closed || from == null || data == null || data.length() < 2) { + return; + } + if (media == null) { + early.add(new String[] {from, data}); + return; + } + char kind = data.charAt(0); + String body = data.substring(2); + if (kind == 'o') { + final PeerConnection pc = peer(from); + final String to = from; + pc.setRemoteDescription(new SessionDescription(SessionDescription.OFFER, body)).ready( + ok -> pc.createAnswer().ready(a -> pc.setLocalDescription(a).ready( + done -> send(to, "a\n" + a.getSdp())))); + } else if (kind == 'a') { + PeerConnection pc = peers.get(from); + if (pc != null) { + pc.setRemoteDescription(new SessionDescription(SessionDescription.ANSWER, body)); + } + } else if (kind == 'c') { + PeerConnection pc = peers.get(from); + int a = body.indexOf('\n'); + int b = a < 0 ? -1 : body.indexOf('\n', a + 1); + if (pc != null && b > a) { + pc.addIceCandidate(new IceCandidate(body.substring(0, a), + Integer.parseInt(body.substring(a + 1, b)), body.substring(b + 1))); + } + } + } + + /// A `mute` message: `username` turned their microphone on or off. + public void mutedBy(String username, boolean on) { + if (listener != null) { + listener.muted(username, on); + } + } + + public void setMuted(boolean m) { + muted = m; + if (media != null && media.getAudio() != null) { + media.getAudio().setEnabled(!m); + } + link.send("{\"type\":\"" + GameProtocol.MUTE + "\",\"on\":" + m + "}"); + } + + public boolean isMuted() { + return muted; + } + + private void send(String to, String data) { + if (!closed) { + link.send("{\"type\":\"" + GameProtocol.VOICE + "\",\"to\":" + Json.write(to) + ",\"data\":" + + Json.write(data) + "}"); + } + } + + public void close() { + closed = true; + for (PeerConnection pc : peers.values()) { + pc.close(); + } + peers.clear(); + owners.clear(); + if (media != null) { + media.release(); + media = null; + } + } + + @Override + public void iceCandidate(PeerConnection connection, IceCandidate c) { + String u = owners.get(connection); + if (u != null) { + send(u, "c\n" + c.getSdpMid() + "\n" + c.getSdpMLineIndex() + "\n" + c.getCandidate()); + } + } + + @Override + public void stateChanged(PeerConnection connection, String state) { + notifyPeers(); + } + + @Override + public void remoteTrack(PeerConnection connection, MediaTrack track) { + // Remote audio plays on its own once the track arrives. + WebRtc.setSpeakerphone(true); + } + + private void notifyPeers() { + if (listener == null) { + return; + } + List on = new ArrayList(); + for (Map.Entry e : peers.entrySet()) { + if (PeerConnection.CONNECTED.equals(e.getValue().getState())) { + on.add(e.getKey()); + } + } + listener.peers(on.toArray(new String[on.size()])); + } +} +// end::splat-voice[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/AchievementsForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/AchievementsForm.java new file mode 100644 index 00000000000..ccc9355b02c --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/AchievementsForm.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.splat.api.AchievementDto; +import com.codenameone.examples.splat.api.ProgressDto; + +/// Every achievement, unlocked or still to go, with how far along each is. +public final class AchievementsForm { + private AchievementsForm() { + } + + public static void show() { + final Form f = Ui.form("Trophy room", "achievements"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + final Container col = Ui.column(); + f.add(Ui.centeredColumn(col)); + Rewards.refresh(() -> { + ProgressDto p = Rewards.progress(); + if (p == null) { + col.add(Ui.label("Could not load your progress.", "Empty")); + } else { + int done = 0; + for (AchievementDto a : p.achievements()) { + done += a.unlocked() ? 1 : 0; + } + col.add(Ui.label(done + " of " + p.achievements().size() + " unlocked", "LeagueName")); + for (AchievementDto a : p.achievements()) { + col.add(row(a)); + } + } + f.revalidate(); + }); + f.show(); + } + + private static Container row(AchievementDto a) { + Container c = new Container(new BorderLayout()); + c.setUIID(a.unlocked() ? "AchievementRow" : "AchievementLocked"); + c.setName("achievement-" + a.id()); + Label icon = new Label("", "Container"); + icon.setIcon(FontImage.createMaterial(a.unlocked() ? FontImage.MATERIAL_MILITARY_TECH : FontImage.MATERIAL_LOCK, + a.unlocked() ? "RewardAchievement" : "XpText", 7)); + c.add(BorderLayout.WEST, icon); + Container text = new Container(BoxLayout.y()); + text.add(new Label(a.title(), "QuestTitle")); + text.add(new Label(a.detail(), "XpText")); + Container bar = new Container(new BorderLayout()); + bar.add(BorderLayout.CENTER, Ui.bar(a.progress(), a.goal(), a.unlocked() ? 0xffd34d : 0x7a849e)); + bar.add(BorderLayout.EAST, new Label(" " + a.progress() + "/" + a.goal(), "XpText")); + text.add(bar); + c.add(BorderLayout.CENTER, text); + return c; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/CrewsForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/CrewsForm.java new file mode 100644 index 00000000000..8ddf6f3d30d --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/CrewsForm.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.ShareButton; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.splat.api.CrewCreateDto; +import com.codenameone.examples.splat.api.CrewDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; + +/// Crews: a standing group of friends with its own leaderboard on every +/// level and a one-tap party. Joining takes the crew's code, which a member +/// shares however they like. +public final class CrewsForm { + private CrewsForm() { + } + + public static void show() { + final Form f = Ui.form("Crews", "crews"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + final Container list = Ui.column(); + f.add(Ui.centeredColumn(list)); + final TextField name = new TextField("", "New crew name", 24, TextField.ANY); + name.setName("crewName"); + final TextField code = new TextField("", "Crew code", 12, TextField.ANY); + code.setName("crewCode"); + Container make = Ui.column(Ui.label("Start a crew", "SectionTitle"), name, + Ui.primary("Create", "createCrew", e -> { + if (name.getText().trim().length() > 0) { + Api.game().createCrew(new CrewCreateDto(name.getText().trim()), + Net.to(c -> Ui.later(CrewsForm::show), Ui::failed)); + } + }), + Ui.label("Join a crew", "SectionTitle"), code, + Ui.secondary("Join", "joinCrew", e -> { + if (code.getText().trim().length() > 0) { + Api.game().joinCrew(code.getText().trim().toUpperCase(), + Net.to(c -> Ui.later(CrewsForm::show), Ui::failed)); + } + })); + f.add(Ui.centeredColumn(make)); + Api.game().crews(Net.to(crews -> Ui.later(() -> { + if (crews.isEmpty()) { + list.add(Ui.text("No crew yet. Start one, or ask a friend for theirs.", "Empty")); + } + for (CrewDto c : crews) { + list.add(card(c)); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } + + private static Container card(final CrewDto c) { + Container card = new Container(new BorderLayout()); + card.setUIID("QuestCard"); + card.setName("crew-" + c.code()); + Container text = new Container(BoxLayout.y()); + text.add(new Label(c.name() + " " + c.code(), "QuestTitle")); + StringBuilder who = new StringBuilder(); + for (int i = 0; i < c.names().size(); i++) { + if (i > 0) { + who.append(", "); + } + who.append(c.names().get(i)); + } + text.add(Ui.text(who.toString(), "QuestReward")); + card.add(BorderLayout.CENTER, text); + ShareButton share = new ShareButton(); + share.setText("Share"); + share.setUIID("DialogButton"); + share.setTextToShare("Join my crew in Splat: code " + c.code()); + card.add(BorderLayout.SOUTH, Ui.row( + Ui.button("Party", "ClaimButton", "crewParty-" + c.code(), e -> MatchLobby.host(null, c.code())), + share, + Ui.button("Leave", "DialogButton", "leaveCrew-" + c.code(), + e -> Api.game().leaveCrew(c.code(), Net.to(x -> Ui.later(CrewsForm::show), Ui::failed))))); + if (Session.user() != null && Session.user().username().equals(c.owner())) { + text.add(new Label("You started this crew", "XpText")); + } + return card; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/FriendsForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/FriendsForm.java new file mode 100644 index 00000000000..4ed8a76fec1 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/FriendsForm.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.splat.api.FriendDto; +import com.codenameone.examples.splat.api.InviteDto; +import com.codenameone.examples.splat.engage.Friends; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import java.util.List; + +/// Invite a friend: the player's link, what an invite pays, and everyone who +/// joined through it. +/// +/// The link itself is made on the device by Codename One's invite links +/// ([Friends#share]); the server only knows the code inside it, and pays +/// both players once the friend has run a race. +public final class FriendsForm { + private FriendsForm() { + } + + public static void show() { + final Form f = Ui.form("Friends", "friends"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + Telemetry.screen("friends"); + final Container col = Ui.column(); + f.add(Ui.centeredColumn(col)); + f.show(); + Api.social().invite(Net.to(mine -> Ui.later(() -> { + fillInvite(col, mine); + f.revalidate(); + Api.social().friends(Net.to(list -> Ui.later(() -> { + fillFriends(col, list); + f.revalidate(); + }), Ui::failed)); + }), Ui::failed)); + } + + private static void fillInvite(Container col, final InviteDto mine) { + + col.add(Ui.label("RECRUIT A FRIEND", "SectionTitle")); + col.add(Ui.text("Your friend starts with " + mine.friendCoins() + " coins. When they have played " + + "their first race, you get " + mine.inviterCoins() + " coins, for up to " + + mine.maxRewarded() + " friends.", "ForgeHelpText")); + Label code = Ui.label(mine.code(), "InviteCode"); + code.setName("inviteCode"); + Container codeCard = new Container(BoxLayout.y()); + codeCard.setUIID("InviteCard"); + codeCard.add(Ui.centered(Ui.label("YOUR INVITE CODE", "InviteCaption"))); + codeCard.add(Ui.centered(code)); + col.add(codeCard); + col.add(Ui.primary("Invite friends", "inviteButton", e -> Friends.share(mine))); + Label count = Ui.label(mine.joined() + " joined, " + mine.rewarded() + " rewarded", "LeagueName"); + count.setName("inviteCount"); + col.add(count); + } + + private static void fillFriends(Container col, List list) { + col.add(Ui.label("Joined through you", "SectionTitle")); + if (list.isEmpty()) { + col.add(Ui.label("Nobody yet. Share your link!", "Empty")); + return; + } + for (FriendDto d : list) { + Container c = new Container(new BorderLayout()); + c.setUIID("AchievementRow"); + Label icon = new Label("", "Container"); + icon.setIcon(FontImage.createMaterial(d.rewarded() ? FontImage.MATERIAL_MONETIZATION_ON + : FontImage.MATERIAL_HOURGLASS_EMPTY, d.rewarded() ? "RewardAchievement" : "XpText", 6)); + c.add(BorderLayout.WEST, icon); + Container text = new Container(BoxLayout.y()); + text.add(new Label(d.displayName(), "QuestTitle")); + text.add(new Label(d.trophies() + " trophies, " + + ("played".equals(d.state()) ? "has played" : "has not played yet"), "XpText")); + c.add(BorderLayout.CENTER, text); + col.add(c); + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/HistoryForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/HistoryForm.java new file mode 100644 index 00000000000..8843cc70dc1 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/HistoryForm.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.MultiButton; +import com.codename1.ui.Form; +import com.codenameone.examples.splat.api.RaceSummaryDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; + +/// The player's recent races, each one a replay away. +public final class HistoryForm { + private HistoryForm() { + } + + public static void show() { + final Form f = Ui.form("Replays", "history"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + Api.game().history(Net.to(list -> Ui.later(() -> { + if (list.isEmpty()) { + f.add(Ui.text("No races yet. Your live races appear here, ready to replay.", "Empty")); + } + for (final RaceSummaryDto r : list) { + MultiButton b = new MultiButton(place(r.place()) + " of " + r.runners() + " " + r.score() + " pts"); + b.setUIID("HistoryRow"); + b.setUIIDLine1(r.place() == 0 ? "HistoryWin" : r.place() * 2 < r.runners() ? "HistoryDraw" + : "HistoryLoss"); + b.setTextLine2(r.level() + " " + (r.trophyChange() > 0 ? "+" : "") + r.trophyChange() + + " trophies"); + b.setName("race-" + r.id()); + b.addActionListener(e -> ReplayForm.load(r.id())); + f.add(b); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } + + /// `place` counted from 0, as a word: 1st, 2nd, 3rd... + public static String place(int place) { + int n = place + 1; + int tens = n % 100; + if (tens >= 11 && tens <= 13) { + return n + "th"; + } + switch (n % 10) { + case 1: + return n + "st"; + case 2: + return n + "nd"; + case 3: + return n + "rd"; + default: + return n + "th"; + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/HomeForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/HomeForm.java new file mode 100644 index 00000000000..f91098d179a --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/HomeForm.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.SpanButton; +import com.codename1.io.Preferences; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.splat.api.ClaimDto; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.ProgressDto; +import com.codenameone.examples.splat.api.QuestDto; +import com.codenameone.examples.splat.api.SpotlightDto; +import com.codenameone.examples.splat.engage.Ads; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.play.OperatorStage; + +/// The main menu, laid out like the front end of every military shooter: your +/// operator standing in the light on the right, a column of capitalised +/// orders down the left, your rank and wallet in the corner, and the daily +/// orders and supply drops along the bottom. +// tag::splat-home[] +public final class HomeForm { + private HomeForm() { + } + + /// The banner the operator sets from the console's Promote tab, as of the + /// last time home was shown. + private static SpotlightDto spotlight; + + public static void show() { + final PlayerDto p = Session.user(); + Form f = Ui.form("", "home"); + f.setLayout(new LayeredLayout()); + f.getToolbar().hideToolbar(); + f.setScrollable(false); + f.setUIID("MenuForm"); + boolean wide = CN.getDisplayWidth() > CN.getDisplayHeight(); + f.add(new OperatorStage(OperatorStage.operatorFor(p.username()), p.runner(), wide ? 0.9f : 0.55f)); + Label shade = new Label(); + shade.setUIID("MenuShade"); + f.add(shade); + final Container front = new Container(new BorderLayout()); + front.setSafeArea(true); + f.add(front); + fill(f, front, p, Rewards.progress()); + f.show(); + Api.social().spotlight(Net.to(found -> { + spotlight = found; + if (Session.user() != null && CN.getCurrentForm() == f) { + fill(f, front, Session.user(), Rewards.progress()); + f.revalidate(); + } + }, (s, m) -> { })); + Rewards.refresh(() -> { + if (Session.user() == null || CN.getCurrentForm() != f) { + return; + } + ProgressDto now = Rewards.progress(); + if (now != null) { + Telemetry.describe(now.level(), Session.user().trophies()); + } + fill(f, front, Session.user(), now); + f.revalidate(); + offerTutorial(now); + }); + } + + /// A new player is walked through basic training before anything else. + /// Offered once per account on this device. + private static void offerTutorial(ProgressDto now) { + if (now == null || now.tutorialDone()) { + return; + } + String key = "tutorialOffered-" + Session.user().username(); + if (Preferences.get(key, false)) { + return; + } + Preferences.set(key, true); + MatchForm.practice("lot7:TEAM_PAINT", Nav::home); + } + + private static void fill(Form f, Container front, PlayerDto p, ProgressDto g) { + front.removeAll(); + + // Top: the game's mark on the left, your rank card on the right. + Container top = new Container(new BorderLayout()); + top.setUIID("MenuTop"); + Container mark = BoxLayout.encloseY(new Label("SPLAT", "MenuLogo"), + new Label("MULTIPLAYER // PAINTBALL", "MenuLogoSub")); + top.add(BorderLayout.CENTER, mark); + Container card = new Container(BoxLayout.y()); + card.setUIID("RankCard"); + Container nameRow = new Container(new FlowLayout(Component.RIGHT)); + nameRow.add(new Label(Ui.caps(p.displayName()), "PlayerName")); + if (g != null) { + Label lvl = new Label(String.valueOf(g.level()), "LevelBadge"); + lvl.setName("level"); + nameRow.add(lvl); + } + card.add(nameRow); + int into = g == null ? 0 : g.xp() - g.levelStart(); + int span = g == null ? 1 : g.levelEnd() - g.levelStart(); + card.add(Ui.bar(into, Math.max(1, span), 0xf2b632)); + Label league = new Label(Ui.caps(leagueName(p.trophies())), "LeagueName"); + Label trophies = new Label(String.valueOf(p.trophies()), "Trophies"); + trophies.setName("Trophies"); + Container wallet = new Container(new FlowLayout(Component.RIGHT)); + Button coins = Ui.button("CP " + p.coins(), "Coins", "Coins", e -> ShopForm.show()); + Button gems = Ui.button("GB " + p.gems(), "Gems", "Gems", e -> ShopForm.show()); + Button gear = Ui.button("", "IconButton", "settingsButton", e -> SettingsForm.show()); + gear.setMaterialIcon(FontImage.MATERIAL_SETTINGS, 3.6f); + wallet.add(league).add(trophies).add(coins).add(gems).add(gear); + card.add(wallet); + top.add(BorderLayout.EAST, card); + front.add(BorderLayout.NORTH, top); + + // Left: the menu, one order per line, the first one primed. + Container menu = new Container(BoxLayout.y()); + menu.setUIID("MenuColumn"); + if (p.suspended()) { + menu.add(Ui.text("ACCOUNT SUSPENDED. MATCHES AND LEADERBOARDS ARE CLOSED UNTIL IT IS LIFTED.", + "Warning")); + } + menu.add(item("FIND A MATCH", "Quick play, 4v4, any yard", "quickRaceButton", true, + () -> MatchLobby.quick(null))); + menu.add(item("OPERATIONS", "Modes, yards, practice vs bots", "runButton", false, PlayForm::show)); + menu.add(item("PARTY UP", "Private lobby with your squad", "partyButton", false, + () -> MatchLobby.host(null))); + menu.add(item("MISSION BUILDER", "Design a field, play it with friends", "builderButton", false, + MissionsForm::show)); + menu.add(item("OPERATOR", "Marker and kit", "loadoutButton", false, LoadoutForm::show)); + int ready = 0; + if (g != null) { + for (QuestDto q : g.quests()) { + if (!q.claimed() && q.progress() >= q.goal()) { + ready++; + } + } + } + final ProgressDto prog = g; + menu.add(item("ORDERS" + (ready > 0 ? " [" + ready + "]" : ""), "Daily challenges", "missionsButton", false, + () -> missions(prog))); + menu.add(item("BARRACKS", "Medals and service record", "achievementsButton", false, + AchievementsForm::show)); + menu.add(item("REPLAYS", "Watch it back. It was a bounce.", "replaysButton", false, HistoryForm::show)); + menu.add(item("LEADERBOARDS", "Who paints the most", "leadersButton", false, LeaderboardForm::show)); + menu.add(item("SOCIAL", "Friends", "friendsButton", false, FriendsForm::show)); + menu.add(item("CREWS", "Clans, but with juice boxes", "crewsButton", false, CrewsForm::show)); + menu.add(item("STORE", "Camo that is clearly a hunting jacket", "shopButton", false, ShopForm::show)); + if (p.admin()) { + menu.add(item("COMMAND", "Admin console", "adminButton", false, Nav::adminConsole)); + } + if (g != null && g.chests() > 0) { + // in the list, not under it: the bottom strip is the news and the + // ad, and a third row there was cut off on a phone + menu.add(supplyDrops(g)); + } + Container left = new Container(new BorderLayout()); + left.add(BorderLayout.NORTH, menu); + left.setScrollableY(true); + front.add(BorderLayout.WEST, left); + + // Bottom: news, supply drops, the ad. + Container bottom = new Container(BoxLayout.y()); + bottom.setUIID("MenuBottom"); + Component spot = spotlightBanner(); + if (spot != null) { + bottom.add(spot); + } + Component banner = Ads.banner(Ui.adFree()); + if (banner != null) { + bottom.add(banner); + } + front.add(BorderLayout.SOUTH, bottom); + } + + /// One line of the menu: the order in capitals and, under it, what it + /// does. The primed one carries the amber bar. + private static Component item(String title, String sub, String name, boolean primed, final Runnable go) { + Button b = Ui.button(title, primed ? "MenuItemTextPrimed" : "MenuItemText", name, e -> go.run()); + // a phone held upright has room for the whole list only without the + // second lines, and so does a desktop window under 1000 pixels once + // the fonts are at their desktop size; the primed order keeps its own + boolean tall = CN.isDesktop() ? CN.getDisplayHeight() < 1000 + : CN.getDisplayHeight() > CN.getDisplayWidth() && !CN.isTablet(); + Container c = tall && !primed ? BoxLayout.encloseY(b) + : BoxLayout.encloseY(b, new Label(Ui.caps(sub), "MenuItemSub")); + c.setUIID(primed ? "MenuItemPrimed" : "MenuItem"); + c.setLeadComponent(b); + return c; + } + + /// Supply drops waiting: one button that opens the next, with the count. + private static Container supplyDrops(ProgressDto g) { + Container c = new Container(new FlowLayout()); + c.setName("chests"); + final Button b = Ui.button("OPEN SUPPLY DROP" + (g.chests() > 1 ? " x" + g.chests() : ""), "CrateSlot", + "chest0", null); + b.addActionListener(e -> { + b.setEnabled(false); + Api.progress().openChest(Net.to(r -> Ui.later(() -> Rewards.show(r, HomeForm::show)), + (s, m) -> Ui.later(() -> Ui.failed(s, m)))); + }); + c.add(b); + return c; + } + + /// Today's quests in a sheet over home, each claimable the moment it is done. + private static void missions(ProgressDto g) { + Dialog d = new Dialog("DAILY ORDERS", BoxLayout.y()); + d.setName("missions"); + d.getContentPane().setScrollableY(true); + if (g == null || g.quests().isEmpty()) { + d.add(new Label("No orders. Stand easy.", "Muted")); + } else { + for (QuestDto q : g.quests()) { + d.add(quest(q)); + } + } + Button close = Ui.button("DISMISSED", "DialogAction", "closeMissions", e -> d.dispose()); + d.add(close); + d.showPacked(BorderLayout.CENTER, true); + } + + private static Container quest(final QuestDto q) { + Container card = new Container(new BorderLayout()); + card.setUIID("QuestCard"); + card.setName("quest-" + q.id()); + Container text = new Container(BoxLayout.y()); + text.add(new Label(q.title(), "QuestTitle")); + Container barRow = new Container(new BorderLayout()); + barRow.add(BorderLayout.CENTER, Ui.bar(q.progress(), q.goal(), 0xffc94a)); + barRow.add(BorderLayout.EAST, new Label(" " + q.progress() + "/" + q.goal(), "XpText")); + text.add(barRow); + text.add(new Label("+" + q.coins() + " coins +" + q.xp() + " XP", "QuestReward")); + card.add(BorderLayout.CENTER, text); + if (q.claimed()) { + Label done = new Label("Done", "Claimed"); + done.setMaterialIcon(FontImage.MATERIAL_CHECK_CIRCLE); + card.add(BorderLayout.EAST, FlowLayout.encloseMiddle(done)); + } else if (q.progress() >= q.goal()) { + Button claim = Ui.button("Claim", "ClaimButton", "claim-" + q.id(), null); + claim.addActionListener(e -> { + claim.setEnabled(false); + Api.progress().claimQuest(new ClaimDto(q.id()), Net.to(r -> Ui.later(() -> Rewards.show(r, + HomeForm::show)), (s, m) -> Ui.later(() -> Ui.failed(s, m)))); + }); + card.add(BorderLayout.EAST, FlowLayout.encloseMiddle(claim)); + } + return card; + } + + // tag::splat-spotlight[] + /// The spotlight: one line of news from the operator, and a tap that goes + /// where it is about -- a Forge pack, or a screen of the game. + private static Component spotlightBanner() { + final SpotlightDto s = spotlight; + if (s == null || s.text() == null || s.text().length() == 0) { + return null; + } + // A SpanButton, so a long line wraps instead of being cut off. + SpanButton b = new SpanButton(s.text()); + b.setUIID("Spotlight"); + b.setTextUIID("SpotlightText"); + b.setIconUIID("SpotlightIcon"); + b.setName("spotlight"); + b.setMaterialIcon(FontImage.MATERIAL_CAMPAIGN, 4); + // a span measures its first layout at its unwrapped width, so the + // strip under the menu was sized for one line and cut the second; + // a news line is at most two, so ask for two from the start; a wide + // screen fits it on one, and two would leave an empty band + boolean wide = CN.getDisplayWidth() > CN.convertToPixels(150); + b.getTextComponent().setRows(wide ? 1 : 2); + b.getTextComponent().setGrowByContent(true); + b.addActionListener(e -> { + Nav.screen(s.screen() == null ? "" : s.screen()); + }); + return b; + } + // end::splat-spotlight[] + + /// The league a trophy count has reached. Cosmetic only: a quick race + /// fills with whoever is waiting, not by league. + static String leagueName(int trophies) { + if (trophies < 300) { + return "Private, Weekend Division"; + } + if (trophies < 800) { + return "Corporal of the Car Park"; + } + if (trophies < 1500) { + return "Sergeant, Snack Bar Ops"; + } + return "Commander of the Cul-de-Sac"; + } +} +// end::splat-home[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Kit.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Kit.java new file mode 100644 index 00000000000..590cb4dada9 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Kit.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Label; + +/// Small pieces of motion the menus share. +final class Kit { + private Kit() { + } + + /// Counts a label up from 0 to `to` over a second, with `prefix` before. + public static Label countUp(final String prefix, final int to, String uiid) { + final Label l = new Label(prefix + to, uiid); + final long start = System.currentTimeMillis(); + // Sized for the final number, so counting never truncates or jumps. + l.setPreferredSize(l.getPreferredSize()); + l.setText(prefix + "0"); + CN.callSerially(new Runnable() { + public void run() { + if (l.getComponentForm() == null) { + l.setText(prefix + to); + return; + } + float k = Math.min(1f, (System.currentTimeMillis() - start) / 1100f); + k = 1f - (1f - k) * (1f - k); + l.setText(prefix + (int) (to * k)); + l.repaint(); + if (k < 1f) { + com.codename1.ui.util.UITimer.timer(30, false, l.getComponentForm(), this); + } + } + }); + return l; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/LeaderboardForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/LeaderboardForm.java new file mode 100644 index 00000000000..8edde738455 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/LeaderboardForm.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.splat.api.LeaderDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; + +/// The top players by trophies. +public final class LeaderboardForm { + private LeaderboardForm() { + } + + public static void show() { + final Form f = Ui.form("Leaders", "leaders"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + Api.game().leaderboard(50, Net.to(list -> Ui.later(() -> { + for (LeaderDto l : list) { + Container row = new Container(new BorderLayout()); + row.setUIID(l.rank() <= 3 ? "LeaderTop" : "LeaderRow"); + row.add(BorderLayout.WEST, new Label("#" + l.rank(), "LeaderRank")); + row.add(BorderLayout.CENTER, new Label(l.displayName(), "LeaderName")); + row.add(BorderLayout.EAST, new Label(String.valueOf(l.trophies()), "Trophies")); + f.add(row); + } + f.revalidate(); + }), Ui::failed)); + f.show(); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/LoadoutForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/LoadoutForm.java new file mode 100644 index 00000000000..b1d1f5e85e7 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/LoadoutForm.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.api.RunnerDto; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.play.OperatorStage; +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.MarkerDef; + +/// The operator screen: your operator on the right holding what you carry, +/// the primary slot down the left with every marker and, for the one in +/// view, its numbers as bars. Three side-grades, none better: the Purist is +/// one accurate shot at a time, the Tactical Office Supply the all-rounder, +/// the Ramping Dad a wall of paint that goes everywhere except where you aim. +// tag::splat-loadout[] +public final class LoadoutForm { + /// Every marker, in the order the screen lists them. + static final String[] RUNNERS = {"semi", "pump", "ramp"}; + + private LoadoutForm() { + } + + public static void show() { + final Form f = Ui.form("", "loadout"); + f.setLayout(new LayeredLayout()); + f.getToolbar().hideToolbar(); + f.setScrollable(false); + f.setUIID("MenuForm"); + PlayerDto p = Session.user(); + String current = p == null || p.runner() == null ? RUNNERS[0] : p.runner(); + boolean wide = CN.getDisplayWidth() > CN.getDisplayHeight(); + final OperatorStage stage = new OperatorStage(OperatorStage.operatorFor(p == null ? null : p.username()), + current, wide ? 0.9f : 0.55f); + f.add(stage); + Label shade = new Label(); + shade.setUIID("MenuShade"); + f.add(shade); + final Container front = new Container(new BorderLayout()); + front.setSafeArea(true); + f.add(front); + fill(f, front, stage, current); + f.show(); + } + + private static void fill(final Form f, final Container front, final OperatorStage stage, final String shown) { + front.removeAll(); + PlayerDto p = Session.user(); + String current = p == null || p.runner() == null ? RUNNERS[0] : p.runner(); + Container top = new Container(new BorderLayout()); + top.setUIID("MenuTop"); + Button back = Ui.button("< BACK", "BackLink", "back", e -> Nav.home()); + top.add(BorderLayout.WEST, BoxLayout.encloseY(back, new Label("OPERATOR", "MenuLogo"), + new Label("PRIMARY // MARKER", "MenuLogoSub"))); + front.add(BorderLayout.NORTH, top); + + Container col = new Container(BoxLayout.y()); + col.setUIID("MenuColumn"); + int maxRate = 0; + int maxSpeed = 0; + int maxSpread = 1; + int maxHopper = 0; + int maxMob = 0; + for (int i = 0; i < Catalog.MARKERS.length; i++) { + MarkerDef m = Catalog.MARKERS[i]; + maxRate = Math.max(maxRate, 30 * 60 / Math.max(1, m.cooldown)); + maxSpeed = Math.max(maxSpeed, m.speed); + maxSpread = Math.max(maxSpread, m.spread); + maxHopper = Math.max(maxHopper, m.hopper); + maxMob = Math.max(maxMob, m.mobility); + } + for (int i = 0; i < RUNNERS.length; i++) { + final String id = RUNNERS[i]; + final MarkerDef m = Catalog.marker(id); + boolean owned = i == 0 || p != null && p.owned() != null && p.owned().contains(id); + boolean open = id.equals(shown); + Button pick = Ui.button(Ui.caps(m.name), open ? "MenuItemTextPrimed" : "MenuItemText", id, null); + // a long name shrinks to fit rather than ending in an ellipsis + pick.setAutoSizeMode(true); + pick.addActionListener(e -> { + stage.setMarker(id); + fill(f, front, stage, id); + }); + Container row = new Container(BoxLayout.y()); + row.setUIID(open ? "MenuItemPrimed" : "MenuItem"); + row.add(pick); + String state = id.equals(current) ? "EQUIPPED" : owned ? "OWNED" : "LOCKED -- STORE"; + row.add(new Label(state, id.equals(current) ? "LoadoutEquipped" : "MenuItemSub")); + if (open) { + row.add(Ui.text(m.blurb, "LoadoutBlurb")); + row.add(stat("FIRE RATE", 30 * 60 / Math.max(1, m.cooldown), maxRate)); + row.add(stat("RANGE", m.speed, maxSpeed)); + row.add(stat("ACCURACY", maxSpread - m.spread + maxSpread / 4, maxSpread + maxSpread / 4)); + row.add(stat("HOPPER", m.hopper, maxHopper)); + row.add(stat("MOBILITY", m.mobility, maxMob)); + if (id.equals(current)) { + Label on = new Label("CARRYING", "RunnerChip"); + row.add(on); + } else if (owned) { + row.add(Ui.button("EQUIP", "DeployButton", "choose-" + id, e -> choose(f, front, stage, id))); + } else { + row.add(Ui.button("UNLOCK IN STORE", "OpsAction", "shop-" + id, e -> ShopForm.show())); + } + } else { + row.setLeadComponent(pick); + } + col.add(row); + } + col.add(Ui.button("ARMORY", "OpsAction", "runnerShop", e -> ShopForm.show())); + Container left = new Container(new BorderLayout()); + left.add(BorderLayout.NORTH, col); + left.setScrollableY(true); + left.setPreferredW(Math.min(CN.getDisplayWidth() * 4 / 5, Ui.mm(90))); + front.add(BorderLayout.WEST, left); + f.revalidate(); + } + + /// One labelled bar: the stat's name, then the bar in amber. + private static Container stat(String name, int value, int max) { + Container c = new Container(new BorderLayout()); + c.setUIID("LoadoutStat"); + Label l = new Label(name, "LoadoutStatName"); + l.setPreferredW(Ui.mm(18)); + c.add(BorderLayout.WEST, l); + c.add(BorderLayout.CENTER, Ui.bar(value, Math.max(1, max), 0xf2b632)); + return c; + } + + private static void choose(final Form f, final Container front, final OperatorStage stage, final String id) { + Api.game().saveRunner(new RunnerDto(id), Net.to(r -> Session.refreshUser(() -> Ui.later(() -> { + Telemetry.runnerChosen(id); + fill(f, front, stage, id); + }), Ui::failed), Ui::failed)); + } +} +// end::splat-loadout[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchForm.java new file mode 100644 index 00000000000..e445793e8ad --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchForm.java @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codenameone.examples.splat.engage.Ads; +import com.codenameone.examples.splat.engage.Friends; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.play.Driver; +import com.codenameone.examples.splat.play.Hud; +import com.codenameone.examples.splat.play.Music; +import com.codenameone.examples.splat.play.OnlineDriver; +import com.codenameone.examples.splat.play.PracticeDriver; +import com.codenameone.examples.splat.play.ReplayDriver; +import com.codenameone.examples.splat.rules.Event; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Mode; +import com.codenameone.examples.splat.rules.Player; +import com.codenameone.examples.splat.rules.Replay; + +/// The match screen: the [ArenaView] with the [Hud] layered over it, a pause +/// button, and -- online -- the emote bar. It is the same form for practice, +/// online and a replay; only the [Driver] differs. +// tag::splat-match-form[] +public final class MatchForm implements ArenaView.Hud, OnlineDriver.Listener { + private final Driver driver; + private final String mode; + private final Runnable back; + private final ArenaView view; + private final Hud hud; + private final Form form; + private final Container layers; + private Container chrome; + private boolean endShown; + + private MatchForm(Driver driver, String mode, Runnable back) { + this.driver = driver; + this.mode = mode; + this.back = back; + view = new ArenaView(driver); + view.setName("arena"); + hud = new Hud(driver); + hud.attach(view); + view.setHud(this); + form = new Form(driver.match().mode().operation, new BorderLayout()); + form.setName("match"); + form.setUIID("MatchScreen"); + form.getToolbar().hideToolbar(); + + chrome = new Container(new BorderLayout()); + chrome.setSafeArea(true); + Button quit = Ui.button("", "HudButton", "quit", e -> quit()); + quit.setMaterialIcon(FontImage.MATERIAL_PAUSE, 4.4f); + Container top = new Container(new BorderLayout()); + Container corner = new Container(BoxLayout.y()); + if (driver instanceof OnlineDriver && driver.interactive()) { + // Comms stay folded behind one button: open, they cover the + // killfeed and nothing else, and they fold again once you speak. + final Container emotes = new Container(BoxLayout.y()); + emotes.setUIID("EmoteBar"); + emotes.setHidden(true); + Button comms = Ui.button("", "HudButton", "comms", null); + comms.setMaterialIcon(FontImage.MATERIAL_CHAT_BUBBLE, 4.4f); + comms.addActionListener(e -> showComms(emotes, emotes.isHidden())); + String[] ids = {"bounce", "cover", "ref", "gg"}; + for (final String id : ids) { + emotes.add(Ui.button(emoteText(id), "EmoteButton", "emote-" + id, e -> { + ((OnlineDriver) driver).emote(id); + showComms(emotes, false); + })); + } + corner.add(FlowLayout.encloseRight(comms, quit)); + corner.add(emotes); + } else { + corner.add(FlowLayout.encloseRight(quit)); + } + top.add(BorderLayout.EAST, corner); + if (driver instanceof ReplayDriver) { + top.add(BorderLayout.WEST, replayControls((ReplayDriver) driver)); + } + chrome.add(BorderLayout.NORTH, top); + layers = new Container(new LayeredLayout()); + layers.add(view); + layers.add(hud); + layers.add(chrome); + form.add(BorderLayout.CENTER, layers); + } + // end::splat-match-form[] + + private void showComms(Container emotes, boolean open) { + emotes.setHidden(!open); + hud.setFeedHidden(open); + form.revalidateLater(); + } + + /// The comms wheel, abridged: the four things every paintball player says. + static String emoteText(String id) { + if ("bounce".equals(id)) { + return "IT BOUNCED!"; + } + if ("cover".equals(id)) { + return "COVER ME!"; + } + if ("ref".equals(id)) { + return "REF!!"; + } + return "GG"; + } + + private Component replayControls(final ReplayDriver r) { + Container c = new Container(BoxLayout.x()); + Button prev = Ui.button("", "HudButton", "replay-prev", e -> r.follow(r.me() - 1)); + prev.setMaterialIcon(FontImage.MATERIAL_SKIP_PREVIOUS, 4f); + Button pause = Ui.button("", "HudButton", "replay-pause", null); + pause.setMaterialIcon(FontImage.MATERIAL_PAUSE, 4f); + pause.addActionListener(e -> { + r.setPaused(!r.paused()); + pause.setMaterialIcon(r.paused() ? FontImage.MATERIAL_PLAY_ARROW : FontImage.MATERIAL_PAUSE, 4f); + }); + Button fast = Ui.button("2x", "HudButton", "replay-speed", null); + fast.addActionListener(e -> { + int next = r.speedPercent() >= 400 ? 50 : r.speedPercent() * 2; + r.setSpeedPercent(next); + fast.setText(next >= 100 ? (next / 100) + "x" : "1/2x"); + }); + Button skip = Ui.button("+30s", "HudButton", "replay-skip", e -> r.skip(30 * Match.TICK_HZ)); + Button next = Ui.button("", "HudButton", "replay-next", e -> r.follow(r.me() + 1)); + next.setMaterialIcon(FontImage.MATERIAL_SKIP_NEXT, 4f); + c.add(prev).add(pause).add(fast).add(skip).add(next); + return c; + } + + /// Offline against bots on `ref` (`arena:MODE`). + public static void practice(String ref, Runnable back) { + int colon = ref.indexOf(':'); + String marker = Session.user() == null ? "semi" : Session.user().runner(); + PracticeDriver d = new PracticeDriver(ref.substring(0, colon), Mode.byName(ref.substring(colon + 1)), + marker, 8, 55, System.currentTimeMillis() & 0xffffffL); + Telemetry.runStarted("practice", ref); + new MatchForm(d, "practice", back).start(); + } + + public static void online(OnlineDriver driver, Runnable back) { + Telemetry.runStarted("online", driver.level()); + MatchForm f = new MatchForm(driver, "online", back); + driver.setListener(f); + f.start(); + } + + public static void replay(Replay replay, int follow, Runnable back) { + new MatchForm(new ReplayDriver(replay, follow), "replay", back).start(); + } + + private void start() { + form.show(); + view.start(); + if (!driver.interactive()) { + // a replay has no countdown to bring the field in + Music.field(); + } + } + + private void quit() { + if (driver instanceof OnlineDriver && driver.interactive() && !driver.match().over()) { + if (!Dialog.show("Leave the match?", "Your team will say you rage-quit. They will be right.", + "Leave", "Stay")) { + return; + } + ((OnlineDriver) driver).leave(); + } + close(); + } + + private void close() { + view.stop(); + driver.dispose(); + Music.menu(); + back.run(); + } + + // ------------------------------------------------------------ hud + + @Override + public void frame(Match m, int me, float lookYaw, float lookPitch) { + hud.frame(m, me, lookYaw, lookPitch); + if (m.over() && !endShown && !(driver instanceof OnlineDriver)) { + endShown = true; + // Time for the winners to celebrate on camera before the card + // covers them. + Ui.later(() -> com.codename1.ui.CN.setTimeout(CELEBRATION_MILLIS, this::localEnd)); + } + } + + @Override + public void event(Event e) { + hud.event(e); + } + + /// How long the end-of-match camera watches the winners cheer. + private static final int CELEBRATION_MILLIS = 5000; + + private void localEnd() { + Match m = driver.match(); + Player me = m.player(driver.me()); + boolean won = m.winner() == me.team(); + Telemetry.runEnded(mode, m.arena().id + ":" + m.mode().name(), won ? 0 : 1, me.paints(), !won); + if ("practice".equals(mode) && Session.user() != null) { + // The first match finished pays the tutorial reward; later ones are free. + Api.progress().tutorialDone(Net.to(r -> { }, (s, msg) -> { })); + } + String title = m.winner() < 0 ? "DRAW" : driver instanceof ReplayDriver ? "REPLAY OVER" : won ? "VICTORY" : "DEFEAT"; + result(title, "Practice does not count. Tell everyone it did.", null); + } + + @Override + public void ended(int[] places, int[] scores, int trophies, int coins, String matchId) { + if (endShown) { + return; + } + endShown = true; + com.codename1.ui.CN.setTimeout(CELEBRATION_MILLIS, + () -> showEnded(places, scores, trophies, coins, matchId)); + } + + private void showEnded(int[] places, int[] scores, int trophies, int coins, String matchId) { + view.stop(); + int mine = driver.me() < places.length ? places[driver.me()] : 1; + Friends.raceFinished(mine == 0); + Telemetry.runEnded("online", ((OnlineDriver) driver).level(), mine, driver.me() < scores.length + ? scores[driver.me()] : 0, mine != 0); + // The server's places, not this phone's prediction: a draw places + // everyone first. + boolean draw = places.length > 0; + for (int p : places) { + draw &= p == 0; + } + String title = draw ? "DRAW" : mine == 0 ? "VICTORY" : "DEFEAT"; + result(title, (trophies >= 0 ? "+" : "") + trophies + " RANK +" + coins + " CP", matchId); + } + + @Override + public void emote(String from, String id) { + chat(from, emoteText(id)); + } + + @Override + public void chat(String from, String text) { + hud.chat(from, text); + } + + @Override + public void lost(String why) { + Ui.toast(why); + close(); + } + + private static Container scoreRow(String who, String a, String b, String c, String uiid) { + Container row = new Container(new BorderLayout()); + row.setUIID(uiid); + row.add(BorderLayout.CENTER, new Label(who, "ScoreCellName")); + Container nums = new Container(new GridLayout(1, 3)); + nums.add(new Label(a, "ScoreCell")).add(new Label(b, "ScoreCell")).add(new Label(c, "ScoreCell")); + nums.setPreferredW(Ui.mm(21)); + row.add(BorderLayout.EAST, nums); + return row; + } + + /// The scoreboard: both teams, best painter first, with the stats nobody + /// should be proud of. + private void result(String title, String note, final String matchId) { + view.stop(); + // the card owns the screen: no HUD, no touch buttons, no pause behind it + hud.setEnded(true); + view.hideControls(); + chrome.setHidden(true); + chrome.setVisible(false); + Match m = driver.match(); + int myTeam = driver.interactive() ? m.player(driver.me()).team() : 0; + Container card = new Container(BoxLayout.y()); + card.setUIID("ResultCard"); + card.setName("result"); + card.add(new Label(title, "VICTORY".equals(title) ? "ResultTitle" : "ResultTitleLoss")); + card.add(new Label(Ui.caps(m.mode().operation) + " // " + Ui.caps(m.arena().name), "ResultCaption")); + // the score big on a line of its own, the two sides named under it + card.add(new Label(m.score(myTeam) + " - " + m.score(1 - myTeam), "ResultScore")); + card.add(new Label(Ui.caps(ArenaView.TEAM_NAMES[myTeam]) + " VS " + + Ui.caps(ArenaView.TEAM_NAMES[1 - myTeam]), "ResultCaption")); + card.add(scoreRow("PLAYER", "HITS", "OUTS", "CAPS", "ScoreHead")); + int mvp = -1; + for (int i = 0; i < m.playerCount(); i++) { + if (mvp < 0 || m.player(i).paints() > m.player(mvp).paints()) { + mvp = i; + } + } + for (int k = 0; k < 2; k++) { + int t = k == 0 ? myTeam : 1 - myTeam; + boolean[] used = new boolean[m.playerCount()]; + while (true) { + int best = -1; + for (int i = 0; i < m.playerCount(); i++) { + if (!used[i] && m.player(i).team() == t + && (best < 0 || m.player(i).paints() > m.player(best).paints())) { + best = i; + } + } + if (best < 0) { + break; + } + used[best] = true; + Player p = m.player(best); + String who = Ui.caps(driver.name(best)) + (best == mvp ? " MVP" : ""); + card.add(scoreRow(who, String.valueOf(p.paints()), String.valueOf(p.outs()), + String.valueOf(p.captures()), best == driver.me() && driver.interactive() ? "ScoreRowMe" + : k == 0 ? "ScoreRowFriendly" : "ScoreRowHostile")); + } + } + card.add(new Label(note, "ResultCaption")); + Container buttons = new Container(new GridLayout(1, 2)); + Button done = Ui.button("MAIN MENU", "OpsAction", "done", e -> close()); + if (driver instanceof PracticeDriver) { + Button again = Ui.button("PLAY AGAIN", "DeployButton", "again", e -> { + view.stop(); + driver.dispose(); + practice(m.arena().id + ":" + m.mode().name(), back); + }); + card.add(again); + } else if (matchId != null && matchId.length() > 0) { + Button watch = Ui.button("WATCH REPLAY", "OpsAction", "watch", e -> { + view.stop(); + driver.dispose(); + ReplayForm.show(matchId, back); + }); + buttons.add(watch); + } + if (buttons.getComponentCount() == 0) { + card.add(done); + } else { + buttons.add(done); + card.add(buttons); + } + Container dim = new Container(new BorderLayout(BorderLayout.CENTER_BEHAVIOR_CENTER)); + dim.setUIID("RunDim"); + if (CN.isDesktop()) { + // a scoreboard, not a phone card floating in a monitor + card.setPreferredW(Math.max(card.getPreferredW(), CN.getDisplayWidth() * 9 / 20)); + } + Container scroll = BoxLayout.encloseY(card); + scroll.setScrollableY(true); + scroll.setScrollVisible(false); + dim.add(BorderLayout.CENTER, scroll); + layers.add(dim); + form.revalidate(); + if (!(driver instanceof ReplayDriver)) { + Rewards.afterRun(() -> Ads.betweenRuns(Ui.adFree())); + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchLobby.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchLobby.java new file mode 100644 index 00000000000..a479c7e8e4a --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MatchLobby.java @@ -0,0 +1,432 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.InfiniteProgress; +import com.codename1.components.ShareButton; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.components.SpanLabel; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codenameone.examples.splat.api.GameProtocol; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codenameone.examples.splat.net.GameLink; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.rules.Json; +import com.codenameone.examples.splat.play.OnlineDriver; +import com.codenameone.examples.splat.play.Arenas; +import com.codenameone.examples.splat.play.Voice; +import java.util.Map; + +/// Getting into a live race. +/// +/// - **Quick race**: whoever is waiting for the same level; bots fill the +/// empty places after a few seconds, so nobody waits long. +/// - **Party**: a code and a link for friends. Everyone who opens it lands +/// here, can talk (voice, if the device has it, and text), and the host +/// starts the race when the crew is in. +/// +/// Every runner in a race runs the same level on the same seed: the track is +/// identical on every screen, and the race is decided by who runs it best. +// tag::splat-lobby[] +public final class MatchLobby implements GameLink.Listener, Voice.Listener { + private final String hello; + + /// Times a match carries on over a new socket before it gives up. + private static final int MAX_RECONNECTS = 3; + private final boolean spectator; + private GameLink link; + private OnlineDriver driver; + private boolean cancelled; + private Form form; + private ShareButton share; + private final Container members = new Container(BoxLayout.y()); + private final Container chat = new Container(BoxLayout.y()); + private Container page; + private final Label code = new Label("", "ChallengeCode"); + private final Label url = new Label("", "ChallengeUrl"); + private boolean host; + private String level = Arenas.DEFAULT; + private String[] names = new String[0]; + private String[] players = new String[0]; + private String[] talking = new String[0]; + private final java.util.Set mutedPeers = new java.util.HashSet(); + + private MatchLobby(String hello, boolean spectator) { + this.hello = hello; + this.spectator = spectator; + } + + /// A quick race on `level` (an official id or `pack/level`), or on the + /// game's featured race level when null. + public static void quick(String level) { + String l = level == null ? Arenas.DEFAULT : level; + MatchLobby r = new MatchLobby("{\"type\":\"" + GameProtocol.QUICK + "\",\"level\":" + Json.write(l) + "}", + false); + r.level = l; + r.waiting("Finding runners"); + } + + /// A party of one's own, on `level`. + public static void host(String level) { + host(level, null); + } + + /// A party for a crew: the server pushes the invite to the other members. + public static void host(String level, String crew) { + String l = level == null ? Arenas.DEFAULT : level; + MatchLobby r = new MatchLobby("{\"type\":\"" + GameProtocol.HOST + "\",\"level\":" + Json.write(l) + + (crew == null ? "" : ",\"crew\":" + Json.write(crew)) + "}", false); + r.level = l; + r.host = true; + r.party("PRIVATE LOBBY"); + } + + /// Someone else's party, from its link or code. + public static void join(String code) { + new MatchLobby("{\"type\":\"" + GameProtocol.JOIN + "\",\"code\":" + Json.write(code) + "}", false) + .party("Joining the party"); + } + + /// The admin console's spectator view of a live race. + public static void spectate(String raceId) { + new MatchLobby("{\"type\":\"" + GameProtocol.SPECTATE + "\",\"race\":" + Json.write(raceId) + "}", true) + .waiting("Connecting"); + } + + /// A "join with a code" prompt, for a friend who was told the code. + public static void enterCode() { + final Form f = Ui.form("Join a party", "joinParty"); + Ui.back(f, Nav::home); + final TextField field = new TextField("", "Party code", 12, TextField.ANY); + field.setName("partyCode"); + f.add(field); + f.add(Ui.primary("Join", "joinButton", e -> { + if (field.getText().trim().length() > 0) { + join(field.getText().trim().toUpperCase()); + } + })); + f.show(); + } + + private void waiting(String title) { + form = Ui.form(title, "lobby"); + Ui.back(form, this::cancel); + form.setLayout(BoxLayout.yCenter()); + form.setUIID("OpsForm"); + form.add(Ui.centered(new Label("SEARCHING FOR MATCH", "OpsTitle"))); + form.add(Ui.centered(new Label(level == null ? "PLAYLIST: ANY YARD, ANY MODE" : "PLAYLIST: " + + Ui.caps(com.codenameone.examples.splat.play.Arenas.arenaName(level)), "OpsCaption"))); + form.add(Ui.centered(new InfiniteProgress())); + form.add(Ui.centered(new Label("BOTS FILL EMPTY SLOTS AFTER 10 SECONDS", "MenuItemSub"))); + form.add(Ui.secondary("CANCEL", "cancel", e -> cancel())); + form.show(); + link = GameLink.open(this); + } + + private void party(String title) { + form = Ui.form(title, "party"); + form.setLayout(new BorderLayout()); + Ui.back(form, this::cancel); + Container top = Ui.column(); + top.add(Ui.label("PRIVATE LOBBY // CODE", "SectionTitle")); + code.setName("partyCode"); + top.add(Ui.centered(code)); + top.add(url); + share = new ShareButton(); + share.setText("INVITE SQUAD"); + share.setUIID("PrimaryButton"); + share.setName("shareParty"); + share.addActionListener(e -> Telemetry.challengeShared()); + // A material icon takes the button's own colour; the stock share + // image is a picture and sits on a light square on a dark button. + share.setMaterialIcon(FontImage.MATERIAL_SHARE); + Button copy = Ui.secondary("COPY LINK", "copyParty", e -> { + Display.getInstance().copyToClipboard(url.getText()); + Ui.toast("Copied"); + }); + top.add(share); + top.add(copy); + // a rule under the invite, so the roster scrolling under it reads as + // a list going under a header rather than rows cut off mid-button + Label rule = new Label(" ", "SectionRule"); + top.add(rule); + // The code and the invite stay put; the runners and the chat scroll + // under them, so the conversation is never squeezed into what the + // code card leaves and scrolling to a new line never hides the code. + Container below = Ui.column(); + + members.setName("members"); + below.add(members); + chat.setName("chat"); + below.add(chat); + page = BoxLayout.encloseY(Ui.centeredColumn(below)); + page.setScrollableY(true); + form.add(BorderLayout.NORTH, Ui.centeredColumn(top)); + form.add(BorderLayout.CENTER, page); + + final TextField say = new TextField("", "Comms", 40, TextField.ANY); + say.setName("chatField"); + say.setDoneListener(e -> { + String t = say.getText().trim(); + if (t.length() > 0 && link != null) { + link.send("{\"type\":\"" + GameProtocol.CHAT + "\",\"text\":" + Json.write(t) + "}"); + say.setText(""); + } + }); + final Button mic = Ui.button("", "HudButton", "mic", null); + mic.setMaterialIcon(FontImage.MATERIAL_MIC); + mic.setEnabled(Voice.available()); + mic.addActionListener(e -> { + Voice v = Voice.current(); + if (v != null) { + v.setMuted(!v.isMuted()); + mic.setMaterialIcon(v.isMuted() ? FontImage.MATERIAL_MIC_OFF : FontImage.MATERIAL_MIC); + } + }); + // The chat line and the buttons share the page's column, so their + // edges line up with the code card and the bubbles above them. + Container sayRow = new Container(new BorderLayout()); + sayRow.setUIID("LobbySay"); + sayRow.add(BorderLayout.CENTER, say); + sayRow.add(BorderLayout.EAST, mic); + Button leave = Ui.secondary("LEAVE", "cancel", e -> cancel()); + // Both buttons in one row from the start: a grid built for one + // child grows a second row for the next, not a second column. + Container actions = host + ? Ui.row(leave, Ui.primary("DEPLOY", "goButton", e -> link.send("{\"type\":\"" + + GameProtocol.GO + "\",\"level\":" + Json.write(level) + "}"))) + : Ui.row(leave); + Container bottom = Ui.centeredColumn(Ui.column(sayRow, actions)); + bottom.setSafeArea(true); + form.add(BorderLayout.SOUTH, bottom); + form.show(); + link = GameLink.open(this); + } + // end::splat-lobby[] + + private void cancel() { + cancelled = true; + Voice.stop(); + if (link != null) { + link.send("{\"type\":\"" + GameProtocol.CANCEL + "\"}"); + link.close(); + } + Nav.home(); + } + + @Override + public void message(Map m) { + if (cancelled) { + return; + } + String type = GameLink.text(m, "type"); + if (GameProtocol.READY.equals(type)) { + // A socket that rejoins a match says nothing: the server already + // knows the seat and sends the match, and the hello would queue + // the player for another one. + if (driver == null) { + link.send(hello); + } + } else if (GameProtocol.PARTY.equals(type)) { + party(m); + } else if (GameProtocol.START.equals(type) && driver == null) { + start(m); + } else if (GameProtocol.VOICE.equals(type)) { + Voice v = Voice.current(); + if (v != null) { + v.signal(GameLink.text(m, "from"), GameLink.text(m, "data")); + } + } else if (GameProtocol.MUTE.equals(type)) { + Voice v = Voice.current(); + if (v != null) { + v.mutedBy(GameLink.text(m, "from"), Boolean.TRUE.equals(m.get("on"))); + } + } else if (GameProtocol.CHAT.equals(type) && driver == null) { + // A bubble per message, the newest scrolled into view. + Container bubble = BoxLayout.encloseY(new Label(GameLink.text(m, "from"), "ChatFrom"), + new SpanLabel(GameLink.text(m, "text"), "ChatLine")); + bubble.setUIID("ChatBubble"); + chat.add(bubble); + form.revalidate(); + page.scrollComponentToVisible(bubble); + } else if (GameProtocol.ERROR.equals(type) || GameProtocol.KICKED.equals(type)) { + String why = GameProtocol.KICKED.equals(type) ? "You were removed from the race." + : GameLink.text(m, "message"); + Voice.stop(); + link.close(); + if (driver != null) { + // The match screen is up: it stops its view and leaves. + driver.lost(why); + return; + } + Ui.toast(why); + Nav.home(); + } else if (driver != null) { + driver.message(m); + } + } + + private String self = ""; + + private void party(Map m) { + code.setText(GameLink.text(m, "code")); + url.setText(GameLink.text(m, "url")); + level = GameLink.text(m, "level"); + if (share != null) { + share.setTextToShare("Come get PAINTED in Splat: " + url.getText()); + } + names = toStrings(m.get("names")); + players = toStrings(m.get("players")); + // The server names members by handle, never by account: an account + // name is an email address. + self = GameLink.text(m, "you"); + if (Voice.available() && Voice.current() == null && self != null && self.length() > 0) { + Voice.start(link, self).setListener(this); + } + if (Voice.current() != null) { + Voice.current().members(players); + } + drawMembers(); + } + + /// The pre-game lobby: your squad in blue down the left, the other side + /// in red down the right. A party is one team; matchmaking or bots fill + /// the other, so its slots read as open until the match starts. + private void drawMembers() { + members.removeAll(); + Container friends = new Container(BoxLayout.y()); + friends.add(new Label("YOUR SQUAD " + names.length + "/4", "RosterHeadFriendly")); + for (int i = 0; i < 4; i++) { + if (i >= names.length) { + friends.add(slot("OPEN SLOT", "RosterOpen", null)); + continue; + } + String u = i < players.length ? players[i] : ""; + boolean me = u.equals(self); + String state = me ? "" : contains(talking, u) ? (mutedPeers.contains(u) ? " MUTED" : " VOICE") : ""; + friends.add(slot(Ui.caps(names[i]) + state, "RosterFriendly", i == 0 ? "HOST" : null)); + } + Container hostiles = new Container(BoxLayout.y()); + hostiles.add(new Label("OPPOSITION 0/4", "RosterHeadHostile")); + for (int i = 0; i < 4; i++) { + hostiles.add(slot("SEARCHING...", "RosterHostile", null)); + } + members.add(com.codename1.ui.layouts.GridLayout.encloseIn(2, friends, hostiles)); + form.revalidateLater(); + } + + private static Container slot(String text, String uiid, String tag) { + Label l = new Label(text, "LobbyMemberName"); + Container row = BorderLayout.center(l); + row.setUIID(uiid); + if (tag != null) { + row.add(BorderLayout.EAST, new Label(tag, "LobbyHostTag")); + } + return row; + } + + /// The match is on: the field and the mode ship with the app, so it starts + /// at once. + private void start(final Map m) { + driver = new OnlineDriver(link, m, spectator); + if (Voice.current() != null) { + Voice.current().members(toStrings(m.get("players"))); + } + MatchForm.online(driver, () -> { + // The match screen is gone, so the socket closing after it is + // no news to anyone. + cancelled = true; + Voice.stop(); + Nav.home(); + }); + } + + @Override + public void closed(int closeCode) { + if (cancelled) { + return; + } + Voice.stop(); + if (driver == null) { + Ui.toast("Could not reach the game server."); + Nav.home(); + } else if (!driver.match().over()) { + if (!spectator && driver.reconnects() < MAX_RECONNECTS) { + // A blip -- a train tunnel, a wifi handover -- is not the end + // of a match: the seat is kept, so a new socket picks it up. + link = GameLink.open(this); + driver.relink(link); + return; + } + // Mid-match, nothing else will ever arrive: without this the + // match freezes once the phone is too far ahead of the server. + driver.lost("Lost the connection to the field."); + } + } + + @Override + public void peers(String[] connected) { + talking = connected; + if (driver == null && form != null) { + drawMembers(); + } + } + + @Override + public void muted(String username, boolean on) { + if (on) { + mutedPeers.add(username); + } else { + mutedPeers.remove(username); + } + if (driver == null && form != null) { + drawMembers(); + } + } + + private static boolean contains(String[] a, String v) { + for (String s : a) { + if (s.equals(v)) { + return true; + } + } + return false; + } + + private static String[] toStrings(Object list) { + if (!(list instanceof java.util.List)) { + return new String[0]; + } + java.util.List l = (java.util.List) list; + String[] out = new String[l.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = l.get(i) == null ? "" : l.get(i).toString(); + } + return out; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionCanvas.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionCanvas.java new file mode 100644 index 00000000000..fea7b85563d --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionCanvas.java @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codenameone.examples.splat.play.Type; +import com.codenameone.examples.splat.rules.Mission; +import com.codenameone.examples.splat.rules.Mode; + +/// The mission editor's field, from above: your half at the bottom in full, +/// the other half above the line as the mirror the game will build from it, +/// dimmed. Tap empty ground to drop the selected tool there, press on a prop, +/// spawn or the flag to pick it up and drag it. +/// +/// Everything is in the mission's decimetres; a 1 m grid snaps what is placed. +final class MissionCanvas extends Component { + /// What a tap on empty ground places. + static final String SPAWN = "spawn"; + static final String FLAG = "flag"; + + interface Listener { + void changed(); + } + + private final Mission mission; + private final Listener listener; + private String tool = "sandbags"; + /// The prop index being dragged, -2 a spawn, -3 the flag, -1 nothing. + private int held = -1; + private int heldSpawn = -1; + /// The last prop touched: what TURN and REMOVE act on. + private int selected = -1; + private float k = 1; + private int ox; + private int oy; + private boolean sideways; + + MissionCanvas(Mission mission, Listener listener) { + this.mission = mission; + this.listener = listener; + setName("missionCanvas"); + setFocusable(true); + getAllStyles().setBgTransparency(0); + } + + void setTool(String t) { + tool = t; + } + + String tool() { + return tool; + } + + int selected() { + return selected; + } + + void select(int i) { + selected = i; + repaint(); + } + + @Override + protected Dimension calcPreferredSize() { + // the whole field fits on screen with the tools around it + int h = CN.getDisplayHeight() * (CN.isDesktop() ? 55 : 46) / 100; + return new Dimension(Math.min(CN.getDisplayWidth(), h * Mission.WIDTH_M / Mission.DEPTH_M + 8), h); + } + + private void layoutField() { + int pad = CN.convertToPixels(2); + // On a wide window the field lies on its side -- your half on the + // left, theirs on the right -- so it fills the screen instead of + // standing as a narrow strip in the middle of it. + sideways = getWidth() > getHeight(); + int across = sideways ? Mission.DEPTH_M * 10 : Mission.WIDTH_M * 10; + int down = sideways ? Mission.WIDTH_M * 10 : Mission.DEPTH_M * 10; + k = Math.min((getWidth() - pad * 2) / (float) across, (getHeight() - pad * 2) / (float) down); + ox = getX() + (getWidth() - (int) (across * k)) / 2; + oy = getY() + (getHeight() - (int) (down * k)) / 2; + } + + /// Screen x of a field point: mirrored, as the game draws it. + private int sx(int x, int z) { + return sideways ? ox + (int) (z * k) : ox + (int) ((Mission.WIDTH_M * 10 - x) * k); + } + + private int sy(int x, int z) { + return sideways ? oy + (int) ((Mission.WIDTH_M * 10 - x) * k) + : oy + (int) ((Mission.DEPTH_M * 10 - z) * k); + } + + private int fx(int px, int py) { + return Mission.WIDTH_M * 10 - (int) (((sideways ? py - oy : px - ox)) / k); + } + + private int fz(int px, int py) { + return sideways ? (int) ((px - ox) / k) : Mission.DEPTH_M * 10 - (int) ((py - oy) / k); + } + + private static int snap(int v) { + return (v + 5) / 10 * 10; + } + + @Override + public void paint(Graphics g) { + layoutField(); + g.setAntiAliased(true); + int far = Mission.DEPTH_M * 10; + int wide = Mission.WIDTH_M * 10; + g.setColor(0x0e1216); + g.fillRect(getX(), getY(), getWidth(), getHeight()); + // the two halves, theirs darker + g.setColor(0x232a31); + rect(g, wide / 2, far / 4, wide, far / 2, true); + g.setColor(0x171c21); + rect(g, wide / 2, far * 3 / 4, wide, far / 2, true); + g.setColor(0x2c343c); + for (int m = 0; m <= Mission.WIDTH_M; m += 4) { + g.drawLine(sx(m * 10, 0), sy(m * 10, 0), sx(m * 10, far), sy(m * 10, far)); + } + for (int m = 0; m <= Mission.DEPTH_M; m += 4) { + g.drawLine(sx(0, m * 10), sy(0, m * 10), sx(wide, m * 10), sy(wide, m * 10)); + } + g.setColor(Type.AMBER); + g.drawLine(sx(0, far / 2), sy(0, far / 2), sx(wide, far / 2), sy(wide, far / 2)); + g.drawLine(sx(0, far / 2) + (sideways ? 1 : 0), sy(0, far / 2) + (sideways ? 0 : 1), + sx(wide, far / 2) + (sideways ? 1 : 0), sy(wide, far / 2) + (sideways ? 0 : 1)); + g.setColor(0x4f5862); + rect(g, wide / 2, far / 2, wide, far, false); + for (int pass = 0; pass < 2; pass++) { + boolean mirror = pass == 0; + for (int i = 0; i < mission.props.size(); i++) { + Mission.Prop p = mission.props.get(i); + int cx = mirror ? wide - p.x : p.x; + int cz = mirror ? far - p.z : p.z; + int w = p.width(); + int d = p.depth(); + g.setColor(mirror ? 0x3a424a : (i == selected ? 0x7d6a3a : 0x5d6873)); + rect(g, cx, cz, w, d, true); + g.setColor(mirror ? 0x4f5862 : (i == selected ? Type.AMBER : 0xa9b3bd)); + rect(g, cx, cz, w, d, false); + } + } + Font f = Type.bold(2.2f); + g.setFont(f); + int r = Math.max(CN.convertToPixels(1.3f), (int) (8 * k)); + for (int i = 0; i < mission.spawns.size(); i++) { + int[] s = mission.spawns.get(i); + dot(g, sx(s[0], s[1]), sy(s[0], s[1]), r, Type.FRIENDLY, String.valueOf(i + 1)); + dot(g, sx(wide - s[0], far - s[1]), sy(wide - s[0], far - s[1]), r, Type.HOSTILE, null); + } + if (mission.mode == Mode.CAPTURE_THE_FLAG) { + flag(g, sx(mission.baseX, mission.baseZ), sy(mission.baseX, mission.baseZ), r, Type.FRIENDLY); + flag(g, sx(wide - mission.baseX, far - mission.baseZ), sy(wide - mission.baseX, far - mission.baseZ), + r, Type.HOSTILE); + } else if (mission.mode == Mode.SNACK_BAR) { + int hr = (int) (40 * k); + g.setColor(Type.AMBER); + g.drawArc(sx(wide / 2, far / 2) - hr, sy(wide / 2, far / 2) - hr, hr * 2, hr * 2, 0, 360); + } + int left = Math.min(sx(0, 0), sx(wide, far)); + int top = Math.min(sy(0, 0), sy(wide, far)); + int bottom = Math.max(sy(0, 0), sy(wide, far)); + int right = Math.max(sx(0, 0), sx(wide, far)); + g.setColor(Type.STEEL); + String theirs = "ENEMY HALF (MIRRORED)"; + if (sideways) { + g.drawString(theirs, right - f.stringWidth(theirs) - 6, top + 4); + } else { + g.drawString(theirs, left + 6, top + 4); + } + g.setColor(Type.AMBER); + g.drawString("YOUR HALF", left + 6, bottom - f.getHeight() - 4); + } + + /// Fills or outlines a field rectangle centred on `cx, cz`, `w` across + /// the field and `d` deep, in whichever way the field lies on screen. + private void rect(Graphics g, int cx, int cz, int w, int d, boolean fill) { + int x0 = sx(cx - w / 2, cz - d / 2); + int y0 = sy(cx - w / 2, cz - d / 2); + int x1 = sx(cx + w / 2, cz + d / 2); + int y1 = sy(cx + w / 2, cz + d / 2); + int x = Math.min(x0, x1); + int y = Math.min(y0, y1); + int rw = Math.max(3, Math.abs(x1 - x0)); + int rh = Math.max(3, Math.abs(y1 - y0)); + if (fill) { + g.fillRect(x, y, rw, rh); + } else { + g.drawRect(x, y, rw, rh); + } + } + + private static void dot(Graphics g, int x, int y, int r, int color, String label) { + g.setColor(color); + g.fillArc(x - r, y - r, r * 2, r * 2, 0, 360); + if (label != null) { + g.setColor(Type.INK); + g.drawString(label, x - g.getFont().stringWidth(label) / 2, y - g.getFont().getHeight() / 2); + } + } + + private static void flag(Graphics g, int x, int y, int r, int color) { + g.setColor(Type.AMBER); + g.fillRect(x - 1, y - r * 2, 3, r * 2); + g.setColor(color); + g.fillTriangle(x + 2, y - r * 2, x + 2, y - r, x + r * 2, y - r * 3 / 2); + } + + @Override + public void pointerPressed(int ax, int ay) { + // pointer events are absolute; the field is laid out in paint's + // parent-relative coordinates + int px = ax - getAbsoluteX() + getX(); + int py = ay - getAbsoluteY() + getY(); + layoutField(); + int x = fx(px, py); + int z = fz(px, py); + held = -1; + heldSpawn = -1; + int grab = (int) (CN.convertToPixels(3) / k); + for (int i = 0; i < mission.spawns.size(); i++) { + int[] s = mission.spawns.get(i); + if (Math.abs(s[0] - x) < grab && Math.abs(s[1] - z) < grab) { + held = -2; + heldSpawn = i; + return; + } + } + if (mission.mode == Mode.CAPTURE_THE_FLAG && Math.abs(mission.baseX - x) < grab + && Math.abs(mission.baseZ - z) < grab) { + held = -3; + return; + } + int hit = mission.propAt(x, z, 0); + if (hit >= 0) { + held = hit; + selected = hit; + repaint(); + return; + } + if (z > Mission.DEPTH_M * 5) { + return; + } + x = Math.max(10, Math.min(Mission.WIDTH_M * 10 - 10, x)); + z = Math.max(10, z); + if (SPAWN.equals(tool)) { + if (mission.spawns.size() < Mission.MAX_SPAWNS) { + mission.spawns.add(new int[] {snap(x), snap(z)}); + held = -2; + heldSpawn = mission.spawns.size() - 1; + } + } else if (FLAG.equals(tool)) { + mission.baseX = snap(x); + mission.baseZ = snap(z); + held = -3; + } else if (mission.props.size() < Mission.MAX_PROPS) { + mission.props.add(inside(new Mission.Prop(tool, snap(x), snap(z), false))); + held = mission.props.size() - 1; + selected = held; + } + changed(); + } + + @Override + public void pointerDragged(int ax, int ay) { + if (held == -1) { + return; + } + int px = ax - getAbsoluteX() + getX(); + int py = ay - getAbsoluteY() + getY(); + layoutField(); + int x = snap(Math.max(0, Math.min(Mission.WIDTH_M * 10, fx(px, py)))); + int z = snap(Math.max(0, Math.min(Mission.DEPTH_M * 5, fz(px, py)))); + if (held == -2) { + mission.spawns.set(heldSpawn, new int[] {x, z}); + } else if (held == -3) { + mission.baseX = x; + mission.baseZ = z; + } else { + Mission.Prop p = mission.props.get(held); + mission.props.set(held, inside(new Mission.Prop(p.kind, x, z, p.turned))); + } + changed(); + } + + @Override + public void pointerReleased(int px, int py) { + held = -1; + heldSpawn = -1; + } + + /// The prop moved, if need be, so all of it is inside your half. + private static Mission.Prop inside(Mission.Prop p) { + int hw = (p.width() + 1) / 2; + int hd = (p.depth() + 1) / 2; + int x = Math.max(hw, Math.min(Mission.WIDTH_M * 10 - hw, p.x)); + int z = Math.max(hd, Math.min(Mission.DEPTH_M * 5 - hd, p.z)); + return x == p.x && z == p.z ? p : new Mission.Prop(p.kind, x, z, p.turned); + } + + /// Turns the selected prop a quarter. + void turnSelected() { + if (selected >= 0 && selected < mission.props.size()) { + Mission.Prop p = mission.props.get(selected); + mission.props.set(selected, inside(new Mission.Prop(p.kind, p.x, p.z, !p.turned))); + changed(); + } + } + + /// Takes away the selected prop, or the last spawn when no prop is selected. + void removeSelected() { + if (selected >= 0 && selected < mission.props.size()) { + mission.props.remove(selected); + selected = -1; + } else if (mission.spawns.size() > 1) { + mission.spawns.remove(mission.spawns.size() - 1); + } + changed(); + } + + private void changed() { + repaint(); + listener.changed(); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionEditor.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionEditor.java new file mode 100644 index 00000000000..3e9582efdaa --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionEditor.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.io.Storage; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.splat.rules.Mission; +import com.codenameone.examples.splat.rules.Mode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// The mission editor: lay out your half of a field, pick the mode, write +/// the briefing, play-test it against bots, and publish it so your friends +/// and your party can play it. +/// +/// The draft is kept on the device as JSON after every change, so leaving +/// the screen loses nothing. +// tag::splat-mission-editor[] +public final class MissionEditor { + static final String DRAFT = "splat-mission-draft.json"; + /// The arena code a play-test runs under; publishing gives the real one. + static final String TEST_CODE = "draft"; + + private MissionEditor() { + } + + public static void show() { + open(loadDraft()); + } + + static void open(final Mission mission) { + final Form f = Ui.form("MISSION EDITOR", "missionEditor"); + f.setLayout(new BorderLayout()); + f.setScrollableY(false); + Ui.back(f, MissionsForm::show); + final Label status = new Label(" ", "EditorStatus"); + status.setName("editorStatus"); + final MissionCanvas[] canvas = new MissionCanvas[1]; + canvas[0] = new MissionCanvas(mission, () -> { + saveDraft(mission); + String p = mission.problem(); + status.setText(p == null ? mission.props.size() + " PROPS // " + mission.spawns.size() + + " SPAWNS // READY" : Ui.caps(p)); + }); + + TextField name = new TextField(mission.name, "Operation name", 20, TextArea.ANY); + name.setName("missionName"); + name.setUIID("EditorField"); + name.addDataChangedListener((t, i) -> { + mission.name = name.getText(); + saveDraft(mission); + }); + Container modes = new Container(new GridLayout(1, 3)); + final Button[] modeButtons = new Button[Mode.values().length]; + for (int i = 0; i < modeButtons.length; i++) { + final Mode m = Mode.values()[i]; + String label = m == Mode.TEAM_PAINT ? "TEAM" : m == Mode.CAPTURE_THE_FLAG ? "FLAG" : "SNACK BAR"; + modeButtons[i] = Ui.button(label, mission.mode == m ? "EditorChipOn" : "EditorChip", + "mode-" + m.name(), e -> { + mission.mode = m; + for (int k = 0; k < modeButtons.length; k++) { + modeButtons[k].setUIID(Mode.values()[k] == m ? "EditorChipOn" : "EditorChip"); + } + saveDraft(mission); + canvas[0].repaint(); + f.revalidate(); + }); + modes.add(modeButtons[i]); + } + + // The palette: one chip per prop, then the spawn and flag tools. + final String[] tools = new String[Mission.KINDS.length + 2]; + System.arraycopy(Mission.KINDS, 0, tools, 0, Mission.KINDS.length); + tools[tools.length - 2] = MissionCanvas.SPAWN; + tools[tools.length - 1] = MissionCanvas.FLAG; + final Button[] chips = new Button[tools.length]; + Container strip = new Container(BoxLayout.x()); + strip.setScrollableX(true); + for (int i = 0; i < tools.length; i++) { + final String t = tools[i]; + chips[i] = Ui.button(Ui.caps(t.replace('-', ' ')), t.equals(canvas[0].tool()) ? "EditorChipOn" + : "EditorChip", "tool-" + t, e -> { + canvas[0].setTool(t); + for (int k = 0; k < chips.length; k++) { + chips[k].setUIID(tools[k].equals(t) ? "EditorChipOn" : "EditorChip"); + } + f.revalidate(); + }); + strip.add(chips[i]); + } + Container edit = new Container(new GridLayout(1, 2)); + edit.add(Ui.secondary("TURN", "turnProp", e -> canvas[0].turnSelected())); + edit.add(Ui.secondary("REMOVE", "removeProp", e -> canvas[0].removeSelected())); + + TextArea briefing = new TextArea(mission.briefing, 3, 30); + briefing.setName("missionBriefing"); + briefing.setUIID("EditorField"); + briefing.setHint("Briefing: what the narrator reads before the match"); + briefing.addDataChangedListener((t, i) -> { + mission.briefing = briefing.getText(); + saveDraft(mission); + }); + + Container go = new Container(new GridLayout(1, 2)); + go.add(Ui.secondary("PLAY TEST", "playTest", e -> playTest(mission))); + go.add(Ui.primary("PUBLISH", "publishMission", e -> MissionsForm.publish(mission))); + if (CN.isDesktop()) { + // A desk has the room for an editor laid out like one: the field + // takes the whole window and the tools stand in a panel at the + // side. Squeezed into the reading column the other screens use, + // the field was a small square in the middle of a wide screen. + Container palette = new Container(new GridLayout(Math.max(1, (tools.length + 1) / 2), 2)); + for (int i = 0; i < chips.length; i++) { + chips[i].remove(); + palette.add(chips[i]); + } + Container side = BoxLayout.encloseY(name, modes, palette, edit, briefing); + side.setScrollableY(true); + Container panel = new Container(new BorderLayout()); + panel.setUIID("EditorPanel"); + panel.add(BorderLayout.CENTER, side).add(BorderLayout.SOUTH, go); + int w = Math.max(CN.convertToPixels(70), f.getWidth() > 0 ? f.getWidth() / 4 : CN.getDisplayWidth() / 4); + panel.setPreferredW(w); + Container field = new Container(new BorderLayout()); + field.add(BorderLayout.CENTER, canvas[0]).add(BorderLayout.SOUTH, status); + f.add(BorderLayout.CENTER, field).add(BorderLayout.EAST, panel); + } else { + Container middle = new Container(BoxLayout.y()); + middle.setScrollableY(true); + f.add(BorderLayout.NORTH, BoxLayout.encloseY(name, modes, strip)); + middle.add(canvas[0]).add(status).add(edit).add(briefing); + f.add(BorderLayout.CENTER, middle); + f.add(BorderLayout.SOUTH, go); + } + f.show(); + String p = mission.problem(); + status.setText(p == null ? "DRAG TO MOVE // TAP GROUND TO PLACE" : Ui.caps(p)); + } + + /// Plays the draft at once against bots, as it would be played online. + static void playTest(Mission mission) { + String p = mission.problem(); + if (p != null) { + Ui.toast(p); + return; + } + Mission test = Mission.fromJson(mission.toJson()); + test.code = TEST_CODE; + test.register(); + MatchForm.practice(test.arenaId() + ":" + test.mode.name(), () -> open(mission)); + } + + static Mission loadDraft() { + if (Storage.getInstance().exists(DRAFT)) { + try { + InputStream in = Storage.getInstance().createInputStream(DRAFT); + try { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + int n; + while ((n = in.read(b)) > 0) { + buf.write(b, 0, n); + } + return Mission.fromJson(new String(buf.toByteArray(), "UTF-8")); + } finally { + in.close(); + } + } catch (IOException | IllegalArgumentException e) { + // an unreadable draft starts over + } + } + return Mission.blank(); + } + + static void saveDraft(Mission mission) { + try { + OutputStream out = Storage.getInstance().createOutputStream(DRAFT); + try { + byte[] b = mission.toJson().getBytes("UTF-8"); + out.write(b); + } finally { + out.close(); + } + } catch (IOException e) { + // the draft is a convenience; the editor still has the mission + } + } +} +// end::splat-mission-editor[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionsForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionsForm.java new file mode 100644 index 00000000000..6f99cee2c1c --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/MissionsForm.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.splat.net.MissionsClient; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.rules.Mission; + +/// The Mission Builder's front page: your draft, and the missions you and +/// your friends have published. A published mission plays like any yard -- +/// practice against bots, or take a party into it. +public final class MissionsForm { + private MissionsForm() { + } + + public static void show() { + final Form f = Ui.form("MISSION BUILDER", "missions"); + Ui.back(f, Nav::home); + f.add(Ui.text("Design one half of a field. The other half is its mirror, so every mission is fair. " + + "Play-test it against bots, then publish it for your friends and your party.", "OpsBriefing")); + f.add(Ui.primary("CONTINUE DRAFT", "editDraft", e -> MissionEditor.show())); + f.add(Ui.secondary("START A NEW FIELD", "newMission", e -> { + Mission m = Mission.blank(); + MissionEditor.saveDraft(m); + MissionEditor.open(m); + })); + f.add(new Label("// PUBLISHED BY YOU AND YOUR FRIENDS", "OpsKicker")); + final Container list = new Container(BoxLayout.y()); + list.setName("missionList"); + f.add(list); + if (Session.user() == null) { + list.add(new Label("SIGN IN TO SEE PUBLISHED MISSIONS", "MissionRowSub")); + } else { + list.add(new Label("LOADING...", "MissionRowSub")); + MissionsClient.list(rows -> { + list.removeAll(); + if (rows.isEmpty()) { + list.add(new Label("NOTHING YET. PUBLISH THE FIRST ONE.", "MissionRowSub")); + } + for (MissionsClient.Row r : rows) { + list.add(row(r)); + } + f.revalidate(); + }); + } + if (CN.isDesktop()) { + Ui.desktopColumn(f); + } + f.show(); + } + + private static Container row(final MissionsClient.Row r) { + Container buttons = new Container(new GridLayout(1, Session.user() == null ? 1 : 2)); + buttons.add(Ui.button("PRACTICE", "OpsAction", "practiceMission-" + r.code, e -> play(r.code, false))); + if (Session.user() != null) { + buttons.add(Ui.button("PARTY", "OpsAction", "partyMission-" + r.code, e -> play(r.code, true))); + } + Container c = BoxLayout.encloseY(new Label(Ui.caps(r.name), "MissionRowTitle"), + new Label(Ui.caps(r.owner + " // " + r.modeTitle + " // " + r.plays + " PLAYS"), "MissionRowSub"), + buttons); + c.setUIID("MissionRow"); + c.setName("missionRow-" + r.code); + return c; + } + + /// Fetches the mission, makes it a playable arena, and goes in. + static void play(String code, final boolean party) { + MissionsClient.get(code, m -> { + if (m == null) { + Ui.toast("That mission could not be loaded."); + return; + } + m.register(); + String ref = m.arenaId() + ":" + m.mode.name(); + if (party) { + MatchLobby.host(ref); + } else { + MatchForm.practice(ref, MissionsForm::show); + } + }); + } + + /// Publishes the editor's mission, after the same check the server makes. + static void publish(Mission mission) { + String p = mission.problem(); + if (p != null) { + Ui.toast(p); + return; + } + if (Session.user() == null) { + Ui.toast("Sign in to publish."); + return; + } + MissionsClient.publish(mission, code -> { + if (code == null) { + Ui.toast("Publishing failed. Try again."); + return; + } + Ui.toast("Published as " + code + ". Your friends can play it now."); + show(); + }); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Nav.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Nav.java new file mode 100644 index 00000000000..08ec838c5e6 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Nav.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.CN; +import com.codenameone.examples.splat.AppConfig; +import com.codenameone.examples.splat.admin.AdminConsole; +import com.codenameone.examples.splat.engage.Friends; +import com.codenameone.examples.splat.engage.Notifications; +import com.codenameone.examples.splat.engage.Store; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Session; + +/// Where the app goes. Every screen change that is not a plain "back" comes +/// through here, so a push notification, a challenge link and a button all +/// land on the same screen the same way. +public final class Nav { + private static String pendingChallenge; + + private Nav() { + } + + /// The first screen: the stored session decides between home and sign-in. + public static void launch() { + pendingChallenge = AppConfig.challengeCode(); + Session.onSignedOut(() -> CN.callSerially(SignInForm::show)); + Session.restore(() -> CN.callSerially(Nav::enter), + () -> CN.callSerially(SignInForm::show), + () -> CN.callSerially(() -> { + SignInForm.show(); + Ui.toast("The server did not answer. Is it running?"); + })); + } + + /// Signed in: a challenge or pack link opened the app wins, then the admin + /// console for an administrator who was last there, then home. + public static void enter() { + Notifications.signedIn(); + Store.resend(); + if (pendingChallenge != null) { + String code = pendingChallenge; + pendingChallenge = null; + challenge(code); + return; + } + if (Session.user() != null && Session.user().admin() + && "admin".equals(AppConfig.mode(Session.user().username()))) { + adminConsole(); + return; + } + home(); + } + + public static void signedIn() { + Telemetry.signedIn(); + Friends.claimPending(); + enter(); + } + + public static void home() { + AppConfig.setMode("player", Session.user() == null ? "" : Session.user().username()); + HomeForm.show(); + } + + public static void challenge(String code) { + MatchLobby.join(code); + } + + public static void adminConsole() { + AppConfig.setMode("admin", Session.user().username()); + AdminConsole.show(); + } + + /// A screen named by a push or the admin's broadcast composer; the names + /// are the server's `Promotion.SCREENS`. + public static void screen(String name) { + if ("shop".equals(name)) { + shop(); + } else if ("friends".equals(name)) { + FriendsForm.show(); + } else if ("loadout".equals(name)) { + LoadoutForm.show(); + } else if ("play".equals(name)) { + PlayForm.show(); + } else if ("crews".equals(name)) { + CrewsForm.show(); + } else { + home(); + } + } + + public static void shop() { + ShopForm.show(); + } + + public static void signOut() { + Notifications.signingOut(() -> Session.signOut(() -> CN.callSerially(SignInForm::show))); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/PlayForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/PlayForm.java new file mode 100644 index 00000000000..29a1ce9b58c --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/PlayForm.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.play.Arenas; +import com.codenameone.examples.splat.rules.Catalog; +import com.codenameone.examples.splat.rules.Mode; + +/// Operations: the playlist down the left, every yard in every mode, and the +/// selected operation's briefing on the right -- the overhead map, the orders +/// and three ways in: deploy into a quick match online, take your party, or +/// practise against bots with no network at all. +// tag::splat-operations[] +public final class PlayForm { + private PlayForm() { + } + + public static void show() { + Form f = Ui.form("OPERATIONS", "play"); + f.setLayout(new BorderLayout()); + f.setUIID("OpsForm"); + Ui.back(f, Nav::home); + final String[] refs = Arenas.all(); + final Container detail = new Container(new BorderLayout()); + detail.setUIID("OpsDetail"); + final Container list = new Container(BoxLayout.y()); + list.setUIID("OpsList"); + list.setScrollableY(true); + final Container[] rows = new Container[refs.length]; + for (int i = 0; i < refs.length; i++) { + final String ref = refs[i]; + final int index = i; + Mode mode = Arenas.mode(ref); + Button pick = Ui.button(Ui.caps(mode.operation), "OpsRowTitle", "mission-" + ref, null); + rows[i] = BoxLayout.encloseY(pick, new Label(Ui.caps(Arenas.arenaName(ref) + " // " + mode.title), + "OpsRowSub")); + rows[i].setUIID("OpsRow"); + rows[i].setLeadComponent(pick); + pick.addActionListener(e -> { + for (int k = 0; k < rows.length; k++) { + rows[k].setUIID(k == index ? "OpsRowOn" : "OpsRow"); + } + brief(detail, ref); + f.revalidate(); + }); + list.add(rows[i]); + } + rows[0].setUIID("OpsRowOn"); + brief(detail, refs[0]); + boolean wide = CN.getDisplayWidth() > CN.getDisplayHeight(); + if (wide) { + Container split = new Container(new GridLayout(1, 2)); + split.add(list).add(detail); + f.add(BorderLayout.CENTER, split); + } else { + f.add(BorderLayout.NORTH, list); + f.add(BorderLayout.CENTER, detail); + list.setPreferredH(CN.getDisplayHeight() / 3); + } + f.show(); + } + + /// The briefing for `ref`: map, orders, and the three ways in. + private static void brief(Container detail, final String ref) { + detail.removeAll(); + Mode mode = Arenas.mode(ref); + String arenaId = ref.substring(0, ref.indexOf(':')); + Container head = BoxLayout.encloseY(new Label("// OPERATION BRIEFING", "OpsKicker"), + new Label(Ui.caps(mode.operation.replace("Operation: ", "")), "OpsTitle"), + new Label(Ui.caps(Arenas.arenaName(ref)) + " " + Ui.caps(mode.title) + " FIRST TO " + mode.target, + "OpsCaption")); + detail.add(BorderLayout.NORTH, head); + Container mid = new Container(BoxLayout.y()); + mid.setScrollableY(true); + mid.add(new TacticalMap(Catalog.arena(arenaId), mode)); + mid.add(Ui.text(briefing(mode), "OpsBriefing")); + detail.add(BorderLayout.CENTER, mid); + Container buttons = new Container(BoxLayout.y()); + boolean online = Session.user() != null; + if (online) { + buttons.add(Ui.button("DEPLOY", "DeployButton", "quick-" + ref, e -> MatchLobby.quick(ref))); + } + Container two = new Container(new GridLayout(1, online ? 2 : 1)); + if (online) { + two.add(Ui.button("PARTY", "OpsAction", "party-" + ref, e -> MatchLobby.host(ref))); + } + two.add(Ui.button("PRACTICE VS BOTS", "OpsAction", "practice-" + ref, + e -> MatchForm.practice(ref, PlayForm::show))); + buttons.add(two); + detail.add(BorderLayout.SOUTH, buttons); + } + + /// The mission brief, in the voice of a man who has watched too many films. + static String briefing(Mode mode) { + switch (mode) { + case CAPTURE_THE_FLAG: + return "Intelligence confirms the enemy has a bedsheet on a pole. Their mother wants it back " + + "by six. Bring it home. Leave no snack behind."; + case SNACK_BAR: + return "The snack bar is the only source of nachos for three counties. Hold it. " + + "Every second it is ours, history remembers. History is also the scoreboard."; + default: + return "Two teams enter. Both leave, because it is paintball and nobody actually dies. " + + "First team to paint the target wins. Goggles on at all times. This is not a drill."; + } + } +} +// end::splat-operations[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/RegisterForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/RegisterForm.java new file mode 100644 index 00000000000..ed9ad9ddeb6 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/RegisterForm.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codenameone.examples.splat.api.RegisterDto; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; + +/// Creating an account. The player name is the one piece of text a player +/// types that everyone else sees, so its field is styled as a banner -- the +/// `NameField` UIID in `theme.css` -- and previews the name as it will appear +/// over the arena. +// tag::splat-name-field[] +public final class RegisterForm { + private RegisterForm() { + } + + public static void show() { + Form f = Ui.form("New player", "register"); + Ui.desktopColumn(f); + Ui.back(f, SignInForm::show); + final TextField name = new TextField("", "Your callsign", 16, TextField.ANY); + name.setUIID("NameField"); + name.setName("displayName"); + name.setMaxSize(16); + final TextField email = SignInForm.field("Email", TextField.EMAILADDR, "email"); + final TextField password = SignInForm.field("Password (8+ characters)", TextField.PASSWORD, + "password"); + Container col = Ui.column(); + col.add(Ui.label("CHOOSE YOUR CALLSIGN", "SectionTitle")); + col.add(Ui.panel(name)); + col.add(Ui.panel(email, password, Ui.primary("Start running", "createButton", e -> { + final String mail = email.getText().trim(); + final String pass = password.getText(); + Api.account().register(new RegisterDto(mail, pass, name.getText().trim()), + Net.to(player -> { + Telemetry.registered(); + Session.signIn(mail, pass, () -> Ui.later(Nav::signedIn), Ui::failed); + }, Ui::failed)); + }))); + f.add(Ui.centeredColumn(col)); + f.show(); + } +} +// end::splat-name-field[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/ReplayForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/ReplayForm.java new file mode 100644 index 00000000000..fe1e4d4fa53 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/ReplayForm.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codenameone.examples.splat.api.ReplayDto; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.rules.Replay; +import com.codenameone.examples.splat.rules.RulesVersion; +import java.util.List; + +/// A finished match, simulated again on the phone from its seed and the human +/// inputs. The server sends no video and no positions: the file is a few +/// kilobytes, and the bots replay their own decisions because they think inside +/// the rules. +// tag::splat-replay-form[] +public final class ReplayForm { + private ReplayForm() { + } + + /// Fetches match `id` and plays it, following the signed-in player. + public static void show(String id, final Runnable back) { + Api.game().replay(id, Net.to(r -> Ui.later(() -> open(r, back)), Ui::failed)); + } + + public static void load(String id) { + show(id, () -> { + if (Session.user() == null) { + SignInForm.show(); + } else { + HistoryForm.show(); + } + }); + } + + public static void open(ReplayDto r, Runnable back) { + Replay replay; + try { + replay = Replay.decode(r.replay()); + } catch (RuntimeException bad) { + Ui.toast("This replay could not be read."); + return; + } + if (replay.rulesVersion() != RulesVersion.ID) { + Ui.toast("This match was played on older rules and can no longer be replayed."); + return; + } + MatchForm.replay(replay, follow(r.names()), back == null ? HistoryForm::show : back); + } + + private static int follow(List names) { + if (names == null || Session.user() == null) { + return 0; + } + for (int i = 0; i < names.size(); i++) { + if (Session.user().displayName().equals(names.get(i))) { + return i; + } + } + return 0; + } +} +// end::splat-replay-form[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Rewards.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Rewards.java new file mode 100644 index 00000000000..0f9d8828aa4 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Rewards.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.FontImage; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.splat.api.AchievementDto; +import com.codenameone.examples.splat.api.ProgressDto; +import com.codenameone.examples.splat.api.RewardDto; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import java.util.ArrayList; +import java.util.List; + +/// The player's progress as the client last saw it, and the celebrations when +/// it moves: a reward popup with a hero in it, a level-up, a new achievement. +/// +/// The server decides every reward. This class only remembers the previous +/// [ProgressDto] so that, after a run, it can tell what changed and say so. +// tag::splat-rewards[] +public final class Rewards { + private static ProgressDto last; + + private Rewards() { + } + + /// The progress last fetched, or null before the first fetch. + public static ProgressDto progress() { + return last; + } + + /// Fetches the progress; `done` runs on the EDT either way. + public static void refresh(final Runnable done) { + if (Session.user() == null) { + Ui.later(done); + return; + } + Api.progress().progress(Net.to(p -> Ui.later(() -> { + last = p; + done.run(); + }), (s, m) -> Ui.later(done))); + } + + /// Fetches the progress and celebrates what changed since the last fetch: + /// levels gained and achievements unlocked by the run just played. + public static void afterRun(final Runnable done) { + afterRun(done, null); + } + + /// [#afterRun(Runnable)], celebrating on `host` when it is not null. + public static void afterRun(final Runnable done, final Host host) { + final ProgressDto before = last; + refresh(() -> { + ProgressDto now = last; + if (before == null || now == null) { + done.run(); + return; + } + List unlocked = new ArrayList(); + for (AchievementDto a : now.achievements()) { + if (a.unlocked() && !wasUnlocked(before, a.id())) { + unlocked.add(a.title()); + } + } + int gainedXp = now.xp() - before.xp(); + int chests = Math.max(0, now.chests() - before.chests()); + if (now.level() > before.level() || !unlocked.isEmpty() || chests > 0) { + show(new RewardDto(now.level() > before.level() ? "Level up!" : "Run rewards", 0, 0, + gainedXp, chests, now.level(), now.level() > before.level(), unlocked), done, host); + } else { + done.run(); + } + }); + } + + /// Somewhere other than a dialog to show a card: the run screen lays it + /// over its own scene, because a dialog over the GPU view captures the + /// form behind it and the 3D scene is not in that capture -- the card + /// sat on flat grey. + public interface Host { + /// Shows `card` and answers what takes it away again. + Runnable open(Component card); + } + + /// A reward the server just granted, shown with the hero cheering. + public static void show(RewardDto r, final Runnable done) { + show(r, done, null); + } + + /// [#show(RewardDto, Runnable)] on `host`, or in a dialog when it is null. + public static void show(RewardDto r, final Runnable done, Host host) { + Telemetry.screen(r.levelUp() ? "levelUp" : "reward"); + final Container d = new Container(BoxLayout.y()); + d.setName("rewardDialog"); + d.setUIID(host == null ? "RewardBody" : "RewardCard"); + // Wraps rather than truncating "Training co...". + SpanLabel title = Ui.text(r.title(), r.levelUp() ? "LevelUpTitle" : "RewardTitle"); + title.setTextBlockAlign(Component.CENTER); + d.add(title); + // A rank-up is the one moment the whole card turns amber. + d.add(FlowLayout.encloseCenter(new Label(r.levelUp() ? "PROMOTED" : r.chests() > 0 && !r.levelUp() + ? "SUPPLY DROP" : "AFTER ACTION REPORT", "RewardKicker"))); + if (r.levelUp()) { + d.add(FlowLayout.encloseCenter(new Label("RANK " + r.level(), "LevelBadgeBig"))); + } + Container items = new Container(new FlowLayout(Component.CENTER)); + if (r.coins() > 0) { + Label coinsWon = Kit.countUp("+", r.coins(), "RewardCoins"); + coinsWon.setMaterialIcon(FontImage.MATERIAL_MONETIZATION_ON); + // The icon widens the label: size it again for the final amount. + coinsWon.setText("+" + r.coins()); + coinsWon.setPreferredSize(null); + coinsWon.setPreferredSize(coinsWon.getPreferredSize()); + coinsWon.setText("+0"); + items.add(coinsWon); + } + if (r.gems() > 0) { + items.add(chip(FontImage.MATERIAL_DIAMOND, "+" + r.gems(), "RewardGems")); + } + if (r.xp() > 0) { + items.add(chip(FontImage.MATERIAL_STAR, "+" + r.xp() + " XP", "RewardXp")); + } + if (r.chests() > 0) { + items.add(chip(FontImage.MATERIAL_INVENTORY_2, "+" + r.chests() + " SUPPLY DROP", "RewardChest")); + } + d.add(items); + if (r.unlocked() != null) { + for (String a : r.unlocked()) { + Label l = new Label("Achievement: " + a, "RewardAchievement"); + l.setMaterialIcon(FontImage.MATERIAL_MILITARY_TECH); + d.add(FlowLayout.encloseCenter(l)); + } + } + final Runnable[] close = new Runnable[1]; + Button collect = Ui.primary("Collect", "collectReward", e -> { + close[0].run(); + Session.refreshUser(() -> Ui.later(done), (s, m) -> Ui.later(done)); + }); + d.add(collect); + // A wrapping title packs to its narrowest, so an empty strut gives the + // card its width. + Label strut = new Label("", "Container"); + strut.setPreferredW(Math.min(Ui.mm(70), CN.getDisplayWidth() - CN.convertToPixels(16))); + strut.setPreferredH(1); + d.add(strut); + if (host != null) { + close[0] = host.open(d); + return; + } + final Dialog dlg = new Dialog(new BorderLayout()); + dlg.setDialogUIID("RewardDialog"); + dlg.getContentPane().setUIID("Container"); + dlg.getTitleComponent().setUIID("Container"); + // A fade, not the pulsate transition: pulsate scales a snapshot of the + // dialog whose rounded corners were captured opaque, so it spun in on + // a white box. + dlg.setTransitionInAnimator(CommonTransitions.createFade(250)); + dlg.setTransitionOutAnimator(CommonTransitions.createFade(200)); + dlg.setDisposeWhenPointerOutOfBounds(false); + // A scrolling body: showPacked clamped the height first, and the last + // child -- the button -- absorbed the shortfall. + dlg.getContentPane().setScrollableY(true); + dlg.add(BorderLayout.CENTER, d); + close[0] = dlg::dispose; + dlg.showPacked(BorderLayout.CENTER, true); + } + + /// A short line under a celebration: how far the player is from the next + /// level. + static SpanLabel nextLevelHint(ProgressDto p) { + return Ui.text((p.levelEnd() - p.xp()) + " XP to level " + (p.level() + 1), "Muted"); + } + + private static Label chip(char icon, String text, String uiid) { + Label l = new Label(text, uiid); + l.setMaterialIcon(icon); + return l; + } + + private static boolean wasUnlocked(ProgressDto p, String id) { + for (AchievementDto a : p.achievements()) { + if (a.id().equals(id)) { + return a.unlocked(); + } + } + return false; + } +} +// end::splat-rewards[] diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/SettingsForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/SettingsForm.java new file mode 100644 index 00000000000..7aaa93e78de --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/SettingsForm.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.Switch; +import com.codename1.io.Preferences; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.splat.AppConfig; +import com.codenameone.examples.splat.api.ProfileDto; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.play.Music; +import com.codenameone.examples.splat.play.Sfx; +import com.codenameone.examples.splat.engage.Notifications; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; + +/// Settings: the callsign, sound, music, analytics consent, push, the server. +public final class SettingsForm { + static final String SOUND_KEY = "splat.sound"; + static final String MUSIC_KEY = "splat.music"; + + private SettingsForm() { + } + + /// Applies what was chosen in an earlier run. + public static void apply() { + Sfx.setMuted(!Preferences.get(SOUND_KEY, true)); + Music.setMuted(!Preferences.get(MUSIC_KEY, true)); + } + + public static void show() { + Form f = Ui.form("Settings", "settings"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + final TextField name = new TextField(Session.user().displayName(), "Callsign", 16, TextField.ANY); + name.setUIID("NameField"); + name.setName("displayName"); + f.add(Ui.label("Callsign", "SectionTitle")); + f.add(Ui.panel(name, Ui.secondary("Rename", "rename", e -> Api.account().saveProfile( + new ProfileDto(name.getText().trim()), Net.to(p -> { + Session.setUser(p); + Ui.toast("Renamed"); + }, Ui::failed))))); + f.add(Ui.label("Game", "SectionTitle")); + f.add(Ui.panel( + toggle("Sound", "sound", !Sfx.isMuted(), on -> { + Preferences.set(SOUND_KEY, on); + Sfx.setMuted(!on); + }), + toggle("Music", "music", !Music.isMuted(), on -> { + Preferences.set(MUSIC_KEY, on); + Music.setMuted(!on); + }), + toggle("Invert look", "invertLook", Preferences.get(ArenaView.INVERT_KEY, false), + on -> Preferences.set(ArenaView.INVERT_KEY, on)), + toggle("Share anonymous usage", "analytics", Telemetry.allowed(), Telemetry::allow), + toggle("Notifications", "push", Notifications.enabled(), Notifications::setEnabled))); + if (AppConfig.serverSwitchable()) { + f.add(Ui.label("Server", "SectionTitle")); + f.add(Ui.secondary("Server: " + AppConfig.serverUrl(), "serverButton", e -> chooseServer())); + } + f.add(Ui.button("Sign out", "DangerButton", "signOut", e -> Nav.signOut())); + f.show(); + } + + interface Flipped { + void flipped(boolean on); + } + + private static Container toggle(String text, String name, boolean value, final Flipped flipped) { + final Switch s = new Switch(); + s.setName(name); + s.setValue(value); + s.addActionListener(e -> flipped.flipped(s.isValue())); + Container row = new Container(new BorderLayout()); + row.setUIID("SettingRow"); + row.add(BorderLayout.CENTER, new Label(text, "SettingLabel")).add(BorderLayout.EAST, s); + return row; + } + + /// Points the app at another server: a phone reaching the developer's + /// laptop, or a test server. + public static void chooseServer() { + TextField url = new TextField(AppConfig.serverUrl(), "http://192.168.1.10:8080", 30, TextField.URL); + url.setName("serverUrl"); + if (Dialog.show("Server", url, new com.codename1.ui.Command("Cancel"), + new com.codename1.ui.Command("Use")).getCommandName().equals("Use")) { + AppConfig.setServerUrl(url.getText().trim()); + Session.start(AppConfig.serverUrl()); + SignInForm.show(); + } + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/ShopForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/ShopForm.java new file mode 100644 index 00000000000..842b1b9d5da --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/ShopForm.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.CN; +import com.codename1.ui.layouts.BorderLayout; +import com.codenameone.examples.splat.api.BuyDto; +import com.codenameone.examples.splat.api.EquipDto; +import com.codenameone.examples.splat.api.ShopItemDto; +import com.codenameone.examples.splat.api.WalletDto; +import com.codenameone.examples.splat.engage.Store; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Api; +import com.codename1.appkit.Net; +import com.codenameone.examples.splat.net.Session; +import java.util.List; + +/// The shop. Gems and the Royal Pass are real-money purchases through the +/// platform store ([Store]); markers, paint and comms packs cost coins or gems and are +/// bought from the game's own server. Either way the server holds the wallet. +public final class ShopForm { + private ShopForm() { + } + + public static void show() { + final Form f = Ui.form("ARMORY", "shop"); + Ui.desktopColumn(f); + Ui.back(f, Nav::home); + Store.listen(wallet -> { + Ui.toast("Purchase complete"); + Session.refreshUser(() -> Ui.later(ShopForm::show), Ui::failed); + }); + Api.shop().wallet(Net.to(wallet -> Api.shop().items(Net.to(items -> Ui.later(() -> { + if (Session.user() == null) { + // Signed out while the shop was loading. + return; + } + // The wallet just fetched, not the player cached at sign-in: + // the shop is where the balance has to be right. + f.add(Ui.wallet(Session.user().trophies(), wallet.coins(), wallet.gems())); + fill(f, items, wallet); + f.revalidate(); + }), Ui::failed)), Ui::failed)); + f.show(); + } + + private static void fill(Form f, List items, WalletDto wallet) { + for (final ShopItemDto item : items) { + boolean owned = wallet.owned() != null && wallet.owned().contains(item.id()); + Container row = new Container(new BorderLayout()); + row.setUIID("ShopItem"); + Container text = new Container(BoxLayout.y()); + text.add(Ui.text(item.name(), "ShopItemName")); + text.add(new Label(kindOf(item), "ShopItemKind")); + row.add(BorderLayout.CENTER, text); + if (item.sku() != null && item.sku().length() > 0) { + if (owned) { + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(new Label("OWNED", "Owned"))); + } else { + String price = Store.price(item.sku()); + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(Ui.button(price == null ? "BUY" : price, + // no price means the store has not answered; amber + // is kept for an offer that can be bought now + price == null ? "GhostButton" : "GemButton", "buy-" + item.id(), + e -> Store.buy(item.sku())))); + } + } else if (owned) { + boolean on = item.id().equals(wallet.outfit()); + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(item.id().startsWith("paint-") && !on + ? Ui.button("EQUIP", "GhostButton", "equip-" + item.id(), e -> equip(item.id())) + : new Label(on ? "EQUIPPED" : "OWNED", "Owned"))); + } else { + String price = item.priceGems() > 0 ? item.priceGems() + " GEMS" : item.priceCoins() + " COINS"; + row.add(BorderLayout.EAST, FlowLayout.encloseMiddle(Ui.button(price, "PriceButton", "buy-" + item.id(), + e -> buy(item)))); + } + f.add(row); + } + } + + private static String kindOf(ShopItemDto item) { + if ("gems-500".equals(item.id())) { + // The biggest pack is the one that is cheapest per gem. + return "BEST VALUE"; + } + String kind = item.kind() == null ? "" : item.kind(); + if ("runner".equals(kind)) { + return "MARKER"; + } + if ("outfit".equals(kind)) { + return "PAINT"; + } + if ("emotes".equals(kind)) { + return "COMMS PACK"; + } + if ("gems".equals(kind)) { + return "GEM PACK"; + } + return "pass".equals(kind) ? "SEASON PASS" : "ITEM"; + } + + private static void buy(ShopItemDto item) { + Api.shop().buy(new BuyDto(item.id()), Net.to(wallet -> { + Telemetry.itemBought(item.id(), item.priceGems() > 0 ? "gems" : "coins"); + Session.refreshUser(() -> Ui.later(ShopForm::show), Ui::failed); + }, Ui::failed)); + } + + private static void equip(String outfit) { + Api.shop().equip(new EquipDto(outfit), Net.to(wallet -> Session.refreshUser(() -> Ui.later(ShopForm::show), + Ui::failed), Ui::failed)); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/SignInForm.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/SignInForm.java new file mode 100644 index 00000000000..c73bb9019f4 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/SignInForm.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codenameone.examples.splat.net.Session; + +/// Signing in, and the way to the practice arena without an account. +public final class SignInForm { + private SignInForm() { + } + + public static void show() { + Form f = Ui.form("", "signin"); + Ui.desktopColumn(f); + f.setLayout(new com.codename1.ui.layouts.LayeredLayout()); + f.setScrollable(false); + f.setUIID("MenuForm"); + f.add(new com.codenameone.examples.splat.play.OperatorStage("op-swat", "semi", + CN.getDisplayWidth() > CN.getDisplayHeight() ? 0.9f : 0.55f)); + Label shade = new Label(); + shade.setUIID("MenuShade"); + f.add(shade); + f.getToolbar().hideToolbar(); + Container col = Ui.column(); + col.add(Ui.label("SPLAT", "Logo")); + col.add(Ui.label("TEAM PAINTBALL. TACTICAL. ALLEGEDLY.", "Tagline")); + final TextField email = field("Email", TextField.EMAILADDR, "email"); + final TextField password = field("Password", TextField.PASSWORD, "password"); + // Fields and both buttons share one panel, so they share one width. + // The guest run and the server switch sit inside the panel too, so + // nothing reads over the backdrop's track. + Button guest = Ui.button("PRACTICE VS BOTS -- NO ACCOUNT NEEDED", "LinkButton", "practiceButton", + e -> MatchForm.practice("lot7:TEAM_PAINT", SignInForm::show)); + Container panel = Ui.panel(email, password, + Ui.primary("SIGN IN", "signInButton", e -> { + Session.signIn(email.getText().trim(), password.getText(), + () -> Ui.later(Nav::signedIn), Ui::failed); + }), + Ui.secondary("ENLIST", "registerButton", e -> RegisterForm.show()), guest); + // Which server the app talks to is a setting, not something every + // player meets before signing in: it is under Settings, signed in. + col.add(panel); + Container safe = Ui.centeredColumn(col); + safe.setSafeArea(true); + Container front = new Container(BoxLayout.yCenter()); + front.setScrollableY(true); + front.add(safe); + f.add(front); + f.show(); + } + + static TextField field(String hint, int constraint, String name) { + TextField t = new TextField("", hint, 20, constraint); + t.setUIID("GameField"); + t.setName(name); + return t; + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/TacticalMap.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/TacticalMap.java new file mode 100644 index 00000000000..42fbb11be78 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/TacticalMap.java @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codenameone.examples.splat.play.Type; +import com.codenameone.examples.splat.rules.Arena; +import com.codenameone.examples.splat.rules.Box; +import com.codenameone.examples.splat.rules.Mode; +import java.util.List; + +/// A yard as the briefing shows it: an overhead plot on a grid, cover in +/// steel, the two spawns in blue and red and the objective in amber, with +/// grid references down the edges. Drawn from the same arena the rules play, +/// so the map is never out of date. +public final class TacticalMap extends Component { + private Arena arena; + private Mode mode; + + public TacticalMap(Arena arena, Mode mode) { + this.arena = arena; + this.mode = mode; + getAllStyles().setBgTransparency(0); + setName("tacticalMap"); + } + + public void show(Arena a, Mode m) { + arena = a; + mode = m; + repaint(); + } + + @Override + protected Dimension calcPreferredSize() { + int s = CN.convertToPixels(40); + // a monitor has few pixels to the millimetre; the briefing map is + // the point of the screen there, so it gets real size + if (CN.isDesktop()) { + s = Math.max(s, CN.getDisplayHeight() * 2 / 3); + } + return new Dimension(s, s * 3 / 4); + } + + @Override + public void paint(Graphics g) { + int x0 = getX(); + int y0 = getY(); + int w = getWidth(); + int h = getHeight(); + g.setAntiAliased(true); + g.setColor(0x0e1216); + g.fillRect(x0, y0, w, h); + // The field is drawn lying on its side, the long axis across. + int span = Math.max(arena.width, arena.depth); + int pad = CN.convertToPixels(3); + // the caption has a strip of its own under the field, never over it + int caption = Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_PLAIN, Font.SIZE_SMALL).getHeight() + 4; + float k = Math.min((w - pad * 2) / (float) arena.depth, (h - pad * 2 - caption) / (float) arena.width); + int fw = (int) (arena.depth * k); + int fh = (int) (arena.width * k); + int ox = x0 + (w - fw) / 2; + int oy = y0 + (h - caption - fh) / 2; + g.setColor(0x1f262d); + int cell = Math.max(4, (int) (5000 * k)); + for (int gx = ox; gx <= ox + fw; gx += cell) { + g.drawLine(gx, oy, gx, oy + fh); + } + for (int gy = oy; gy <= oy + fh; gy += cell) { + g.drawLine(ox, gy, ox + fw, gy); + } + g.setColor(0x4f5862); + g.drawRect(ox, oy, fw, fh); + List boxes = arena.boxes(); + for (int i = 0; i < boxes.size(); i++) { + Box b = boxes.get(i); + int bx = ox + (int) (b.z0 * k); + int by = oy + (int) ((arena.width - b.x1) * k); + int bw = Math.max(2, (int) ((b.z1 - b.z0) * k)); + int bh = Math.max(2, (int) ((b.x1 - b.x0) * k)); + g.setColor(0x56606a); + g.fillRect(bx, by, bw, bh); + g.setColor(0x8a949e); + g.drawRect(bx, by, bw, bh); + } + int r = CN.convertToPixels(1.4f); + for (int t = 0; t < 2; t++) { + int sx = ox + (int) (arena.spawnZ(t, 0) * k); + int sy = oy + (int) ((arena.width - arena.spawnX(t, 0)) * k); + g.setColor(t == 0 ? Type.FRIENDLY : Type.HOSTILE); + g.fillRect(sx - r, sy - r, r * 2, r * 2); + if (mode == Mode.CAPTURE_THE_FLAG) { + int fx = ox + (int) (arena.baseZ(t) * k); + int fy = oy + (int) ((arena.width - arena.baseX(t)) * k); + g.setColor(Type.AMBER); + g.drawRect(fx - r * 2, fy - r * 2, r * 4, r * 4); + g.drawString(t == 0 ? "A" : "B", fx + r * 2 + 2, fy - r * 2); + } + } + if (mode == Mode.SNACK_BAR) { + int hr = (int) (arena.hillRadius * k); + int hx = ox + (int) (arena.hillZ * k); + int hy = oy + (int) ((arena.width - arena.hillX) * k); + g.setColor(Type.AMBER); + g.drawArc(hx - hr, hy - hr, hr * 2, hr * 2, 0, 360); + g.drawArc(hx - hr + 1, hy - hr + 1, hr * 2 - 2, hr * 2 - 2, 0, 360); + } + Font f = Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_PLAIN, Font.SIZE_SMALL); + g.setFont(f); + g.setColor(0x8a949e); + for (int i = 0; i * cell <= fw; i += 2) { + g.drawString(String.valueOf((char) ('A' + i / 2 % 26)), ox + i * cell + 2, oy - f.getHeight()); + } + g.setColor(Type.AMBER); + String tag = "SAT FEED // " + Type.caps(arena.name) + " // " + (span / 1000) + "M"; + g.drawString(tag, x0 + pad / 2, y0 + h - f.getHeight() - 2); + } +} diff --git a/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Ui.java b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Ui.java new file mode 100644 index 00000000000..bc810cf5c92 --- /dev/null +++ b/scripts/splat/common/src/main/java/com/codenameone/examples/splat/ui/Ui.java @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.ui; + +import com.codename1.components.SpanLabel; +import com.codename1.components.ToastBar; +import com.codename1.io.Log; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Command; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Font; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.Toolbar; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codenameone.examples.splat.api.PlayerDto; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.net.Session; +import java.io.IOException; +import java.util.Enumeration; +import java.util.Hashtable; + +/// The few building blocks every screen is made of. None of them draws +/// anything itself: each one only names a UIID, and `theme.css` decides what a +/// `PrimaryButton`, a `Panel` or a `Banner` looks like. Re-skinning the game is +/// editing that file, not this one. +// tag::splat-ui[] +public final class Ui { + /// The widest a column of fields and buttons grows, in millimetres. + public static final int MAX_COLUMN_MM = 90; + + private Ui() { + } + + /// A screen: a scrolling column on the game's backdrop, its name reported + /// to analytics and given to the form so tests can find it. + public static Form form(String title, String name) { + Form f = new Form(title, BoxLayout.y()); + f.setName(name); + f.setUIID("GameForm"); + f.getToolbar().setUIID("GameToolbar"); + f.getToolbar().getTitleComponent().setUIID("GameTitle"); + f.setTitle(caps(title)); + f.setScrollableY(true); + Telemetry.screen(name); + return f; + } + + /// On a desktop, lifts every theme font to the floor `Type` keeps for the + /// HUD. The stylesheet sizes its small print in millimetres, and a monitor + /// reports so few pixels to the millimetre that a 2mm caption -- a menu + /// item's subtitle, a store row's tag -- came out at seven pixels. Call it + /// once, before the first form shows. Does nothing on a phone or tablet. + public static void desktopFonts() { + if (!CN.isDesktop()) { + return; + } + float perMm = CN.convertToPixels(100f) / 100f; + if (perMm <= 0 || perMm >= 5.5f) { + return; + } + float scale = 5.5f / perMm; + try { + Resources r = Resources.openLayered("/theme"); + String[] names = r.getThemeResourceNames(); + if (names == null || names.length == 0) { + return; + } + Hashtable theme = r.getTheme(names[0]); + Hashtable lifted = new Hashtable(); + Enumeration keys = theme.keys(); + while (keys.hasMoreElements()) { + Object k = keys.nextElement(); + Object v = theme.get(k); + if (v instanceof Font && ((Font) v).isTTFNativeFont()) { + Font f = (Font) v; + v = f.derive(f.getPixelSize() * scale, f.getStyle()); + } + lifted.put(k, v); + } + UIManager.getInstance().setThemeProps(lifted); + } catch (IOException e) { + Log.e(e); + } + } + + /// The widest a list screen reads on a desktop, in pixels: a row with its + /// label at one edge of a monitor and its button at the other is two + /// things, not one. + static final int DESKTOP_PAGE_PX = 1000; + + /// `mm` millimetres in pixels, with the same desktop floor `desktopFonts` + /// gives the type. A monitor reports so few pixels to the millimetre that + /// a panel sized for a phone came out as a phone-sized box in the middle + /// of a large window; sized through this it grows with its text. + public static int mm(float mm) { + int px = CN.convertToPixels(mm); + if (!CN.isDesktop()) { + return px; + } + float perMm = CN.convertToPixels(100f) / 100f; + return perMm > 0 && perMm < 5.5f ? (int) (mm * 5.5f) : px; + } + + /// The column a desktop list screen keeps to: wide enough to fill a large + /// window with something, never so wide that a row splits in two. + static int desktopPage(int width) { + return Math.max(DESKTOP_PAGE_PX, width * 3 / 5); + } + + /// On a desktop, keeps a list screen to a column in the middle of the + /// window, however wide the window is. Does nothing on a phone or tablet. + public static void desktopColumn(final Form f) { + if (!CN.isDesktop()) { + return; + } + final Container c = f.getContentPane(); + final Runnable fit = () -> { + int side = Math.max(0, (f.getWidth() - desktopPage(f.getWidth())) / 2); + Style s = c.getUnselectedStyle(); + s.setPaddingUnitLeft(Style.UNIT_TYPE_PIXELS); + s.setPaddingUnitRight(Style.UNIT_TYPE_PIXELS); + s.setPadding(Component.LEFT, side); + s.setPadding(Component.RIGHT, side); + }; + f.addShowListener(e -> { + fit.run(); + f.revalidate(); + }); + f.addSizeChangedListener(e -> { + fit.run(); + f.revalidateLater(); + }); + } + + /// Adds a back command that leaves through `leave`. + public static void back(Form f, final Runnable leave) { + Command back = new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + leave.run(); + } + }; + // The toolbar draws its arrow in millimetres, which on a monitor came + // out as a speck beside the title: there it gets the same desktop + // floor as the fonts. + float mm = 3f; + float perMm = CN.convertToPixels(100f) / 100f; + if (CN.isDesktop() && perMm > 0 && perMm < 5.5f) { + mm = mm * 5.5f / perMm; + } + f.getToolbar().setBackCommand(back, Toolbar.BackCommandPolicy.AS_ARROW, mm); + } + + public static Label label(String text, String uiid) { + return new Label(text, uiid); + } + + public static SpanLabel text(String text, String uiid) { + SpanLabel l = new SpanLabel(text); + l.setUIID("Container"); + l.setTextUIID(uiid); + return l; + } + + public static Button button(String text, String uiid, String name, ActionListener onClick) { + Button b = new Button(text, uiid); + b.setName(name); + if (onClick != null) { + b.addActionListener(onClick); + } + return b; + } + + /// The bright, rounded call to action of a screen. + public static Button primary(String text, String name, ActionListener onClick) { + return button(text, "PrimaryButton", name, onClick); + } + + /// The outlined button for everything secondary. + public static Button secondary(String text, String name, ActionListener onClick) { + return button(text, "GhostButton", name, onClick); + } + + /// Sets a material icon drawn on nothing, so a label with a translucent + /// background doesn't get a darker square behind its icon. + public static void glyph(com.codename1.ui.Label l, char icon, float mm) { + com.codename1.ui.plaf.Style st = new com.codename1.ui.plaf.Style(l.getUnselectedStyle()); + st.setBgTransparency(0); + st.setBorder(null); + l.setIcon(com.codename1.ui.FontImage.createMaterial(icon, st, mm)); + } + + public static Container panel(Component... children) { + Container c = BoxLayout.encloseY(children); + c.setUIID("Panel"); + return c; + } + + /// Equal cells side by side, with a gap between them and none at the + /// outer edges, so a row lines up with the full-width buttons above it. + public static Container row(Component... children) { + Container c = new Container(new GridLayout(1, children.length)); + for (int i = 0; i < children.length; i++) { + Style s = children[i].getAllStyles(); + s.setMarginUnitLeft(Style.UNIT_TYPE_DIPS); + s.setMarginUnitRight(Style.UNIT_TYPE_DIPS); + s.setMarginLeft(i == 0 ? 0f : 0.8f); + s.setMarginRight(i == children.length - 1 ? 0f : 0.8f); + c.add(children[i]); + } + return c; + } + + /// A column no wider than a phone held upright, centred on wider screens, + /// with the same gutter on both sides. Fields and buttons inside it all + /// fill its width, so they line up. + public static Container column(Component... children) { + Container col = new Container(BoxLayout.y()) { + @Override + protected Dimension calcPreferredSize() { + Dimension d = super.calcPreferredSize(); + int gutter = CN.convertToPixels(4) * 2; + d.setWidth(Math.min(CN.convertToPixels(MAX_COLUMN_MM), CN.getDisplayWidth() - gutter)); + return d; + } + }; + for (Component child : children) { + col.add(child); + } + return col; + } + + /// `column` centred in the space it is given. + public static Container centeredColumn(Container column) { + return BorderLayout.centerCenter(column); + } + + /// A rounded bar filled to `value` of `max`, for experience and quests. + public static Component bar(final int value, final int max, final int color) { + return new Component() { + @Override + public void paint(Graphics g) { + // Segmented and square, the way a shooter shows a stat. + int h = getHeight(); + int w = getWidth(); + int segs = 10; + int gap = Math.max(1, CN.convertToPixels(0.3f)); + int sw = Math.max(1, (w - gap * (segs - 1)) / segs); + int lit = max <= 0 ? 0 : (int) ((long) Math.max(0, Math.min(value, max)) * segs * 2 / max + 1) / 2; + for (int i = 0; i < segs; i++) { + g.setColor(i < lit ? color : 0x2a3038); + g.fillRect(getX() + i * (sw + gap), getY(), sw, h); + } + } + + @Override + protected Dimension calcPreferredSize() { + return new Dimension(CN.convertToPixels(26), CN.convertToPixels(1.4f)); + } + }; + } + + public static Container centered(Component... children) { + return FlowLayout.encloseCenter(children); + } + + /// The trophies, coins and gems strip at the top of the menus. + public static Container wallet(PlayerDto p) { + return wallet(p.trophies(), p.coins(), p.gems()); + } + + /// The same bar for balances fresher than the signed-in player's. + public static Container wallet(int trophies, int coins, int gems) { + Container c = new Container(new GridLayout(1, 3)); + c.setUIID("WalletBar"); + c.add(stat(FontImage.MATERIAL_EMOJI_EVENTS, trophies, "Trophies")); + c.add(stat(FontImage.MATERIAL_MONETIZATION_ON, coins, "Coins")); + c.add(stat(FontImage.MATERIAL_DIAMOND, gems, "Gems")); + return c; + } + + private static Label stat(char icon, int value, String uiid) { + Label l = new Label(String.valueOf(value), uiid); + l.setName(uiid); + l.setMaterialIcon(icon); + return l; + } + + /// `s` in capitals, the way every menu label in the game is set. + public static String caps(String s) { + return com.codenameone.examples.splat.play.Type.caps(s == null ? "" : s); + } + + public static void toast(String text) { + ToastBar.showMessage(text, FontImage.MATERIAL_INFO, 2500); + } + + public static void failed(int status, String message) { + toast(message == null || message.length() == 0 ? "Something went wrong (" + status + ")" : message); + } + + public static PlayerDto me() { + return Session.user(); + } + + public static boolean adFree() { + PlayerDto p = Session.user(); + return p != null && p.owned() != null && p.owned().contains("royal-pass"); + } + + public static void later(Runnable r) { + CN.callSerially(r); + } +} +// end::splat-ui[] diff --git a/scripts/splat/common/src/main/resources/Rajdhani-Bold.ttf b/scripts/splat/common/src/main/resources/Rajdhani-Bold.ttf new file mode 100644 index 00000000000..47af15713b4 Binary files /dev/null and b/scripts/splat/common/src/main/resources/Rajdhani-Bold.ttf differ diff --git a/scripts/splat/common/src/main/resources/Rajdhani-Medium.ttf b/scripts/splat/common/src/main/resources/Rajdhani-Medium.ttf new file mode 100644 index 00000000000..a6960c6d7f5 Binary files /dev/null and b/scripts/splat/common/src/main/resources/Rajdhani-Medium.ttf differ diff --git a/scripts/splat/common/src/main/resources/model_barrel.glb b/scripts/splat/common/src/main/resources/model_barrel.glb new file mode 100644 index 00000000000..0a77e0ae378 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_barrel.glb differ diff --git a/scripts/splat/common/src/main/resources/model_barrier-single.glb b/scripts/splat/common/src/main/resources/model_barrier-single.glb new file mode 100644 index 00000000000..2c69c229493 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_barrier-single.glb differ diff --git a/scripts/splat/common/src/main/resources/model_barrier.glb b/scripts/splat/common/src/main/resources/model_barrier.glb new file mode 100644 index 00000000000..e33c3913d7f Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_barrier.glb differ diff --git a/scripts/splat/common/src/main/resources/model_birch.glb b/scripts/splat/common/src/main/resources/model_birch.glb new file mode 100644 index 00000000000..b8f1aa3451f Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_birch.glb differ diff --git a/scripts/splat/common/src/main/resources/model_boxes.glb b/scripts/splat/common/src/main/resources/model_boxes.glb new file mode 100644 index 00000000000..61927d3ee5c Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_boxes.glb differ diff --git a/scripts/splat/common/src/main/resources/model_bush-2.glb b/scripts/splat/common/src/main/resources/model_bush-2.glb new file mode 100644 index 00000000000..b4d0845c9e6 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_bush-2.glb differ diff --git a/scripts/splat/common/src/main/resources/model_bush-berries.glb b/scripts/splat/common/src/main/resources/model_bush-berries.glb new file mode 100644 index 00000000000..d403b585c9b Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_bush-berries.glb differ diff --git a/scripts/splat/common/src/main/resources/model_bush.glb b/scripts/splat/common/src/main/resources/model_bush.glb new file mode 100644 index 00000000000..8c7c9b12864 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_bush.glb differ diff --git a/scripts/splat/common/src/main/resources/model_campfire.glb b/scripts/splat/common/src/main/resources/model_campfire.glb new file mode 100644 index 00000000000..905042a10bf Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_campfire.glb differ diff --git a/scripts/splat/common/src/main/resources/model_car.glb b/scripts/splat/common/src/main/resources/model_car.glb new file mode 100644 index 00000000000..70dddff0149 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_car.glb differ diff --git a/scripts/splat/common/src/main/resources/model_cone.glb b/scripts/splat/common/src/main/resources/model_cone.glb new file mode 100644 index 00000000000..25195a98dd7 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_cone.glb differ diff --git a/scripts/splat/common/src/main/resources/model_container-small.glb b/scripts/splat/common/src/main/resources/model_container-small.glb new file mode 100644 index 00000000000..602798aa98c Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_container-small.glb differ diff --git a/scripts/splat/common/src/main/resources/model_container.glb b/scripts/splat/common/src/main/resources/model_container.glb new file mode 100644 index 00000000000..ba1feb5bf0d Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_container.glb differ diff --git a/scripts/splat/common/src/main/resources/model_crate.glb b/scripts/splat/common/src/main/resources/model_crate.glb new file mode 100644 index 00000000000..b6328a630b9 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_crate.glb differ diff --git a/scripts/splat/common/src/main/resources/model_dumpster.glb b/scripts/splat/common/src/main/resources/model_dumpster.glb new file mode 100644 index 00000000000..6e2597e69e0 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_dumpster.glb differ diff --git a/scripts/splat/common/src/main/resources/model_fence.glb b/scripts/splat/common/src/main/resources/model_fence.glb new file mode 100644 index 00000000000..98ff1797aac Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_fence.glb differ diff --git a/scripts/splat/common/src/main/resources/model_flowers.glb b/scripts/splat/common/src/main/resources/model_flowers.glb new file mode 100644 index 00000000000..6b57c120c72 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_flowers.glb differ diff --git a/scripts/splat/common/src/main/resources/model_grass-2.glb b/scripts/splat/common/src/main/resources/model_grass-2.glb new file mode 100644 index 00000000000..600eecbd9c7 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_grass-2.glb differ diff --git a/scripts/splat/common/src/main/resources/model_grass-short.glb b/scripts/splat/common/src/main/resources/model_grass-short.glb new file mode 100644 index 00000000000..0749583cc02 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_grass-short.glb differ diff --git a/scripts/splat/common/src/main/resources/model_grass.glb b/scripts/splat/common/src/main/resources/model_grass.glb new file mode 100644 index 00000000000..ae4018016a6 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_grass.glb differ diff --git a/scripts/splat/common/src/main/resources/model_log.glb b/scripts/splat/common/src/main/resources/model_log.glb new file mode 100644 index 00000000000..9fac80bf472 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_log.glb differ diff --git a/scripts/splat/common/src/main/resources/model_metal-fence.glb b/scripts/splat/common/src/main/resources/model_metal-fence.glb new file mode 100644 index 00000000000..608e3c47e78 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_metal-fence.glb differ diff --git a/scripts/splat/common/src/main/resources/model_mk-grenade.glb b/scripts/splat/common/src/main/resources/model_mk-grenade.glb new file mode 100644 index 00000000000..1c9c1356ed7 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_mk-grenade.glb differ diff --git a/scripts/splat/common/src/main/resources/model_mk-hopper.glb b/scripts/splat/common/src/main/resources/model_mk-hopper.glb new file mode 100644 index 00000000000..f94edfe3f96 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_mk-hopper.glb differ diff --git a/scripts/splat/common/src/main/resources/model_mk-pump.glb b/scripts/splat/common/src/main/resources/model_mk-pump.glb new file mode 100644 index 00000000000..7a1f44be78b Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_mk-pump.glb differ diff --git a/scripts/splat/common/src/main/resources/model_mk-ramp.glb b/scripts/splat/common/src/main/resources/model_mk-ramp.glb new file mode 100644 index 00000000000..e599ee82e89 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_mk-ramp.glb differ diff --git a/scripts/splat/common/src/main/resources/model_mk-scope.glb b/scripts/splat/common/src/main/resources/model_mk-scope.glb new file mode 100644 index 00000000000..ad8efe3110a Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_mk-scope.glb differ diff --git a/scripts/splat/common/src/main/resources/model_mk-semi.glb b/scripts/splat/common/src/main/resources/model_mk-semi.glb new file mode 100644 index 00000000000..61bdd207e8c Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_mk-semi.glb differ diff --git a/scripts/splat/common/src/main/resources/model_oak-2.glb b/scripts/splat/common/src/main/resources/model_oak-2.glb new file mode 100644 index 00000000000..8c4db39c8fd Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_oak-2.glb differ diff --git a/scripts/splat/common/src/main/resources/model_oak.glb b/scripts/splat/common/src/main/resources/model_oak.glb new file mode 100644 index 00000000000..dd97a7fdd70 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_oak.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-dad.glb b/scripts/splat/common/src/main/resources/model_op-dad.glb new file mode 100644 index 00000000000..06bda8ad74d Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-dad.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-farmer.glb b/scripts/splat/common/src/main/resources/model_op-farmer.glb new file mode 100644 index 00000000000..286c4698827 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-farmer.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-hoodie.glb b/scripts/splat/common/src/main/resources/model_op-hoodie.glb new file mode 100644 index 00000000000..254d51bdfa3 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-hoodie.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-punk.glb b/scripts/splat/common/src/main/resources/model_op-punk.glb new file mode 100644 index 00000000000..e2dde0b3d8e Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-punk.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-ranger.glb b/scripts/splat/common/src/main/resources/model_op-ranger.glb new file mode 100644 index 00000000000..b88745b6a70 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-ranger.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-suit.glb b/scripts/splat/common/src/main/resources/model_op-suit.glb new file mode 100644 index 00000000000..c0d472d19bf Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-suit.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-swat.glb b/scripts/splat/common/src/main/resources/model_op-swat.glb new file mode 100644 index 00000000000..25f08362862 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-swat.glb differ diff --git a/scripts/splat/common/src/main/resources/model_op-worker.glb b/scripts/splat/common/src/main/resources/model_op-worker.glb new file mode 100644 index 00000000000..fb3ea632bb2 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_op-worker.glb differ diff --git a/scripts/splat/common/src/main/resources/model_pallet.glb b/scripts/splat/common/src/main/resources/model_pallet.glb new file mode 100644 index 00000000000..3bdd25cd91a Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_pallet.glb differ diff --git a/scripts/splat/common/src/main/resources/model_pine-2.glb b/scripts/splat/common/src/main/resources/model_pine-2.glb new file mode 100644 index 00000000000..39ca09db529 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_pine-2.glb differ diff --git a/scripts/splat/common/src/main/resources/model_pine.glb b/scripts/splat/common/src/main/resources/model_pine.glb new file mode 100644 index 00000000000..79ad71caec1 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_pine.glb differ diff --git a/scripts/splat/common/src/main/resources/model_planks.glb b/scripts/splat/common/src/main/resources/model_planks.glb new file mode 100644 index 00000000000..9cb51b5b9bf Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_planks.glb differ diff --git a/scripts/splat/common/src/main/resources/model_plant.glb b/scripts/splat/common/src/main/resources/model_plant.glb new file mode 100644 index 00000000000..8bca9dcd0ee Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_plant.glb differ diff --git a/scripts/splat/common/src/main/resources/model_rock-moss.glb b/scripts/splat/common/src/main/resources/model_rock-moss.glb new file mode 100644 index 00000000000..8d93152e114 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_rock-moss.glb differ diff --git a/scripts/splat/common/src/main/resources/model_rock.glb b/scripts/splat/common/src/main/resources/model_rock.glb new file mode 100644 index 00000000000..02dc047967e Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_rock.glb differ diff --git a/scripts/splat/common/src/main/resources/model_sandbags-small.glb b/scripts/splat/common/src/main/resources/model_sandbags-small.glb new file mode 100644 index 00000000000..7084996496b Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_sandbags-small.glb differ diff --git a/scripts/splat/common/src/main/resources/model_sandbags.glb b/scripts/splat/common/src/main/resources/model_sandbags.glb new file mode 100644 index 00000000000..08dd9c5a228 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_sandbags.glb differ diff --git a/scripts/splat/common/src/main/resources/model_shack.glb b/scripts/splat/common/src/main/resources/model_shack.glb new file mode 100644 index 00000000000..dc3f89e9347 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_shack.glb differ diff --git a/scripts/splat/common/src/main/resources/model_sign.glb b/scripts/splat/common/src/main/resources/model_sign.glb new file mode 100644 index 00000000000..ea48e8151f3 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_sign.glb differ diff --git a/scripts/splat/common/src/main/resources/model_sofa.glb b/scripts/splat/common/src/main/resources/model_sofa.glb new file mode 100644 index 00000000000..995bd6c42ba Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_sofa.glb differ diff --git a/scripts/splat/common/src/main/resources/model_streetlight.glb b/scripts/splat/common/src/main/resources/model_streetlight.glb new file mode 100644 index 00000000000..3f544551248 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_streetlight.glb differ diff --git a/scripts/splat/common/src/main/resources/model_stump.glb b/scripts/splat/common/src/main/resources/model_stump.glb new file mode 100644 index 00000000000..d6ac1fa3853 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_stump.glb differ diff --git a/scripts/splat/common/src/main/resources/model_tank.glb b/scripts/splat/common/src/main/resources/model_tank.glb new file mode 100644 index 00000000000..56e24cc6e0b Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_tank.glb differ diff --git a/scripts/splat/common/src/main/resources/model_tent.glb b/scripts/splat/common/src/main/resources/model_tent.glb new file mode 100644 index 00000000000..b35aa0b8b04 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_tent.glb differ diff --git a/scripts/splat/common/src/main/resources/model_tires.glb b/scripts/splat/common/src/main/resources/model_tires.glb new file mode 100644 index 00000000000..cd544687472 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_tires.glb differ diff --git a/scripts/splat/common/src/main/resources/model_tree-2.glb b/scripts/splat/common/src/main/resources/model_tree-2.glb new file mode 100644 index 00000000000..dde9a02f658 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_tree-2.glb differ diff --git a/scripts/splat/common/src/main/resources/model_tree.glb b/scripts/splat/common/src/main/resources/model_tree.glb new file mode 100644 index 00000000000..b8f351bc3ab Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_tree.glb differ diff --git a/scripts/splat/common/src/main/resources/model_watertower.glb b/scripts/splat/common/src/main/resources/model_watertower.glb new file mode 100644 index 00000000000..d54c870d807 Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_watertower.glb differ diff --git a/scripts/splat/common/src/main/resources/model_workbench.glb b/scripts/splat/common/src/main/resources/model_workbench.glb new file mode 100644 index 00000000000..510da0a09ac Binary files /dev/null and b/scripts/splat/common/src/main/resources/model_workbench.glb differ diff --git a/scripts/splat/common/src/main/resources/tex_asphalt.jpg b/scripts/splat/common/src/main/resources/tex_asphalt.jpg new file mode 100644 index 00000000000..b476e416f6d Binary files /dev/null and b/scripts/splat/common/src/main/resources/tex_asphalt.jpg differ diff --git a/scripts/splat/common/src/main/resources/tex_concrete.jpg b/scripts/splat/common/src/main/resources/tex_concrete.jpg new file mode 100644 index 00000000000..229ed059744 Binary files /dev/null and b/scripts/splat/common/src/main/resources/tex_concrete.jpg differ diff --git a/scripts/splat/common/src/main/resources/tex_grass.jpg b/scripts/splat/common/src/main/resources/tex_grass.jpg new file mode 100644 index 00000000000..279840658b9 Binary files /dev/null and b/scripts/splat/common/src/main/resources/tex_grass.jpg differ diff --git a/scripts/splat/common/src/main/resources/tex_gravel.jpg b/scripts/splat/common/src/main/resources/tex_gravel.jpg new file mode 100644 index 00000000000..db9abd81e85 Binary files /dev/null and b/scripts/splat/common/src/main/resources/tex_gravel.jpg differ diff --git a/scripts/splat/common/src/main/resources/tex_iron.jpg b/scripts/splat/common/src/main/resources/tex_iron.jpg new file mode 100644 index 00000000000..0a6d55f859c Binary files /dev/null and b/scripts/splat/common/src/main/resources/tex_iron.jpg differ diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/AccountTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/AccountTest.java new file mode 100644 index 00000000000..4a2394c70eb --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/AccountTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.ui.Nav; + +/// Registering with a call sign, the first practice match, choosing a marker, the +/// settings and signing out. +public class AccountTest extends E2e { + @Override + protected boolean run() throws Exception { + shot("signin"); + click("registerButton"); + waitForForm("register"); + String stamp = String.valueOf(System.currentTimeMillis() % 100000); + type("displayName", "Ironclad"); + type("email", "new" + stamp + "@splat.example"); + type("password", "a-long-password"); + shot("register-name"); + click("createButton"); + waitForForm("home"); + // A new player is taken straight into the first level. + waitForForm("match"); + final ArenaView view = arena(); + shot("tutorial-first-visit"); + onEdt(() -> { + view.stop(); + view.driver().dispose(); + Nav.home(); + }); + waitForForm("home"); + assertEqual("Ironclad", Session.user().displayName()); + assertEqual("semi", Session.user().runner() == null ? "semi" : Session.user().runner(), "a new player carries the semi"); + shot("home"); + + // Picking another marker saves it on the account. + click("loadoutButton"); + waitForForm("loadout"); + shot("loadout"); + click("pump"); + if (has("choose-pump")) { + click("choose-pump"); + until(() -> "pump".equals(Session.user().runner()), 10000, "the marker to be saved"); + } + + onEdt(() -> com.codenameone.examples.splat.ui.SettingsForm.show()); + waitForForm("settings"); + shot("settings"); + click("signOut"); + waitForForm("signin"); + assertNull(Session.user()); + return true; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/AdminTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/AdminTest.java new file mode 100644 index 00000000000..ec9a1bedd88 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/AdminTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codenameone.examples.splat.net.Session; + +/// God mode: the overview, the flag queue with the demo's flagged player, +/// and that player's file. +public class AdminTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(ADMIN); + waitForForm("home"); + assertTrue(Session.user().admin()); + click("adminButton"); + waitForForm("admin"); + until(() -> has("stat-Open flags"), 20000, "the numbers"); + shot("admin-overview"); + onEdt(() -> ((com.codename1.ui.Tabs) findByName("adminTabs")).setSelectedIndex(7)); + until(() -> has("promoteTitle"), 20000, "the Promote tab"); + shot("admin-promote"); + onEdt(() -> ((com.codename1.ui.Tabs) findByName("adminTabs")).setSelectedIndex(1)); + until(() -> findFlag() != null, 20000, "a flag in the queue"); + shot("admin-flags"); + touch(findFlag()); + waitForForm("adminPlayer"); + until(() -> has("act-SUSPEND"), 20000, "the actions"); + shot("admin-player"); + return true; + } + + private String findFlag() { + com.codename1.ui.Form f = com.codename1.ui.Display.getInstance().getCurrent(); + return find(f); + } + + private static String find(com.codename1.ui.Container c) { + for (int i = 0; i < c.getComponentCount(); i++) { + com.codename1.ui.Component k = c.getComponentAt(i); + if (k.getName() != null && k.getName().startsWith("flag-")) { + return k.getName(); + } + if (k instanceof com.codename1.ui.Container) { + String inner = find((com.codename1.ui.Container) k); + if (inner != null) { + return inner; + } + } + } + return null; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/DemoVideoTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/DemoVideoTest.java new file mode 100644 index 00000000000..f94758f45fa --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/DemoVideoTest.java @@ -0,0 +1,220 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.impl.javase.record.RecordingOptions; +import com.codename1.impl.javase.record.SimulatorRecorder; +import com.codename1.impl.javase.record.TouchOverlay; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.rules.Input; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.ui.Nav; +import java.io.File; + +/// A walkthrough of ordinary play, with the game's sound: the menu and its +/// march, a look at the loadout, the operations list and a briefing, then a +/// practice match -- the countdown, moving and firing, aiming down the sights, +/// a grenade, a reload, wiping paint off -- to the final whistle and the +/// scoreboard. It runs in both passes, so the phone gets one inside its skin +/// and the desktop window one of its own, and only when `splat.demo.dir` +/// names the directory the two files go to (`record-demo.sh`). +public class DemoVideoTest extends E2e { + private SimulatorRecorder recorder; + + @Override + public int getTimeoutMillis() { + return 600000; + } + + @Override + protected boolean desktop() { + return desktopRun(); + } + + @Override + protected boolean run() throws Exception { + String dir = System.getProperty("splat.demo.dir"); + if (dir == null || dir.length() == 0) { + log("The demo is recorded only when splat.demo.dir names a directory; skipped"); + return true; + } + boolean desk = desktopRun(); + File out = new File(dir, desk ? "splat-desktop-demo.mp4" : "splat-mobile-demo.mp4"); + try { + signIn(PLAYER); + waitForForm("home"); + waitFor(1500); + record(out, desk ? RecordingOptions.Frame.SCREEN : RecordingOptions.Frame.SKIN); + // Told as a story, at the pace of someone watching: every caption + // gets time to be read, every tap is a visible touch, and nothing + // changes screen while a caption is still up. + caption("Saturday, 0900. Lot 7 Self Storage.", 3200); + waitFor(3600); + caption("Four dads, four moms, one referee. This is Splat.", 3400); + waitFor(3800); + + tap("loadoutButton"); + caption("First, the gear: every marker trades rate for range", 3400); + waitFor(3800); + if (has("choose-pump")) { + tap("choose-pump"); + caption("The pump: slow, honest, devastating", 3000); + waitFor(3400); + } + home(); + + tap("runButton"); + waitForForm("play"); + caption("Pick an operation", 2600); + waitFor(2800); + tap("mission-cul-de-sac:CAPTURE_THE_FLAG"); + caption("The briefing: the map, the orders, the snacks at stake", 3600); + waitFor(4800); + tap("practice-cul-de-sac:CAPTURE_THE_FLAG"); + waitForForm("match"); + ArenaView view = arena(); + // the briefing plays out in full with its beeps and the horn + waitFor(5200); + + caption("Left thumb moves, right thumb looks", 3800); + driveFor(view, 7000); + caption("Their flag flies over the glowing ring across the yard", 3800); + driveFor(view, 4000); + caption("Paintballs are slow -- lead your target", 3800); + held(view, Input.FIRE, 4000); + caption("Aim down the sights for a tighter group", 3600); + held(view, Input.AIM, 900); + held(view, Input.AIM | Input.FIRE, 3600); + caption("Splat grenade over the cover", 3400); + held(view, Input.THROW, 400); + driveFor(view, 4500); + + caption("Sooner or later, everyone gets painted...", 3600); + if (untilPainted(view, 40000)) { + // the wipe has to be tried in the first second, before the + // replay rolls + caption("Wipe it off! Maybe the ref did not see", 3000); + held(view, Input.WIPE, 900); + waitFor(600); + caption("Instant replay: who did it, in slow motion", 3000); + waitFor(2600); + caption("Again, slower, from the side", 2600); + waitFor(2600); + caption("And the performance. Every time.", 2800); + waitFor(3600); + } + caption("Bird's-eye view: the whole yard at once", 3600); + onEdt(view::toggleOverhead); + driveFor(view, 8000); + onEdt(view::toggleOverhead); + caption("Carry their flag home on your back for a point", 3800); + driveFor(view, 10000); + finish(view, 10 * 60 * Match.TICK_HZ); + caption("The winners celebrate. Loudly.", 4200); + waitFor(5600); + collectAll(); + caption("The scoreboard. Somebody is disputing every hit.", 3800); + waitFor(4400); + home(); + caption("Next: build your own field in the Mission Builder", 3600); + waitFor(4000); + + SimulatorRecorder.Result r = recorder.stop(); + recorder = null; + log("Recorded " + r.file + ": " + r.durationMs + " ms, " + r.frames + " frames" + + (r.note == null ? "" : " -- " + r.note)); + assertTrue(r.frames > 0, "frames recorded"); + return true; + } finally { + if (recorder != null && recorder.isRecording()) { + recorder.stop(); + } + } + } + + /// The autopilot plays with `buttons` held on top of its own for `millis`. + private void held(final ArenaView view, int buttons, int millis) { + onEdt(() -> view.hold(buttons)); + try { + driveFor(view, millis); + } finally { + onEdt(() -> view.hold(0)); + } + } + + private void record(File out, RecordingOptions.Frame frame) throws Exception { + recorder = JavaSEPort.instance.getSimulatorRecorder(); + RecordingOptions o = new RecordingOptions(); + o.setPath(out.getAbsolutePath()); + o.setFps(30); + o.setScale(1f); + o.setFrame(frame); + o.setTouches(TouchOverlay.Style.DOT); + o.setAudio(RecordingOptions.Audio.APP); + o.setMaxSeconds(300); + recorder.start(o); + } + + /// A visible tap, held a moment before and after so the eye can follow. + private void tap(String name) { + waitFor(500); + touch(name); + waitFor(900); + } + + /// Plays on until your own player is painted; answers whether it happened. + private boolean untilPainted(final ArenaView view, int millis) { + long end = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < end) { + final boolean[] out = new boolean[1]; + onEdt(() -> { + Match m = view.driver().match(); + out[0] = !m.player(view.driver().me()).alive(); + }); + if (out[0]) { + return true; + } + driveFor(view, 250); + } + return false; + } + + private void caption(String text, long ms) { + recorder.caption(text, ms); + } + + private void home() { + onEdt(Nav::home); + waitForForm("home"); + waitFor(800); + } + + private void collectAll() { + until(() -> has("result") || has("collectReward"), 20000, "the result"); + while (!has("result")) { + if (has("collectReward")) { + waitFor(1400); + click("collectReward"); + } + waitFor(600); + } + waitFor(1800); + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/DesktopTourTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/DesktopTourTest.java new file mode 100644 index 00000000000..25f70969df2 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/DesktopTourTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.rules.Match; + +/// The game itself on a desktop window: the front end at full width, the +/// loadout and the armory, and a match with the detailed desktop HUD. The +/// phone pass shoots the same screens in the phone skin. +public class DesktopTourTest extends E2e { + @Override + protected boolean desktop() { + return true; + } + + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + waitFor(1500); + shot("desktop-home"); + click("shopButton"); + waitForForm("shop"); + waitFor(800); + shot("desktop-shop"); + onEdt(() -> com.codenameone.examples.splat.ui.Nav.home()); + waitForForm("home"); + click("runButton"); + waitForForm("play"); + click("mission-cul-de-sac:CAPTURE_THE_FLAG"); + shot("desktop-operations"); + click("practice-cul-de-sac:CAPTURE_THE_FLAG"); + waitForForm("match"); + final ArenaView view = arena(); + shot("desktop-briefing"); + until(() -> view.driver().countdownTicks() == 0, 20000, "the briefing to end"); + long give; + give = System.currentTimeMillis() + 40000; + // the HUD as you play it: in play twice in a row, never painted out + // or facing a wall, and shot at once so the bot cannot walk into one + while ((view.driver().match().tick() < 3 * Match.TICK_HZ + || !(PracticeTest.inPlay(view) && !driveFor(view, 1500) && PracticeTest.inPlay(view))) + && System.currentTimeMillis() < give) { + driveFor(view, 150); + } + // a ball in the air can still land in the instant the capture takes + shotNow("desktop-match"); + while (!PracticeTest.inPlay(view) && System.currentTimeMillis() < give) { + driveFor(view, 1500); + if (PracticeTest.inPlay(view)) { + shotNow("desktop-match"); + } + } + finish(view, 10 * 60 * Match.TICK_HZ); + awaitResult("the scoreboard"); + shot("desktop-scoreboard"); + click("done"); + waitForForm("play"); + return true; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/E2e.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/E2e.java new file mode 100644 index 00000000000..33f7d40f825 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/E2e.java @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.oidc.OidcTokens; +import com.codename1.io.oidc.TokenStore; +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Button; +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.TextArea; +import com.codename1.ui.util.ImageIO; +import com.codename1.util.AsyncResource; +import com.codenameone.examples.splat.engage.Telemetry; +import com.codenameone.examples.splat.rules.Bot; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Rng; +import com.codenameone.examples.splat.play.Driver; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.play.Sfx; +import com.codenameone.examples.splat.net.Session; +import com.codenameone.examples.splat.ui.Nav; +import java.io.OutputStream; + +/// What every end-to-end test shares: a fresh session against the test +/// server, ways to press and type by component name, and screenshots. +/// +/// The screenshots are the ones the developer guide's "Make it your own" +/// chapter shows; `run-e2e.sh` writes them to `target/e2e-shots`. +public abstract class E2e extends AbstractTest { + public static final String PASSWORD = "splat-demo"; + public static final String PLAYER = "player@splat.example"; + public static final String RIVAL = "rival@splat.example"; + public static final String ADMIN = "admin@splat.example"; + + protected interface Check { + boolean ok(); + } + + @Override + public boolean shouldExecuteOnEDT() { + return false; + } + + @Override + public int getTimeoutMillis() { + return 180000; + } + + protected abstract boolean run() throws Exception; + + /// Whether this run of the tests is the one in a desktop window. + /// + /// `run-e2e.sh` runs the tests twice: on the simulator's phone, and then, + /// on Linux, with the simulator as a desktop, where the admin console is + /// a side bar and a page. A test belongs to one of the two -- see + /// [#desktop()] -- and passes without doing anything in the other. + static boolean desktopRun() { + return "true".equals(System.getProperty("splat.e2e.desktop")); + } + + /// Whether this test is one for the desktop window. The phone's are not. + protected boolean desktop() { + return false; + } + + private boolean mine() { + // splat.e2e.only names the test classes a run is for, by comma (the demo + // recordings); the runner itself has no way to pick a test. + String only = System.getProperty("splat.e2e.only"); + if (only != null && only.length() > 0 + && ("," + only + ",").indexOf("," + getClass().getSimpleName() + ",") < 0) { + return false; + } + return desktop() == desktopRun(); + } + + @Override + public final boolean runTest() throws Exception { + if (!mine()) { + log("Not a test for this run; skipped"); + return true; + } + if (desktopRun()) { + desktopWindow(1440, 900); + } + return run(); + } + + /// The desktop pass shoots a laptop-sized window. The simulator opens a + /// desktop at whatever size the content asks for, which for a phone app + /// is small; the window is sized here, and the form lays itself out again + /// at the new size as it would after a user resized it. + private void desktopWindow(int width, int height) throws Exception { + javax.swing.SwingUtilities.invokeAndWait(() -> { + for (java.awt.Frame f : java.awt.Frame.getFrames()) { + if (f.isShowing()) { + f.setExtendedState(java.awt.Frame.NORMAL); + f.setSize(width, height); + f.validate(); + } + } + }); + waitFor(1200); + } + + @Override + public void prepare() { + if (!mine()) { + return; + } + Session.useStore(new MemoryStore()); + onEdt(() -> { + Telemetry.allow(false); + Sfx.setMuted(true); + AppConfig.setMode("player"); + Session.start(AppConfig.serverUrl()); + Nav.launch(); + }); + waitForForm("signin"); + } + + @Override + public void cleanup() { + if (!mine()) { + return; + } + onEdt(() -> Session.start(AppConfig.serverUrl())); + } + + protected void onEdt(Runnable work) { + CN.callSeriallyAndWait(work); + } + + /// Presses a button, or taps anything else by that name -- a list row is + /// a `MultiButton`, which is a container holding its button. + protected void click(final String name) { + until(() -> findByName(name) != null, 20000, "a button called " + name); + if (!(findByName(name) instanceof Button)) { + touch(name); + return; + } + onEdt(() -> { + Button button = (Button) findByName(name); + button.pressed(); + button.released(); + }); + } + + protected void type(final String name, final String text) { + until(() -> findByName(name) instanceof TextArea, 20000, "a field called " + name); + onEdt(() -> ((TextArea) findByName(name)).setText(text)); + } + + protected boolean has(String name) { + return findByName(name) != null; + } + + protected void until(Check check, int timeoutMillis, String what) { + long deadline = System.currentTimeMillis() + timeoutMillis; + while (!check.ok()) { + if (System.currentTimeMillis() > deadline) { + Form current = Display.getInstance().getCurrent(); + shot("timed-out-" + (current == null ? "no-form" : current.getName())); + throw new RuntimeException("Timed out waiting for " + what + " (on " + + (current == null ? "no form" : current.getName()) + ")"); + } + waitFor(100); + } + } + + protected void waitForForm(final String name) { + until(() -> { + Form current = Display.getInstance().getCurrent(); + return current != null && name.equals(current.getName()); + }, 30000, "the " + name + " screen"); + waitFor(150); + } + + protected void signIn(String email) { + type("email", email); + type("password", PASSWORD); + click("signInButton"); + until(() -> Session.user() != null, 30000, "the sign-in"); + } + + protected ArenaView arena() { + until(() -> findByName("arena") instanceof ArenaView, 20000, "the arena"); + return (ArenaView) findByName("arena"); + } + + /// Plays with the rules' bot on the player's thumbs for `millis` of real + /// time, or until the match ends. Answers whether it ended. A test asserts + /// that a match ended, never that it was won. + protected boolean driveFor(final ArenaView view, int millis) { + onEdt(() -> view.setAutopilot(new Rng(7))); + try { + long end = System.currentTimeMillis() + millis; + while (System.currentTimeMillis() < end) { + final boolean[] over = new boolean[1]; + onEdt(() -> over[0] = view.driver().match().over()); + if (over[0]) { + return true; + } + waitFor(16); + } + } finally { + onEdt(() -> view.setAutopilot(null)); + } + return false; + } + + /// Plays the rest of an offline match at once, the bot choosing every + /// tick, so a five minute match costs a test a moment. The view then sees + /// a finished match on its next frame, as it would at the end of a real one. + protected void finish(final ArenaView view, final int maxTicks) { + onEdt(() -> { + Rng rng = new Rng(7); + Driver d = view.driver(); + Match m = d.match(); + int limit = m.tick() + maxTicks; + while (!m.over() && d.canStep() && m.tick() < limit) { + d.step(Bot.think(m, m.player(d.me()), rng)); + } + }); + } + + /// Waits for the result screen, collecting the reward that a run which + /// earned something shows first. + protected void awaitResult(String what) { + until(() -> has("result") || has("collectReward"), 20000, what); + // A run can earn more than one card -- the level's reward, then a + // level up -- and each is collected in turn. + boolean first = true; + while (!has("result")) { + if (has("collectReward")) { + if (first) { + shot("run-reward"); + first = false; + } + click("collectReward"); + waitFor(600); + } + until(() -> has("result") || has("collectReward"), 20000, what); + } + } + + /// A real touch: press and release at a point on the screen, the way a + /// finger would, so it reaches the form's pointer listeners. + protected void touch(final int x, final int y) { + onEdt(() -> { + Form f = Display.getInstance().getCurrent(); + f.pointerPressed(x, y); + f.pointerReleased(x, y); + }); + } + + protected void touch(String name) { + until(() -> findByName(name) != null, 20000, name); + // A tap lands only on what is on screen: scroll a row below the fold + // into view first. + onEdt(() -> { + Component k = findByName(name); + if (k.getComponentForm() != null) { + k.getComponentForm().scrollComponentToVisible(k); + } + }); + waitFor(600); + Component c = findByName(name); + touch(c.getAbsoluteX() + c.getWidth() / 2, c.getAbsoluteY() + c.getHeight() / 2); + } + + // -------------------------------------------------------- screenshots + + protected void shot(String name) { + String dir = System.getProperty("splat.shots.dir"); + if (dir == null || dir.length() == 0) { + return; + } + waitFor(1200); + capture(dir, name); + } + + /// A shot of a run in progress: the bot keeps steering while the frame + /// settles, or the runner would be on the floor by the time it is taken. + protected void shot(String name, ArenaView view) { + String dir = System.getProperty("splat.shots.dir"); + if (dir == null || dir.length() == 0) { + return; + } + driveFor(view, 1200); + capture(dir, name); + } + + /// A shot of this very moment, for a frame that would not survive the + /// bot driving on: a stamp, a replay, a clear view down the field. + protected void shotNow(String name) { + String dir = System.getProperty("splat.shots.dir"); + if (dir == null || dir.length() == 0) { + return; + } + waitFor(150); + capture(dir, name); + } + + private void capture(String dir, String name) { + String out = dir.startsWith("file:") ? dir : "file://" + dir; + final Image[] captured = new Image[1]; + onEdt(() -> captured[0] = Display.getInstance().captureScreen()); + FileSystemStorage files = FileSystemStorage.getInstance(); + try { + files.mkdir(out); + OutputStream png = files.openOutputStream(out + "/" + name + ".png"); + try { + ImageIO.getImageIO().save(captured[0], png, ImageIO.FORMAT_PNG, 1f); + } finally { + png.close(); + } + } catch (java.io.IOException err) { + log("Could not save " + name + ": " + err); + } + } + + static final class MemoryStore implements TokenStore { + private OidcTokens kept; + + @Override + public AsyncResource load(String key) { + AsyncResource out = new AsyncResource(); + out.complete(kept); + return out; + } + + @Override + public AsyncResource save(String key, OidcTokens tokens) { + kept = tokens; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + + @Override + public AsyncResource clear(String key) { + kept = null; + AsyncResource out = new AsyncResource(); + out.complete(Boolean.TRUE); + return out; + } + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/FriendsTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/FriendsTest.java new file mode 100644 index 00000000000..96cd4eb5b50 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/FriendsTest.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +/// Invite a friend: the player's code, what an invite pays, and who joined. +public class FriendsTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("friendsButton"); + waitForForm("friends"); + until(() -> has("inviteCode") && has("inviteButton"), 20000, "the invite code"); + shot("friends"); + return true; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/Guest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/Guest.java new file mode 100644 index 00000000000..76409238c9e --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/Guest.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.io.WebSocket; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// A second player for a test that needs company: signs in through the same +/// OAuth steps the app takes, opens its own game socket and says what the +/// test tells it to. The app under test is the other player. +final class Guest { + private final String server; + private String access; + private WebSocket socket; + private final List heard = new ArrayList(); + + private Guest(String server) { + this.server = server; + } + + static Guest signIn(String email, String password) throws Exception { + Guest g = new Guest(System.getProperty("splat.server.url", "http://localhost:18090")); + g.login(email.trim().toLowerCase(java.util.Locale.ROOT), password); + return g; + } + + private void login(String email, String password) throws Exception { + HttpURLConnection c = open("POST", "/login"); + String session = cookie(send(c, "username=" + enc(email) + "&password=" + enc(password))); + byte[] raw = new byte[32]; + new SecureRandom().nextBytes(raw); + String verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + HttpURLConnection a = open("GET", "/oauth2/authorize?response_type=code&client_id=" + AppConfig.CLIENT_ID + + "&redirect_uri=" + enc(AppConfig.REDIRECT_URI) + "&scope=" + enc("openid profile") + + "&state=guest&code_challenge=" + challenge + "&code_challenge_method=S256"); + a.setRequestProperty("Cookie", session); + a.getResponseCode(); + Matcher code = Pattern.compile("[?&]code=([^&]+)").matcher(String.valueOf(a.getHeaderField("Location"))); + if (!code.find()) { + throw new IllegalStateException("no authorization code for the guest"); + } + HttpURLConnection t = open("POST", "/oauth2/token"); + String body = read(send(t, "grant_type=authorization_code&code=" + code.group(1) + "&redirect_uri=" + + enc(AppConfig.REDIRECT_URI) + "&code_verifier=" + verifier + "&client_id=" + AppConfig.CLIENT_ID)); + Matcher token = Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\"").matcher(body); + if (!token.find()) { + throw new IllegalStateException("no token for the guest"); + } + access = token.group(1); + } + + /// Opens the game socket and joins the party `code`. + void join(final String code) throws Exception { + HttpURLConnection t = open("POST", "/api/game/ticket"); + t.setRequestProperty("Authorization", "Bearer " + access); + Matcher m = Pattern.compile("\"ticket\"\\s*:\\s*\"([^\"]+)\"").matcher(read(send(t, ""))); + if (!m.find()) { + throw new IllegalStateException("no game ticket for the guest"); + } + socket = WebSocket.build("ws" + server.substring(4) + "/ws/game?ticket=" + m.group(1)) + .onConnect(ws -> ws.send("{\"type\":\"join\",\"code\":\"" + code + "\"}")) + .onTextMessage((ws, text) -> { + synchronized (heard) { + heard.add(text); + } + }) + .connect(10000); + } + + void say(String text) { + socket.send("{\"type\":\"chat\",\"text\":\"" + text.replace("\"", "'") + "\"}"); + } + + void emote(String id) { + socket.send("{\"type\":\"emote\",\"id\":\"" + id + "\"}"); + } + + boolean heard(String fragment) { + synchronized (heard) { + for (String h : heard) { + if (h.indexOf(fragment) >= 0) { + return true; + } + } + } + return false; + } + + void leave() { + if (socket != null) { + socket.close(); + } + } + + private HttpURLConnection open(String method, String path) throws Exception { + HttpURLConnection c = (HttpURLConnection) new URL(server + path).openConnection(); + c.setRequestMethod(method); + c.setInstanceFollowRedirects(false); + if ("POST".equals(method)) { + c.setDoOutput(true); + c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + } + return c; + } + + private static HttpURLConnection send(HttpURLConnection c, String form) throws Exception { + try (OutputStream out = c.getOutputStream()) { + out.write(form.getBytes(StandardCharsets.UTF_8)); + } + c.getResponseCode(); + return c; + } + + private static String cookie(HttpURLConnection c) { + String all = ""; + for (int i = 0; c.getHeaderFieldKey(i) != null || i == 0; i++) { + if ("Set-Cookie".equalsIgnoreCase(c.getHeaderFieldKey(i))) { + String v = c.getHeaderField(i); + int semi = v.indexOf(';'); + all += (all.length() > 0 ? "; " : "") + (semi < 0 ? v : v.substring(0, semi)); + } + } + return all; + } + + private static String read(HttpURLConnection c) throws Exception { + InputStream in = c.getResponseCode() >= 400 ? c.getErrorStream() : c.getInputStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + for (int n; in != null && (n = in.read(b)) > 0;) { + out.write(b, 0, n); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + + private static String enc(String s) throws Exception { + return URLEncoder.encode(s, "UTF-8"); + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/MemoryPreferences.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/MemoryPreferences.java new file mode 100644 index 00000000000..4dd9bc2d202 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/MemoryPreferences.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import java.util.HashMap; +import java.util.Map; +import java.util.prefs.AbstractPreferences; +import java.util.prefs.Preferences; +import java.util.prefs.PreferencesFactory; + +/// Preferences that live in memory and die with the JVM, for recording the +/// desktop demo on a Mac. The simulator becomes a desktop only through its +/// "desktopSkin" preference, and outside Linux the JDK keeps preferences +/// where the developer's own simulator reads them (the macOS plist), so the +/// run swaps the whole store for this one with +/// `-Djava.util.prefs.PreferencesFactory=com.codenameone.examples.splat.MemoryPreferences`. +/// Every key named in `splat.prefs.seed` (comma separated `key=value`) starts +/// out set on every node. +public class MemoryPreferences implements PreferencesFactory { + private static final Preferences USER = new Node(null, ""); + private static final Preferences SYSTEM = new Node(null, ""); + + @Override + public Preferences userRoot() { + return USER; + } + + @Override + public Preferences systemRoot() { + return SYSTEM; + } + + private static final class Node extends AbstractPreferences { + private final Map values = new HashMap<>(); + private final Map children = new HashMap<>(); + + Node(Node parent, String name) { + super(parent, name); + String seed = System.getProperty("splat.prefs.seed", ""); + for (String kv : seed.split(",")) { + int eq = kv.indexOf('='); + if (eq > 0) { + values.put(kv.substring(0, eq).trim(), kv.substring(eq + 1).trim()); + } + } + } + + @Override + protected void putSpi(String key, String value) { + values.put(key, value); + } + + @Override + protected String getSpi(String key) { + return values.get(key); + } + + @Override + protected void removeSpi(String key) { + values.remove(key); + } + + @Override + protected void removeNodeSpi() { + values.clear(); + } + + @Override + protected String[] keysSpi() { + return values.keySet().toArray(new String[0]); + } + + @Override + protected String[] childrenNamesSpi() { + return children.keySet().toArray(new String[0]); + } + + @Override + protected AbstractPreferences childSpi(String name) { + return children.computeIfAbsent(name, n -> new Node(this, n)); + } + + @Override + protected void syncSpi() { + } + + @Override + protected void flushSpi() { + } + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/MissionBuilderTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/MissionBuilderTest.java new file mode 100644 index 00000000000..d9228bb0aa7 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/MissionBuilderTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.impl.javase.record.RecordingOptions; +import com.codename1.impl.javase.record.SimulatorRecorder; +import com.codename1.impl.javase.record.TouchOverlay; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codenameone.examples.splat.play.ArenaView; +import java.io.File; + +/// The Mission Builder end to end: start a field, place cover, spawns and a +/// flag by touch, turn a prop, name it and write the briefing, play-test it +/// against bots, publish it, and find it in the published list ready to play. +/// +/// When `splat.demo.dir` names a directory (`record-demo.sh`) the same run is +/// recorded, with captions, as the editor's tour. +public class MissionBuilderTest extends E2e { + private SimulatorRecorder recorder; + + @Override + public int getTimeoutMillis() { + return 400000; + } + + @Override + protected boolean desktop() { + return desktopRun(); + } + + @Override + protected boolean run() throws Exception { + String dir = System.getProperty("splat.demo.dir"); + boolean desk = desktopRun(); + try { + signIn(PLAYER); + waitForForm("home"); + if (dir != null && dir.length() > 0) { + record(new File(dir, desk ? "splat-desktop-mission-builder.mp4" : "splat-mobile-mission-builder.mp4")); + } + caption("The Mission Builder: design your own field", 3200); + tap("builderButton"); + waitForForm("missions"); + caption("Start a new field", 2800); + tap("newMission"); + waitForForm("missionEditor"); + caption("You build one half. The other half is its mirror -- always fair.", 3800); + waitFor(4000); + + caption("Pick a prop, tap the field to drop it", 3200); + tap("tool-container"); + at(0.25f, 0.68f); + tap("tool-car"); + at(0.72f, 0.62f); + caption("Turn it to make a lane", 2800); + tap("turnProp"); + tap("tool-barrier"); + at(0.5f, 0.58f); + tap("tool-tires"); + at(0.85f, 0.78f); + caption("Spawn points and the flag", 3000); + tap("tool-spawn"); + at(0.35f, 0.93f); + tap("mode-CAPTURE_THE_FLAG"); + tap("tool-flag"); + at(0.5f, 0.85f); + waitFor(1500); + + caption("Name the operation and write the briefing", 3200); + type("missionName", "Operation Driveway Denial"); + waitFor(1200); + type("missionBriefing", "The Hendersons parked across the cul-de-sac again. Reclaim the driveway."); + waitFor(2500); + shot("mission-editor"); + + caption("Play-test it against bots", 3000); + tap("playTest"); + waitForForm("match"); + final ArenaView test = arena(); + // play the test, as a player would: standing still until a ball + // arrives is what the tour showed before + until(() -> test.driver().countdownTicks() == 0, 20000, "the briefing to end"); + driveFor(test, 9000); + // in play, not painted out or showing a replay + long give = System.currentTimeMillis() + 40000; + while (!(PracticeTest.inPlay(test) && !driveFor(test, 1500) && PracticeTest.inPlay(test)) + && System.currentTimeMillis() < give) { + driveFor(test, 150); + } + shotNow("mission-playtest"); + caption("Your briefing, your field", 3000); + waitFor(5000); + onEdt(com.codenameone.examples.splat.ui.MissionsForm::show); + waitForForm("missions"); + tap("editDraft"); + waitForForm("missionEditor"); + + caption("Publish it for your friends and your party", 3200); + tap("publishMission"); + waitForForm("missions"); + until(() -> rowNamed("OPERATION DRIVEWAY DENIAL"), 20000, "the published mission in the list"); + caption("Published. Practice it, or take your party in.", 3600); + waitFor(4200); + shot("mission-builder"); + if (recorder != null) { + SimulatorRecorder.Result r = recorder.stop(); + recorder = null; + log("Recorded " + r.file + ": " + r.durationMs + " ms"); + } + return true; + } finally { + if (recorder != null && recorder.isRecording()) { + recorder.stop(); + } + } + } + + private boolean rowNamed(String title) { + Component list = findByName("missionList"); + return list instanceof Container && contains((Container) list, title); + } + + private static boolean contains(Container c, String text) { + for (int i = 0; i < c.getComponentCount(); i++) { + Component k = c.getComponentAt(i); + if (k instanceof com.codename1.ui.Label && text.equals(((com.codename1.ui.Label) k).getText())) { + return true; + } + if (k instanceof Container && contains((Container) k, text)) { + return true; + } + } + return false; + } + + /// A visible tap on the editor field at a fraction of its size. + private void at(float fx, float fy) { + until(() -> findByName("missionCanvas") != null, 20000, "the editor field"); + Component c = findByName("missionCanvas"); + waitFor(500); + if (c.getWidth() > c.getHeight()) { + // a wide window lays the field on its side: your half on the left + float across = 1f - fy; + fy = fx; + fx = across; + } + touch(c.getAbsoluteX() + (int) (c.getWidth() * fx), c.getAbsoluteY() + (int) (c.getHeight() * fy)); + waitFor(900); + } + + private void tap(String name) { + waitFor(400); + touch(name); + waitFor(800); + } + + private void record(File out) throws Exception { + recorder = JavaSEPort.instance.getSimulatorRecorder(); + RecordingOptions o = new RecordingOptions(); + o.setPath(out.getAbsolutePath()); + o.setFps(30); + o.setScale(1f); + o.setFrame(desktopRun() ? RecordingOptions.Frame.SCREEN : RecordingOptions.Frame.SKIN); + o.setTouches(TouchOverlay.Style.DOT); + o.setAudio(RecordingOptions.Audio.APP); + o.setMaxSeconds(240); + recorder.start(o); + } + + private void caption(String text, long ms) { + if (recorder != null) { + recorder.caption(text, ms); + waitFor(400); + } + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/OnlineTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/OnlineTest.java new file mode 100644 index 00000000000..0f50f21d550 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/OnlineTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.play.OnlineDriver; +import com.codenameone.examples.splat.ui.Nav; + +/// A quick match against the server. The test server fills a lone player's +/// match with bots at once, so this needs no second player. +public class OnlineTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(RIVAL); + waitForForm("home"); + click("quickRaceButton"); + waitForForm("match"); + final ArenaView view = arena(); + assertTrue(view.driver() instanceof OnlineDriver, "a live match"); + assertTrue(view.driver().match().playerCount() > 1, "other players on the field"); + // The match only moves past the briefing when the server's snapshots + // arrive: reaching tick 100 means they are. + long give = System.currentTimeMillis() + 30000; + while (view.driver().match().tick() < 100 && System.currentTimeMillis() < give) { + driveFor(view, 200); + } + assertTrue(view.driver().match().tick() >= 100, "server snapshots: tick " + + view.driver().match().tick() + ", " + ((OnlineDriver) view.driver()).describe()); + // The client carries a match over a dropped socket, which would hide + // a server that drops them: on a test machine none may drop at all. + assertTrue(((OnlineDriver) view.driver()).reconnects() == 0, "the socket dropped mid-match: " + + ((OnlineDriver) view.driver()).describe()); + click("comms"); + click("emote-gg"); + shot("match-online", view); + onEdt(() -> { + ((OnlineDriver) view.driver()).leave(); + view.stop(); + view.driver().dispose(); + Nav.home(); + }); + waitForForm("home"); + return true; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PartyTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PartyTest.java new file mode 100644 index 00000000000..45ca45b7e65 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PartyTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codenameone.examples.splat.play.OnlineDriver; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.ui.Nav; + +/// A party of two: the app hosts, a second player signs in from the test, +/// joins by the party code and talks; then they play on opposite teams and +/// trade emotes. The screenshots are the party chat and the match with the +/// guest's emote and chat on screen. +public class PartyTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("partyButton"); + waitForForm("party"); + until(() -> ((Label) findByName("partyCode")).getText().length() > 2, 20000, "the party code"); + String code = ((Label) findByName("partyCode")).getText().trim(); + Guest guest = Guest.signIn(RIVAL, PASSWORD); + try { + guest.join(code); + until(() -> guest.heard("\"party\""), 20000, "the guest in the party"); + waitFor(800); + guest.say("Hey! Ready when you are"); + waitFor(600); + onEdt(() -> { + TextField say = (TextField) findByName("chatField"); + say.setText("Cul-de-sac, loser mows the lawn?"); + say.fireDoneEvent(); + }); + waitFor(600); + guest.say("You're on. Bring goggles"); + until(() -> guest.heard("mows the lawn"), 10000, "the host's message at the guest"); + waitFor(1200); + shot("party-chat"); + click("goButton"); + waitForForm("match"); + final ArenaView view = arena(); + // A match only moves as the server's snapshots arrive, so a stalled + // one would spin here until the suite's timeout; give it half a + // minute and say where it stood. + long give = System.currentTimeMillis() + 30000; + while (view.driver().match().tick() < 6 * Match.TICK_HZ && System.currentTimeMillis() < give + && !driveFor(view, 200)) { + // keep playing + } + int tick = view.driver().match().tick(); + assertTrue(tick >= 6 * Match.TICK_HZ || view.driver().match().over(), + "the match reached tick " + tick + " in 30 s; " + + ((OnlineDriver) view.driver()).describe()); + assertTrue(((OnlineDriver) view.driver()).reconnects() == 0, "the socket dropped mid-match: " + + ((OnlineDriver) view.driver()).describe()); + guest.emote("bounce"); + guest.say("That one bounced, I swear"); + click("comms"); + click("emote-ref"); + driveFor(view, 1500); + shot("party-match", view); + onEdt(() -> { + ((OnlineDriver) view.driver()).leave(); + view.stop(); + view.driver().dispose(); + Nav.home(); + }); + waitForForm("home"); + } finally { + guest.leave(); + } + return true; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PracticeTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PracticeTest.java new file mode 100644 index 00000000000..838ca72a4a0 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PracticeTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.play.PracticeDriver; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.rules.Player; +import com.codenameone.examples.splat.rules.Rng; +import com.codenameone.examples.splat.rules.Trig; + +/// Practice against bots from the operations list: the briefing, the match in +/// progress, and the scoreboard at the end. +public class PracticeTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + click("runButton"); + waitForForm("play"); + click("mission-cul-de-sac:CAPTURE_THE_FLAG"); + shot("operations"); + click("practice-cul-de-sac:CAPTURE_THE_FLAG"); + waitForForm("match"); + final ArenaView view = arena(); + assertTrue(view.driver() instanceof PracticeDriver, "a practice match"); + shot("briefing"); + until(() -> view.driver().countdownTicks() == 0, 20000, "the briefing to end"); + long give = System.currentTimeMillis() + 20000; + while (view.driver().match().tick() < 6 * Match.TICK_HZ && System.currentTimeMillis() < give) { + driveFor(view, 200); + } + // The shot is of the HUD in play, so not while painted: then the view + // looks at the floor and the HUD counts down to the respawn. + give = System.currentTimeMillis() + 40000; + // Nor while facing a wall from arm's length: a shot of the field. + // Two checks apart, so a stamp from a moment ago (PAINTED, CAUGHT) has + // cleared and the player is up and looking ahead. + while (System.currentTimeMillis() < give && !(inPlay(view) && !driveFor(view, 1500) && inPlay(view))) { + driveFor(view, 150); + } + shotNow("match-practice"); + // a ball in the air can still land in the instant the capture takes + while (!inPlay(view) && System.currentTimeMillis() < give) { + driveFor(view, 1500); + if (inPlay(view)) { + shotNow("match-practice"); + } + } + + // The bird's-eye view, a button away in any match. + // Shot only while still in play, or a hit's replay takes the screen. + onEdt(view::toggleOverhead); + give = System.currentTimeMillis() + 40000; + do { + driveFor(view, 1500); + } while (!inPlay(view) && System.currentTimeMillis() < give); + shotNow("match-overhead"); + onEdt(view::toggleOverhead); + + // Getting painted, and the instant replay from the shooter's side. The bot drives until the moment you are hit + // and no longer, or it would wipe the paint off and the ref would see. + onEdt(() -> view.setAutopilot(new Rng(7))); + until(() -> view.replayQueued() || view.driver().match().over(), 90000, "a hit"); + onEdt(() -> view.setAutopilot(null)); + if (view.replayQueued()) { + until(() -> view.replayingShooter() >= 0, 8000, "the instant replay"); + waitFor(900); + shotNow("instant-replay"); + } + finish(view, 10 * 60 * Match.TICK_HZ); + assertTrue(view.driver().match().over(), "the match ended"); + awaitResult("the scoreboard"); + shot("scoreboard"); + click("done"); + waitForForm("play"); + return true; + } + + static boolean inPlay(ArenaView view) { + return view.driver().match().player(view.driver().me()).alive() && !view.replayQueued() + && lookingDownfield(view); + } + + /// True when the player can see five metres straight ahead at eye height. + private static boolean lookingDownfield(ArenaView view) { + Match m = view.driver().match(); + Player p = m.player(view.driver().me()); + int eye = p.y() + 1500; + int x = p.x() + (int) (5000L * Trig.sin(p.yaw()) / Trig.ONE); + int z = p.z() + (int) (5000L * Trig.cos(p.yaw()) / Trig.ONE); + return m.arena().clear(p.x(), eye, p.z(), x, eye, z); + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PromoVideoTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PromoVideoTest.java new file mode 100644 index 00000000000..13b31911e43 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PromoVideoTest.java @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.impl.javase.JavaSEPort; +import com.codename1.impl.javase.record.RecordingOptions; +import com.codename1.impl.javase.record.SimulatorRecorder; +import com.codename1.impl.javase.record.TouchOverlay; +import com.codename1.ui.Label; +import com.codename1.ui.TextField; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.play.OnlineDriver; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.ui.Nav; +import java.io.File; + +/// The promotional videos: the simulator's own recorder films a tour of the +/// game with captions -- a mission briefing, a practice match to the +/// scoreboard, and a live party match with chat and emotes -- and, when +/// `splat.e2e.clip` names a second file, a short gameplay clip inside the +/// phone's skin first. It is not a test of anything the others do not cover, +/// so it runs only when asked for (`run-e2e.sh --video`, which names the files +/// in `splat.e2e.video` and `splat.e2e.clip`). +public class PromoVideoTest extends E2e { + private SimulatorRecorder recorder; + + /// The clip and the whole tour play in real time, a few minutes. + @Override + public int getTimeoutMillis() { + return 600000; + } + + @Override + protected boolean run() throws Exception { + String out = System.getProperty("splat.e2e.video"); + if (out == null || out.length() == 0) { + log("The promo video is recorded only when splat.e2e.video names the file; skipped"); + return true; + } + Guest guest = null; + try { + signIn(PLAYER); + waitForForm("home"); + waitFor(1500); + String clip = System.getProperty("splat.e2e.clip"); + if (clip != null && clip.length() > 0) { + clip(clip); + } + record(out, RecordingOptions.Frame.SCREEN, 240); + caption("Splat -- team paintball you can make your own", 2600); + waitFor(2800); + + click("runButton"); + waitForForm("play"); + caption("Every mission gets the briefing it does not deserve", 2800); + click("mission-cul-de-sac:CAPTURE_THE_FLAG"); + waitFor(3000); + click("practice-cul-de-sac:CAPTURE_THE_FLAG"); + waitForForm("match"); + ArenaView view = arena(); + waitFor(3200); + caption("First person, 3D, four on four", 2800); + driveFor(view, 6000); + caption("Get hit and the whole field knows", 2800); + driveFor(view, 8000); + finish(view, 10 * 60 * Match.TICK_HZ); + caption("Scoreboards, trophies and excuses", 2600); + collectAll(); + waitFor(1200); + home(); + + click("partyButton"); + waitForForm("party"); + until(() -> ((Label) findByName("partyCode")).getText().length() > 2, 20000, "the party code"); + String code = ((Label) findByName("partyCode")).getText().trim(); + guest = Guest.signIn(RIVAL, PASSWORD); + final Guest g = guest; + caption("Play friends live: a party code, chat and voice", 3000); + g.join(code); + until(() -> g.heard("\"party\""), 20000, "the guest in the party"); + waitFor(900); + g.say("Bring goggles"); + waitFor(1100); + onEdt(() -> { + TextField say = (TextField) findByName("chatField"); + say.setText("Loser mows the lawn"); + say.fireDoneEvent(); + }); + waitFor(1500); + click("goButton"); + waitForForm("match"); + view = arena(); + caption("The server is the referee; the phone predicts", 3000); + driveFor(view, 8000); + g.emote("bounce"); + g.say("That one bounced"); + click("comms"); + click("emote-ref"); + driveFor(view, 6000); + final ArenaView live = view; + onEdt(() -> { + ((OnlineDriver) live.driver()).leave(); + live.stop(); + live.driver().dispose(); + }); + g.leave(); + guest = null; + home(); + + caption("Splat -- a Codename One sample. Rename it, reskin it, ship it.", 3200); + waitFor(3400); + SimulatorRecorder.Result r = recorder.stop(); + recorder = null; + log("Recorded " + r.file + ": " + r.durationMs + " ms, " + r.frames + " frames" + + (r.note == null ? "" : " -- " + r.note)); + assertTrue(r.frames > 0, "frames recorded"); + return true; + } finally { + if (recorder != null && recorder.isRecording()) { + recorder.stop(); + } + if (guest != null) { + guest.leave(); + } + } + } + + /// About thirty seconds of play in the phone's skin: one practice match. + private void clip(String out) throws Exception { + record(out, RecordingOptions.Frame.SKIN, 90); + caption("Splat", 1800); + click("runButton"); + waitForForm("play"); + waitFor(900); + click("practice-lot7:TEAM_PAINT"); + waitForForm("match"); + ArenaView view = arena(); + waitFor(3200); + driveFor(view, 10000); + caption("Built with Codename One", 2600); + driveFor(view, 8000); + finish(view, 10 * 60 * Match.TICK_HZ); + collectAll(); + SimulatorRecorder.Result r = recorder.stop(); + recorder = null; + log("Recorded " + r.file + ": " + r.durationMs + " ms, " + r.frames + " frames" + + (r.note == null ? "" : " -- " + r.note)); + assertTrue(r.frames > 0, "clip frames recorded"); + home(); + } + + private void record(String out, RecordingOptions.Frame frame, int maxSeconds) throws Exception { + recorder = JavaSEPort.instance.getSimulatorRecorder(); + RecordingOptions o = new RecordingOptions(); + o.setPath(new File(out).getAbsolutePath()); + o.setFps(30); + o.setScale(1f); + o.setFrame(frame); + o.setTouches(TouchOverlay.Style.DOT); + o.setAudio(RecordingOptions.Audio.APP); + o.setMaxSeconds(maxSeconds); + recorder.start(o); + } + + private void caption(String text, long ms) { + recorder.caption(text, ms); + } + + /// Back to the home screen, the way the tab bar takes a player there. + private void home() { + onEdt(Nav::home); + waitForForm("home"); + waitFor(800); + } + + /// Collects every reward card until the result shows. + private void collectAll() { + until(() -> has("result") || has("collectReward"), 20000, "the result"); + while (!has("result")) { + if (has("collectReward")) { + waitFor(1400); + click("collectReward"); + } + waitFor(600); + } + waitFor(1800); + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PromoteTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PromoteTest.java new file mode 100644 index 00000000000..8cfb180d4e7 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/PromoteTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.ui.Component; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Tabs; + +/// The console in a desktop window: the reports and their charts, a query +/// over the players, the engagement pipelines and one opened for editing, +/// then the Promote tab -- a push broadcast counted against its audience, +/// and a home spotlight that every player then sees. +public class PromoteTest extends E2e { + @Override + protected boolean desktop() { + return true; + } + + @Override + protected boolean run() throws Exception { + signIn(ADMIN); + waitForForm("home"); + click("adminButton"); + waitForForm("admin"); + until(() -> has("stat-Open flags") && has("chartPlayers"), 20000, "the numbers and the charts"); + waitFor(800); + shot("desktop-admin-overview"); + reveal("chartGems"); + shot("desktop-admin-reports"); + tab("Query"); + until(() -> has("queryRun"), 20000, "the query tool"); + click("queryRun"); + until(() -> has("queryCount"), 20000, "the players the query found"); + shot("desktop-admin-query"); + tab("Pipelines"); + until(() -> has("pipeline-Win back"), 20000, "the pipelines"); + shot("desktop-admin-pipelines"); + click("pipelineEdit-Win back"); + until(() -> has("pipelineSave"), 20000, "the pipeline editor"); + click("pipelineCountButton"); + waitFor(1500); + shot("desktop-admin-pipeline-edit"); + click("pipelineCancel"); + tab("Promote"); + until(() -> has("promoteTitle"), 20000, "the Promote tab"); + shot("desktop-admin-promote"); + type("promoteTitle", "Season two is here"); + type("promoteBody", "A new level is up. Come and race!"); + type("promoteCampaign", "season-two"); + reveal("promoteTitle"); + click("promoteCount"); + waitFor(1500); + shot("desktop-admin-broadcast"); + // Send asks first; the shot is the question, and the answer is no. + click("promoteSend"); + until(() -> Display.getInstance().getCurrent() instanceof Dialog, 20000, "the confirmation"); + shot("desktop-admin-broadcast-confirm"); + onEdt(() -> ((Dialog) Display.getInstance().getCurrent()).dispose()); + waitForForm("admin"); + reveal("spotlightText"); + type("spotlightText", "Season two: the Frost Arena is open"); + click("spotlightSave"); + waitFor(1500); + reveal("linkCampaign"); + click("linkMake"); + waitFor(1500); + shot("desktop-admin-spotlight"); + onEdt(() -> com.codenameone.examples.splat.ui.Nav.home()); + waitForForm("home"); + until(() -> has("spotlight"), 20000, "the spotlight banner"); + shot("desktop-home-spotlight"); + return true; + } + + /// Selects a tab by its title, so a tab added to the console cannot + /// silently shift which page each shot shows. + private void tab(final String title) { + onEdt(() -> { + Tabs t = (Tabs) findByName("adminTabs"); + for (int i = 0; i < t.getTabCount(); i++) { + if (title.equals(t.getTabTitle(i))) { + t.setSelectedIndex(i); + return; + } + } + throw new IllegalStateException("no admin tab " + title); + }); + waitFor(600); + } + + private void reveal(final String name) { + onEdt(() -> { + Component c = findByName(name); + c.getComponentForm().scrollComponentToVisible(c); + }); + waitFor(600); + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/ShopTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/ShopTest.java new file mode 100644 index 00000000000..d49cd019491 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/ShopTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codenameone.examples.splat.net.Session; + +/// Buying gems through the store and spending them in the game's shop. In +/// the simulator the store purchase completes at once with a simulated +/// receipt, which the test server's receipt validator accepts. +public class ShopTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + final int gems = Session.user().gems(); + click("shopButton"); + waitForForm("shop"); + until(() -> has("buy-gems-80"), 20000, "the shop items"); + shot("shop"); + click("buy-gems-80"); + until(() -> Session.user().gems() == gems + 80, 20000, "the gems to arrive"); + waitForForm("shop"); + until(() -> has("buy-paint-glitter"), 20000, "the shop items"); + click("buy-paint-glitter"); + until(() -> Session.user().owned().contains("paint-glitter"), 20000, "the paint to be owned"); + return true; + } +} diff --git a/scripts/splat/common/src/test/java/com/codenameone/examples/splat/TutorialTest.java b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/TutorialTest.java new file mode 100644 index 00000000000..106bb6bd3b9 --- /dev/null +++ b/scripts/splat/common/src/test/java/com/codenameone/examples/splat/TutorialTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat; + +import com.codename1.ui.CN; +import com.codenameone.examples.splat.api.RewardDto; +import com.codenameone.examples.splat.play.ArenaView; +import com.codenameone.examples.splat.ui.Nav; +import com.codenameone.examples.splat.ui.Rewards; +import com.codenameone.examples.splat.play.Arenas; +import com.codenameone.examples.splat.rules.Match; +import com.codenameone.examples.splat.ui.MatchForm; +import java.util.Arrays; + +/// The home screen with progress on it, then the first practice match the way +/// a new player meets it: the briefing, the match, the scoreboard and the +/// reward. +public class TutorialTest extends E2e { + @Override + protected boolean run() throws Exception { + signIn(PLAYER); + waitForForm("home"); + until(() -> has("level"), 20000, "the progress on the home screen"); + shot("home"); + onEdt(() -> MatchForm.practice(Arenas.DEFAULT, Nav::home)); + waitForForm("match"); + final ArenaView view = arena(); + shot("tutorial-briefing"); + until(() -> view.driver().countdownTicks() == 0, 20000, "the briefing to end"); + finish(view, 10 * 60 * Match.TICK_HZ); + awaitResult("the result"); + shot("tutorial-result"); + click("done"); + // Not through onEdt: the popup is modal and would hold it forever. + CN.callSerially(() -> Rewards.show(new RewardDto("Training complete", 100, 15, 150, 2, 2, true, + Arrays.asList("Weekend Warrior")), Nav::home)); + until(() -> has("collectReward"), 10000, "the reward"); + shot("reward-levelup"); + click("collectReward"); + waitForForm("home"); + return true; + } +} diff --git a/scripts/splat/ios/pom.xml b/scripts/splat/ios/pom.xml new file mode 100644 index 00000000000..4c68703c3b2 --- /dev/null +++ b/scripts/splat/ios/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + com.codenameone.examples.splat + splat + 1.0-SNAPSHOT + + com.codenameone.examples.splat + splat-ios + 1.0-SNAPSHOT + + splat-ios + + + UTF-8 + 17 + 17 + ios + ios + ios-device + + + + + src/main/objectivec + + + src/main/resources + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-ios + package + + build + + + + + + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + + + + + + + diff --git a/scripts/splat/ios/src/main/objectivec/.gitkeep b/scripts/splat/ios/src/main/objectivec/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/scripts/splat/pom.xml b/scripts/splat/pom.xml new file mode 100644 index 00000000000..a5d31fb1199 --- /dev/null +++ b/scripts/splat/pom.xml @@ -0,0 +1,321 @@ + + 4.0.0 + com.codenameone.examples.splat + splat + 1.0-SNAPSHOT + pom + splat + splat + https://www.codenameone.com + + + MIT No Attribution + https://opensource.org/license/mit-0 + repo + The same terms as the LICENSE file beside this pom + + + + + rules + + shared + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 1.8 + 11 + 1.7.11 + 3.8.0 + 1.8 + 1.8 + 1.8 + splat + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-android-compat + ${cn1.version} + provided + + + + com.codenameone + cn1-binaries-javase + ${cn1.version} + pom + runtime + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + codenameone + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + codenameone-plugins + https://repo.codenameone.com/maven2 + + true + + + false + + + + + + + + cn1-client-tests + + + codename1.platform + !backend + + + + ${basedir}/codenameone_settings.properties + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + javascript + + + codename1.platform + javascript + + + ${basedir}/javascript/pom.xml + + + + javascript + + + + ios + + + codename1.platform + ios + + + ${basedir}/ios/pom.xml + + + + ios + + + + win + + + codename1.platform + win + + + ${basedir}/win/pom.xml + + + + win + + + + linux + + + codename1.platform + linux + + + ${basedir}/linux/pom.xml + + + + linux + + + + + backend + + + codename1.platform + backend + + + ${basedir}/backend/pom.xml + + + + backend + + + + android + + + codename1.platform + android + + + ${basedir}/android/pom.xml + + + + android + + + + javase + + + codename1.platform + javase + + + ${basedir}/javase/pom.xml + + + + javase + + + + cn1libs + + + ${basedir}/cn1libs/pom.xml + + + + cn1libs + + + + diff --git a/scripts/splat/record-demo.sh b/scripts/splat/record-demo.sh new file mode 100755 index 00000000000..3d607953055 --- /dev/null +++ b/scripts/splat/record-demo.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Records the gameplay demos with sound -- one on the simulator's phone, one +# with the simulator as a desktop window -- into the directory given (default +# target/demo). Runs on a desktop with a display: the simulator's recorder +# films its own window. Preferences are kept in memory for the run +# (MemoryPreferences), so the developer's own simulator settings are untouched. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +OUT="$(mkdir -p "${1:-$HERE/target/demo}" && cd "${1:-$HERE/target/demo}" && pwd)" +MVN="${MVN:-mvn}" +PORT="${SPLAT_PORT:-18090}" +LOG="$HERE/target/demo-server.log" +mkdir -p "$HERE/target" + +"$HERE/backend/server.sh" build +CN1_PROFILE=test "$HERE/backend/server.sh" run --prebuilt --port "$PORT" >"$LOG" 2>&1 & +SERVER_PID=$! +trap 'kill "$SERVER_PID" >/dev/null 2>&1 || true; wait "$SERVER_PID" 2>/dev/null || true' EXIT +for _ in $(seq 1 90); do + curl -fs -o /dev/null "http://localhost:$PORT/oauth2/jwks" 2>/dev/null && break + kill -0 "$SERVER_PID" 2>/dev/null || { cat "$LOG" >&2; exit 1; } + sleep 1 +done + +BASE="${JAVA_TOOL_OPTIONS:-} -Xmx2560m -Dsplat.server.url=http://localhost:$PORT -Dsplat.demo.dir=$OUT -Dsplat.e2e.only=DemoVideoTest,MissionBuilderTest -Djava.util.prefs.PreferencesFactory=com.codenameone.examples.splat.MemoryPreferences" +JAVA_TOOL_OPTIONS="$BASE" "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify +JAVA_TOOL_OPTIONS="$BASE -Dsplat.e2e.desktop=true -Dsplat.prefs.seed=desktopSkin=true -Dcodename1.arg.desktop.titleBar=toolbar" \ + "$MVN" -B -f "$HERE/pom.xml" -Dcodename1.platform=javase -Ptest verify +ls -l "$OUT" diff --git a/scripts/splat/rules/pom.xml b/scripts/splat/rules/pom.xml new file mode 100644 index 00000000000..ad19a8b3dfd --- /dev/null +++ b/scripts/splat/rules/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.codenameone.examples.splat + splat + 1.0-SNAPSHOT + + splat-rules + jar + splat-rules + + + + 1.8 + 1.8 + + + + + org.junit.jupiter + junit-jupiter + 5.9.3 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + + diff --git a/scripts/splat/rules/src/main/java/com/codenameone/examples/splat/rules/Arena.java b/scripts/splat/rules/src/main/java/com/codenameone/examples/splat/rules/Arena.java new file mode 100644 index 00000000000..0173f4366b9 --- /dev/null +++ b/scripts/splat/rules/src/main/java/com/codenameone/examples/splat/rules/Arena.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ +package com.codenameone.examples.splat.rules; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +/// A paintball field: a fenced rectangle of boxes, two team bases and the snack bar. +/// +/// The field runs from `(0, 0)` to `(width, depth)` in millimetres. Team 0 spawns +/// near `z = 0`, team 1 near `z = depth`. Fields are built mirrored by +/// [Builder#mirror] so neither side is better, which the tests check. +public final class Arena { + /// Millimetres per metre. + public static final int UNIT = 1000; + + public final String id; + public final String name; + public final int width; + public final int depth; + private final List boxes; + /// Spawn points per team, `{x, z}` pairs. + private final int[][] spawns; + /// Flag base per team, `{x, z}`. + private final int[][] bases; + /// The snack bar: centre and radius. + public final int hillX; + public final int hillZ; + public final int hillRadius; + /// Built the first time a bot asks; the same for every match on this arena. + private final AtomicReference