diff --git a/.github/workflows/build-runtime.yml b/.github/workflows/build-runtime.yml new file mode 100644 index 0000000..d5dd8d7 --- /dev/null +++ b/.github/workflows/build-runtime.yml @@ -0,0 +1,242 @@ +name: Build Runtime (micro.sfx) + +on: + release: + types: [published] + workflow_dispatch: + inputs: + php_version: + description: 'PHP version' + required: true + default: '8.4' + type: choice + options: + - '8.4' + - '8.5' + +env: + PHP_VERSION: ${{ inputs.php_version || '8.4' }} + +jobs: + # ── macOS build (native) ── + build-macos: + name: micro.sfx / macos-arm64 / PHP ${{ inputs.php_version || '8.4' }} + runs-on: macos-14 + steps: + - name: Checkout static-php-cli (fork) + uses: actions/checkout@v4 + with: + repository: hmennen90/static-php-cli + ref: main + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, mbstring, xml, tokenizer + tools: composer + + - name: Install dependencies + run: | + composer install --no-dev --no-interaction --ignore-platform-reqs + brew install cmake pkg-config + + - name: Download sources + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + php bin/spc download \ + --with-php=${{ env.PHP_VERSION }} \ + --for-extensions=glfw,mbstring,zip,phar \ + --prefer-pre-built + + - name: Build micro.sfx + run: | + php bin/spc doctor --auto-fix + php bin/spc build glfw,mbstring,zip,phar --build-micro --debug + + - name: Upload micro.sfx + uses: actions/upload-artifact@v4 + with: + name: micro-macos-arm64 + path: buildroot/bin/micro.sfx + retention-days: 90 + + # ── Linux builds (Alpine Docker for musl-compatible X11) ── + build-linux: + name: micro.sfx / linux-${{ matrix.arch }} / PHP ${{ inputs.php_version || '8.4' }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout static-php-cli (fork) + uses: actions/checkout@v4 + with: + repository: hmennen90/static-php-cli + ref: main + + - name: Build micro.sfx in Alpine container + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + docker build -t spc-glfw -f- . << 'DOCKERFILE' + FROM alpine:3.21 + + RUN apk update && apk upgrade -a && apk add --no-cache \ + autoconf automake bash binutils bison build-base cmake curl \ + file flex g++ gcc git jq libgcc libtool libstdc++ \ + linux-headers m4 make pkgconfig re2c wget xz gettext-dev \ + binutils-gold \ + libx11-dev libx11-static libxrandr-dev libxinerama-dev libxcursor-dev \ + libxi-dev libxi-static libxext-static libxcb-dev libxcb-static \ + libxau-dev libxdmcp-dev mesa-dev \ + && cd /tmp \ + && wget -q https://xorg.freedesktop.org/releases/individual/lib/libXrandr-1.5.4.tar.xz \ + && tar xf libXrandr-1.5.4.tar.xz && cd libXrandr-1.5.4 \ + && ./configure --enable-static --disable-shared --prefix=/usr >/dev/null 2>&1 \ + && make -j$(nproc) >/dev/null 2>&1 && make install >/dev/null 2>&1 \ + && rm -rf /tmp/libXrandr* + + RUN curl -#fSL https://dl.static-php.dev/static-php-cli/bulk/php-8.4.4-cli-linux-$(uname -m).tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/php + RUN curl -#fSL https://getcomposer.org/download/latest-stable/composer.phar \ + -o /usr/local/bin/composer && chmod +x /usr/local/bin/composer + + WORKDIR /app + DOCKERFILE + + docker run --rm \ + -e GITHUB_TOKEN="${GITHUB_TOKEN}" \ + -v "$(pwd)/config:/app/config" \ + -v "$(pwd)/src:/app/src" \ + -v "$(pwd)/bin:/app/bin" \ + -v "$(pwd)/composer.json:/app/composer.json" \ + -v "$(pwd)/composer.lock:/app/composer.lock" \ + -v "$(pwd)/buildroot:/app/buildroot" \ + -v "$(pwd)/source:/app/source" \ + -v "$(pwd)/downloads:/app/downloads" \ + -v "$(pwd)/pkgroot:/app/pkgroot" \ + -v "$(pwd)/log:/app/log" \ + spc-glfw bash -c ' + composer install --no-dev --no-interaction --ignore-platform-reqs + bin/spc doctor --auto-fix + + # Copy Alpine musl X11 headers/libs into buildroot (not symlink — volume mounts break symlinks) + mkdir -p buildroot/include buildroot/lib buildroot/lib/pkgconfig + for dir in X11 GL; do + [ -d "/usr/include/$dir" ] && cp -r "/usr/include/$dir" buildroot/include/ + done + for lib in /usr/lib/libX11.a /usr/lib/libXrandr.a /usr/lib/libXinerama.a \ + /usr/lib/libXcursor.a /usr/lib/libXi.a /usr/lib/libXext.a \ + /usr/lib/libXfixes.a /usr/lib/libXrender.a /usr/lib/libxcb.a \ + /usr/lib/libXau.a /usr/lib/libXdmcp.a; do + [ -f "$lib" ] && cp "$lib" buildroot/lib/ + done + for pc in /usr/lib/pkgconfig/x11.pc /usr/lib/pkgconfig/xrandr.pc \ + /usr/lib/pkgconfig/xinerama.pc /usr/lib/pkgconfig/xcursor.pc \ + /usr/lib/pkgconfig/xi.pc /usr/lib/pkgconfig/xext.pc \ + /usr/lib/pkgconfig/xfixes.pc /usr/lib/pkgconfig/xrender.pc \ + /usr/lib/pkgconfig/xcb.pc /usr/lib/pkgconfig/xau.pc \ + /usr/lib/pkgconfig/xdmcp.pc; do + [ -f "$pc" ] && cp "$pc" buildroot/lib/pkgconfig/ + done + + bin/spc download --with-php=${{ env.PHP_VERSION }} \ + --for-extensions=glfw,mbstring,zip,phar --prefer-pre-built + bin/spc build glfw,mbstring,zip,phar --build-micro --debug || { + echo "=== spc.shell.log ===" && cat log/spc.shell.log 2>/dev/null | tail -100 + echo "=== spc.output.log ===" && cat log/spc.output.log 2>/dev/null | tail -100 + exit 1 + } + ' + + - name: Upload micro.sfx + uses: actions/upload-artifact@v4 + with: + name: micro-linux-${{ matrix.arch }} + path: buildroot/bin/micro.sfx + retention-days: 90 + + # ── Windows build ── + build-windows: + name: micro.sfx / windows-x86_64 / PHP ${{ inputs.php_version || '8.4' }} + runs-on: windows-latest + steps: + - name: Checkout static-php-cli (fork) + uses: actions/checkout@v4 + with: + repository: hmennen90/static-php-cli + ref: main + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, mbstring, xml, tokenizer + tools: composer + + - name: Install dependencies + run: composer install --no-dev --no-interaction --ignore-platform-reqs + + - name: Download sources + shell: powershell + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + php bin/spc download ` + --with-php=${{ env.PHP_VERSION }} ` + --for-extensions=glfw,mbstring,zip,phar ` + --prefer-pre-built + + - name: Build micro.sfx + shell: powershell + run: | + php bin/spc doctor --auto-fix + php bin/spc build glfw,mbstring,zip,phar --build-micro --debug + + - name: Upload micro.sfx + uses: actions/upload-artifact@v4 + with: + name: micro-windows-x86_64 + path: buildroot/bin/micro.sfx + retention-days: 90 + + # ── Upload binaries to release ── + upload-to-release: + name: Upload to Release + needs: [build-macos, build-linux, build-windows] + if: github.event_name == 'release' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir release + for dir in artifacts/micro-*/; do + platform=$(basename "$dir") + cp "$dir/micro.sfx" "release/${platform}.sfx" + done + ls -lh release/ + + - name: Upload to existing release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for file in release/*.sfx; do + echo "Uploading $(basename $file)..." + gh release upload "${{ github.event.release.tag_name }}" "$file" --repo "${{ github.repository }}" --clobber + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74da75c..219dc5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,57 +1,93 @@ -name: VISU CI +name: VISU CI on: push: - branches: [ '*' ] + branches: ['*'] pull_request: - branches: [ '*' ] + branches: ['*'] jobs: - ubuntu: - - runs-on: ${{ matrix.operating-system }} + tests: + name: PHP ${{ matrix.php }} — Tests + runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - operating-system: ['ubuntu-latest'] - php-versions: ['8.1', '8.2'] - phpunit-versions: ['9.6'] - + php: ['8.3', '8.4', '8.5'] + steps: - - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-versions }} - extensions: :glfw - coverage: xdebug - tools: cs2pr, phpunit:${{ matrix.phpunit-versions }} - - - name: Install dependenies - run: sudo apt-get update && sudo apt-get install -y php-dev build-essential cmake libglfw3-dev xvfb libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev gdb - - - name: Build PHP-GLFW - run: > - git clone https://github.com/mario-deluna/php-glfw && - cd php-glfw && - sudo phpize && - ./configure --enable-glfw && - make -j$(nproc) && - sudo make install && - cd ../ && - grep -qxF 'extension=glfw.so' /etc/php/${{ matrix.php-versions }}/cli/php.ini || - echo 'extension=glfw.so' >> /etc/php/${{ matrix.php-versions }}/cli/php.ini - - - name: Run composer install - run: composer install - - #- name: Install PHPUnit - # run: composer require "phpunit/phpunit" - - - name: Run PHPUnit - run: xvfb-run --auto-servernum phpunit - - - name: Run PHPStan - if: ${{ matrix.php-versions == '8.2' }} - run: php vendor/bin/phpstan analyse src --error-format=github -l8 - + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, mbstring, xml, tokenizer + coverage: none + tools: phpunit:9.6 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + php${{ matrix.php }}-dev php${{ matrix.php }}-xml php${{ matrix.php }}-curl \ + build-essential cmake \ + libglfw3-dev xvfb \ + libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev + + - name: Build PHP-GLFW + run: | + git clone https://github.com/mario-deluna/php-glfw + cd php-glfw + sudo phpize + ./configure --enable-glfw + make -j$(nproc) + sudo make install + cd ../ + echo 'extension=glfw.so' | sudo tee -a $(php -r "echo php_ini_loaded_file();") + + - name: Install Composer dependencies + run: composer install --no-interaction + + - name: Run PHPUnit + run: xvfb-run --auto-servernum phpunit + + static-analysis: + name: PHPStan (Level 8) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: dom, curl, mbstring, xml, tokenizer + coverage: none + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + php8.4-dev php8.4-xml php8.4-curl \ + build-essential cmake \ + libglfw3-dev \ + libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev + + - name: Build PHP-GLFW + run: | + git clone https://github.com/mario-deluna/php-glfw + cd php-glfw + sudo phpize + ./configure --enable-glfw + make -j$(nproc) + sudo make install + cd ../ + echo 'extension=glfw.so' | sudo tee -a $(php -r "echo php_ini_loaded_file();") + + - name: Install Composer dependencies + run: composer install --no-interaction + + - name: Run PHPStan + run: php vendor/bin/phpstan analyse src --error-format=github -l8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..36f15c6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + branches: [master] + +jobs: + semantic-release: + name: Semantic Release + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + new_release: ${{ steps.release.outputs.new_release_published }} + version: ${{ steps.release.outputs.new_release_version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install semantic-release + run: npm install -g semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/github + + - name: Run semantic-release + id: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release diff --git a/.gitignore b/.gitignore index fff46e7..c1a6232 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ composer.phar composer.lock +CLAUDE.md /vendor/ /coverage/ /var/ @@ -7,3 +8,5 @@ docs/docs-assets/ .phpdoc/ docs/api bin/phpDocumentor.phar +node_modules/ +.phpunit.result.cache diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..536c0d4 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,9 @@ +{ + "branches": ["master"], + "tagFormat": "v${version}", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] +} diff --git a/bin/visu b/bin/visu index ea298e5..279dd51 100755 --- a/bin/visu +++ b/bin/visu @@ -1,33 +1,64 @@ get('visu.command.cli_loader')->pass($argv); \ No newline at end of file +$container->get('visu.command.cli_loader')->pass($argv); diff --git a/bootstrap.php b/bootstrap.php index bc04ae8..4bc0be3 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -32,6 +32,27 @@ if (!defined('VISU_APPCONFIG_PREFIX')) define('VISU_APPCONFIG_PREFIX', 'app'); if (!defined('VISU_APPCONFIG_ROOT')) define('VISU_APPCONFIG_ROOT', '/app.ctn'); +/** + * ---------------------------------------------------------------------------- + * Ensure required directories exist + * ---------------------------------------------------------------------------- + * + * When VISU is required as a dependency in a new project, these directories + * may not exist yet. Create them early so the container factory and all + * downstream code can rely on their presence. + */ +foreach ([ + VISU_PATH_CACHE, + VISU_PATH_STORE, + VISU_PATH_RESOURCES, + VISU_PATH_RESOURCES_SHADER, + VISU_PATH_APPCONFIG, +] as $requiredDir) { + if (!is_dir($requiredDir)) { + mkdir($requiredDir, 0777, true); + } +} + /** * ---------------------------------------------------------------------------- * Setup the Container @@ -43,12 +64,17 @@ $container = $factory->create('GameContainer', function($builder) { - // ensure var directory with cache and store exists - if (!file_exists(VISU_PATH_CACHE)) mkdir(VISU_PATH_CACHE, 0777, true); - if (!file_exists(VISU_PATH_STORE)) mkdir(VISU_PATH_STORE, 0777, true); + + // Ensure app.ctn exists in the project root. When VISU is used as an engine + // dependency, projects may not have created one yet — create an empty one so + // the container namespace can resolve the `import app` in visu.ctn. + $appCtnFile = VISU_PATH_ROOT . VISU_APPCONFIG_ROOT; + if (!file_exists($appCtnFile)) { + file_put_contents($appCtnFile, "/**\n * VISU application container configuration.\n * @see https://container.clancats.com/\n */\n"); + } $importPaths = [ - 'app' => VISU_PATH_ROOT . VISU_APPCONFIG_ROOT, + 'app' => $appCtnFile, ]; global $overrideVisuBaseImportPaths; @@ -60,7 +86,17 @@ // create a new container file namespace and parse our `app.ctn` file. $namespace = new \ClanCats\Container\ContainerNamespace($importPaths); - $namespace->importDirectory(VISU_PATH_APPCONFIG, VISU_APPCONFIG_PREFIX); + if (is_dir(VISU_PATH_APPCONFIG)) { + $namespace->importDirectory(VISU_PATH_APPCONFIG, VISU_APPCONFIG_PREFIX); + } + // Generate an empty container map if it doesn't exist yet. + // This happens when VISU is used as an engine dependency and the consumer + // project hasn't run the Composer post-autoload-dump script. + $containerMapFile = VISU_PATH_VENDOR . '/container_map.php'; + if (!file_exists($containerMapFile)) { + file_put_contents($containerMapFile, "importFromVendor(VISU_PATH_VENDOR); // start with visu diff --git a/composer.json b/composer.json index 7b3ed53..c73a9d8 100644 --- a/composer.json +++ b/composer.json @@ -4,6 +4,7 @@ "license": "MIT", "require": { "php": ">=8.1", + "ext-glfw": "*", "clancats/container": "^1.3", "league/climate": "^3.8" }, @@ -13,6 +14,9 @@ "phpgl/ide-stubs": "dev-main", "phpbench/phpbench": "^1.2" }, + "suggest": { + "ext-ffi": "Required for audio (SDL3/OpenAL) and gamepad support" + }, "autoload": { "psr-4": { "VISU\\": "src/" @@ -29,6 +33,12 @@ "scripts": { "post-autoload-dump": [ "ClanCats\\Container\\ComposerContainerFileLoader::generateMap" + ], + "post-install-cmd": [ + "VISU\\Setup\\ComposerSetupScript::postInstall" + ], + "post-update-cmd": [ + "VISU\\Setup\\ComposerSetupScript::postUpdate" ] }, "extra": { diff --git a/docs/3D_ENGINE_PLAN.md b/docs/3D_ENGINE_PLAN.md new file mode 100644 index 0000000..64782cf --- /dev/null +++ b/docs/3D_ENGINE_PLAN.md @@ -0,0 +1,315 @@ +# 3D Engine Plan — Netrunner: Uprising & Beyond + +> Dieses Dokument beschreibt die 3D-Engine-Erweiterungen nach Code Tycoon. +> Voraussetzung: Code Tycoon ist spielbar (Phase 4 abgeschlossen). +> Referenz: CLAUDE.md fuer Projekt-Kontext und Konventionen. + +--- + +## Ziel + +Die VISU-Engine von einer 2D-faehigen zu einer vollwertigen 3D-Engine erweitern. +Erstes 3D-Spiel: **Netrunner: Uprising** (Cyberpunk RPG). + +--- + +## IST-Zustand (was fuer 3D bereits existiert) + +| Modul | Status | Details | +|-------|--------|---------| +| OpenGL 4.1 | Vorhanden | php-glfw Bindings, alle Shader-Stages | +| Deferred Rendering | Vorhanden | GBuffer Pass, Deferred Light Pass | +| SSAO | Vorhanden | 3 Qualitaetsstufen (Low/Medium/High) | +| Perspective Camera | Vorhanden | VISUCameraSystem mit Flying-Mode | +| Heightmap Terrain | Vorhanden | CPU + GPU Renderer, Ray-Terrain Intersection | +| Low-Poly Renderer | Vorhanden | OBJ-Modelle, Shadow Casting | +| 3D Debug | Vorhanden | BoundingBox, Ray Visualisierung | +| Transform | Vorhanden | Vollstaendige 3D Transform-Hierarchie | +| Directional Light | Vorhanden | Component mit Direction, Color, Intensity | + +--- + +## Phase 6 — 3D Rendering Pipeline (Wochen 23-28) + +**Ziel:** Professionelle 3D-Rendering-Qualitaet. + +``` +Mesh-System: +[ ] MeshComponent + MeshRenderer System +[ ] glTF 2.0 Loader (Szenen, Meshes, Materialien, Animationen) + - glTF Binary (.glb) fuer optimierte Ladezeiten +[ ] OBJ Loader verbessern (Normals, UVs, Multi-Material) +[ ] Mesh-Instancing (GPU Instanced Rendering fuer wiederholte Objekte) +[ ] LOD-System (Level of Detail — Mesh-Wechsel nach Kamera-Distanz) + +Material-System: +[ ] MaterialComponent (Shader-Referenz + Property-Map) +[ ] PBR Material (Albedo, Normal, Metallic, Roughness, AO Maps) +[ ] Material-Instanzen (Shared Material + Per-Entity Overrides) +[ ] Texture-Atlas / Texture-Arrays fuer Batching +[ ] Shader-Varianten (#define basiert, z.B. HAS_NORMAL_MAP, SKINNED) + +Beleuchtung: +[ ] PointLight Component (Position, Color, Intensity, Range, Attenuation) +[ ] SpotLight Component (Direction, Angle, Falloff) +[ ] Shadow Mapping (Directional: Cascaded Shadow Maps, Point: Cubemap Shadows) +[ ] Light Culling (Tile-based oder Clustered, fuer viele Lichter) +[ ] Emissive Materials (Self-Illumination) +[ ] Environment Mapping (Cubemap Reflections, IBL) + +Post-Processing: +[ ] Bloom (HDR Glow) +[ ] Tone Mapping (ACES, Reinhard, Filmic) +[ ] Anti-Aliasing (FXAA oder TAA) +[ ] Motion Blur (per-Object oder Screen-Space) +[ ] Depth of Field +[ ] Color Grading / LUT +[ ] Fog (Linear, Exponential, Volumetric) +[ ] Vignette + +[ ] MEILENSTEIN: PBR-Material Szene mit mehreren Lichtquellen, Schatten, + Bloom und SSAO rendert bei 60 FPS. +``` + +--- + +## Phase 7 — 3D Physics & Animation (Wochen 29-34) + +**Ziel:** Physik-Simulation und skelettale Animation. + +``` +Physics: +[ ] RigidBody3D Component (Mass, Velocity, Angular Velocity, Drag) +[ ] Collider3D Components (BoxCollider3D, SphereCollider3D, CapsuleCollider3D) +[ ] PhysicsSystem (Broad Phase: AABB-Tree, Narrow Phase: GJK/SAT) +[ ] Collision Response (Impulse-based, Restitution, Friction) +[ ] Trigger Volumes (OnTriggerEnter/Stay/Exit) +[ ] Raycasting 3D (Ray vs. Collider, Layer-basierte Filterung) +[ ] Physics Layers (Collision Matrix: welche Layer kollidieren) +[ ] CharacterController3D (Gravity, Ground Check, Slope Handling, Steps) +[ ] Option: php-bullet oder php-physics C-Extension fuer Performance + +Skelettale Animation: +[ ] Skeleton Component (Bone-Hierarchie, Bind Pose) +[ ] SkinnedMeshRenderer (GPU Skinning, Bone Matrices als Uniform Buffer) +[ ] AnimationClip (Keyframes fuer Position/Rotation/Scale pro Bone) +[ ] Animator Component + AnimatorSystem + - Animation State Machine (States, Transitions, Conditions) + - Blend Trees (1D/2D Blending zwischen Clips) + - Animation Layers (Full Body + Upper Body Override) +[ ] glTF Animation Import (Clips aus glTF extrahieren) +[ ] IK System (Inverse Kinematics — Feet Placement, Look-At) +[ ] Root Motion (Bewegung aus Animation statt aus Code) + +Partikel-System: +[ ] ParticleEmitter Component + - Emitter-Formen: Point, Sphere, Box, Cone, Mesh Surface + - Spawn Rate, Burst, Lifetime +[ ] ParticleSystem (GPU-basiert oder CPU mit Instancing) + - Size/Color/Velocity over Lifetime (Curves) + - Gravity, Drag, Turbulence + - Texture Sheet Animation + - Sub-Emitters (Partikel spawnen Partikel) + - Collision mit Welt (optional) +[ ] Vorgefertigte Effekte: Feuer, Rauch, Funken, Regen, Staub + +[ ] MEILENSTEIN: Animierter Charakter laeuft durch Szene, kollidiert mit Waenden, + Partikeleffekte bei Interaktionen. +``` + +--- + +## Phase 8 — Netrunner Game Systems (Wochen 35-44) + +**Ziel:** Netrunner-spezifische Gameplay-Systeme. + +``` +Welt & Navigation: +[ ] Navmesh System (Navmesh-Generierung aus Level-Geometrie) +[ ] NavAgent Component (Pathfinding auf Navmesh, Obstacle Avoidance) +[ ] Alternativ: Grid-basiertes A* Pathfinding (einfacher, schneller) +[ ] Waypoint-System (vordefinierte Pfade fuer NPCs) + +Charakter-System: +[ ] PlayerController3D (First/Third Person, Mouselook, Springen, Crouchen) +[ ] RPG Stats Component (Hacking, Stealth, Combat, Charisma — Enum-basiert) +[ ] InventorySystem + InventoryComponent (Items, Equipment Slots, Stacking) +[ ] ItemDatabase (JSON-definiert: ID, Name, Typ, Stats, Icon, Beschreibung) + +Kampf-System: +[ ] CombatSystem (Echtzeit oder Pausierbar) +[ ] WeaponComponent (Damage, Range, Fire Rate, Ammo, Reload) +[ ] HealthSystem + HealthComponent (HP, Shields, Damage Types, Death) +[ ] HitDetection (Hitscan Raycast oder Projectile Entities) +[ ] AI Combat (Deckung suchen, Flanken, Retreat bei Low HP) + +Dialog-System: +[ ] DialogueTree (JSON-definiert, Knoten mit Text + Optionen + Bedingungen) +[ ] DialogueSystem (Baum traversieren, Bedingungen pruefen, Konsequenzen) +[ ] DialogueUI (Portrait, Text mit Typewriter-Effekt, Antwort-Optionen) +[ ] Conditions: Skill-Checks, Quest-State, Inventar, Reputation +[ ] Consequences: Quest starten, Item geben, Reputation aendern + +Quest-System: +[ ] QuestSystem + QuestComponent + - Quest-Definitionen als JSON (Titel, Beschreibung, Objectives, Rewards) + - Objective-Typen: Kill, Collect, GoTo, Talk, Hack, Deliver + - Quest-States: Available, Active, Completed, Failed + - Quest-Tracking UI (aktive Quests, Objectives, Fortschritt) +[ ] QuestTrigger Component (Bereich betreten -> Quest starten/updaten) + +Hacking-Minispiel: +[ ] HackingSystem (Netrunner-Kernmechanik) + - Netzwerk als Graph-Datenstruktur (Nodes + Edges) + - Node-Typen: Firewall, Data Store, Security, ICE, Access Point + - Hacking-Actions: Breach, Decrypt, Upload, Download, Disable + - Zeitdruck: Trace-Timer, Alarm-Level +[ ] HackingUI (Netzwerk-Visualisierung, Node-Details, Action-Auswahl) + +KI & Verhalten: +[ ] BehaviorTree System (JSON-definierte Baeume) + - Node-Typen: Sequence, Selector, Parallel, Decorator, Leaf + - Leaf Actions: MoveTo, Attack, Patrol, Flee, Idle, Investigate + - Conditions: CanSeePlayer, IsHealthLow, IsInRange, HasAmmo +[ ] AI Perception (Sichtfeld, Hoerweite, Alarm-Propagation) +[ ] NPC Schedules (Tagesablauf, Routen, Aktivitaeten) + +[ ] MEILENSTEIN: Spieler bewegt sich durch Cyberpunk-Level, spricht mit NPCs, + hackt Terminals, kaempft gegen Feinde. 20+ Minuten Gameplay. +``` + +--- + +## Phase 9 — 3D Editor & Tools (Wochen 45-52) + +**Ziel:** Visueller 3D-Editor fuer Level-Design. + +``` +Editor-Erweiterungen: +[ ] 3D Viewport im Vue SPA (WebGL Preview oder Screenshot-Stream) +[ ] Transform Gizmos (Translate/Rotate/Scale — existiert teilweise in GizmoEditorSystem) +[ ] Multi-Select + Group Operations +[ ] Undo/Redo (Command Pattern) +[ ] Copy/Paste Entities +[ ] Snap-to-Grid, Snap-to-Surface + +Level-Design Tools: +[ ] Brush-basiertes Level-Building (CSG oder Prefab-Platzierung) +[ ] ProBuilder-aehnliches Mesh-Editing (einfache Geometrie im Editor) +[ ] Material-Zuweisung per Drag & Drop +[ ] Lightmap Baking (optional, Pre-computed GI) + +Spezial-Editoren: +[ ] Dialog-Editor (visueller Knoten-Editor fuer Dialogue Trees) +[ ] Behavior-Tree-Editor (visueller Knoten-Editor) +[ ] Quest-Editor (Objectives verketten, Bedingungen konfigurieren) +[ ] Hacking-Level-Editor (Netzwerk-Graph visuell erstellen) + +Asset-Pipeline: +[ ] glTF Import mit Preview +[ ] Texture Compression (ETC2, S3TC — je nach Plattform) +[ ] Audio Conversion Pipeline (WAV -> OGG, Normalisierung) +[ ] Asset-Referenz-Tracker (wo wird welches Asset genutzt?) + +[ ] MEILENSTEIN: Komplettes Level in Vue Editor gebaut, mit Dialogbaum, + NPC-Platzierung, Licht-Setup — als JSON exportiert und spielbar. +``` + +--- + +## Phase 10 — Polish & Distribution (Wochen 53+) + +**Ziel:** Netrunner ist als Standalone-Spiel verteilbar. + +``` +Performance: +[ ] Frustum Culling (existiert teilweise in src/Geo/Frustum.php) +[ ] Occlusion Culling (optional, fuer dichte Indoor-Level) +[ ] Draw Call Batching (Static/Dynamic Batching) +[ ] Texture Streaming (Mipmap-Level basierend auf Distanz) +[ ] Object Pooling (Entity Recycling fuer Projektile, Partikel, etc.) +[ ] GPU Profiler Dashboard (existiert in src/Instrument/) + +Audio 3D: +[ ] Spatial Audio (Listener Position, Panning, Distance Attenuation) +[ ] Audio Occlusion (Waende daempfen Sound) +[ ] Reverb Zones (Halleffekte je nach Raum) +[ ] Music System (Adaptive Music, Layer-basiert je nach Spielzustand) + +Cinematics: +[ ] Timeline/Sequencer (Kamera-Fahrten, Animationen, Audio, Events) +[ ] Cutscene-System (Timeline + Dialog + Kamera synchronisiert) +[ ] Camera Tracks (Spline-basierte Kamera-Pfade) + +Distribution: +[ ] Build-Script fuer 3D (groessere Assets, optimierte Shader) +[ ] Asset-Bundles (Lazy-Load Level-Assets) +[ ] Mod-Support (Custom Levels, Custom Dialoge, Custom Items) +[ ] Steam-Integration (optional: Achievements, Workshop) + +[ ] MEILENSTEIN: Netrunner als Standalone-App verteilbar, + 60+ Minuten Content, stabile Performance. +``` + +--- + +## Architektur-Notizen + +### Rendering-Architektur (erweitert) + +``` +Forward+ oder Deferred (existiert bereits) -> PBR Pipeline + -> Shadow Atlas + -> Post-Processing Stack + -> Debug Overlays + +Pass-Reihenfolge (3D): +1. Shadow Map Pass (pro Lichtquelle) +2. GBuffer Pass (Geometrie -> Position/Normal/Albedo/Roughness/Metallic) +3. SSAO Pass (existiert) +4. Deferred Light Pass (existiert, erweitern um Point/Spot) +5. Forward Pass (Transparente Objekte, Partikel) +6. Post-Processing Stack (Bloom, Tonemap, AA, DoF, Fog) +7. UI Pass (FlyUI/UIInterpreter) +8. Debug Overlay Pass +``` + +### Physics-Architektur + +``` +PhysicsWorld (Singleton Component) + -> Broad Phase: Dynamic AABB Tree (src/System/VISUAABBTreeSystem.php als Basis) + -> Narrow Phase: GJK + EPA oder SAT + -> Solver: Sequential Impulse + -> Queries: Raycast, Overlap, Sweep + +Spaeter optional: php-bullet FFI Bindings fuer native Bullet Physics Performance +``` + +### AI-Architektur + +``` +BehaviorTreeSystem + -> BehaviorTree (JSON -> Baum-Struktur) + -> Composite: Sequence, Selector, Parallel + -> Decorator: Inverter, Repeater, Succeeder, UntilFail + -> Leaf: MoveTo, Attack, Wait, PlayAnimation, SetVariable + -> Blackboard (Key-Value Store pro Entity fuer AI-State) + +PerceptionSystem + -> SightPerception (Sichtfeld-Kegel, Raycast Sichtpruefung) + -> HearingPerception (Sound-Events mit Radius) + -> AlertSystem (Alarm-Propagation zwischen NPCs) +``` + +--- + +## Abhaengigkeiten + +``` +Phase 6 (3D Rendering) -> Keine (baut auf existierender Pipeline auf) +Phase 7 (Physics & Anim) -> Phase 6 (Meshes + Materials noetig) +Phase 8 (Game Systems) -> Phase 7 (Physics + Animation noetig) +Phase 9 (3D Editor) -> Phase 6 (3D Viewport noetig) +Phase 10 (Polish) -> Phase 8 (Gameplay muss stehen) +``` diff --git a/docs/VISU_Engine_Documentation.docx b/docs/VISU_Engine_Documentation.docx new file mode 100644 index 0000000..bb31acd Binary files /dev/null and b/docs/VISU_Engine_Documentation.docx differ diff --git a/docs/generate_docx.py b/docs/generate_docx.py new file mode 100644 index 0000000..3f93a3b --- /dev/null +++ b/docs/generate_docx.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +"""Generate VISU Engine documentation as DOCX.""" + +from docx import Document +from docx.shared import Inches, Pt, Cm, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.oxml.ns import qn +import os + +doc = Document() + +# -- Styles -- +style = doc.styles['Normal'] +style.font.name = 'Calibri' +style.font.size = Pt(11) +style.paragraph_format.space_after = Pt(6) + +for level in range(1, 5): + h = doc.styles[f'Heading {level}'] + h.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) + +# -- Helper functions -- +def add_code_block(text, doc=doc): + p = doc.add_paragraph() + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.left_indent = Cm(0.5) + run = p.add_run(text) + run.font.name = 'Consolas' + run.font.size = Pt(9) + run.font.color.rgb = RGBColor(0x2D, 0x2D, 0x2D) + # Background shading + shading = run._element.get_or_add_rPr() + s = shading.makeelement(qn('w:shd'), { + qn('w:val'): 'clear', + qn('w:fill'): 'F5F5F5', + }) + shading.append(s) + return p + +def add_table(headers, rows, doc=doc): + table = doc.add_table(rows=1 + len(rows), cols=len(headers)) + table.style = 'Light Grid Accent 1' + table.alignment = WD_TABLE_ALIGNMENT.LEFT + for i, h in enumerate(headers): + cell = table.rows[0].cells[i] + cell.text = h + for p in cell.paragraphs: + for r in p.runs: + r.bold = True + r.font.size = Pt(10) + for ri, row in enumerate(rows): + for ci, val in enumerate(row): + cell = table.rows[ri + 1].cells[ci] + cell.text = str(val) + for p in cell.paragraphs: + for r in p.runs: + r.font.size = Pt(10) + doc.add_paragraph() # spacer + return table + +def add_bullet(text, bold_prefix=None, doc=doc): + p = doc.add_paragraph(style='List Bullet') + if bold_prefix: + run = p.add_run(bold_prefix) + run.bold = True + p.add_run(text) + else: + p.add_run(text) + return p + +# ============================================================ +# TITLE PAGE +# ============================================================ +doc.add_paragraph() +doc.add_paragraph() +title = doc.add_paragraph() +title.alignment = WD_ALIGN_PARAGRAPH.CENTER +run = title.add_run('VISU') +run.font.size = Pt(48) +run.bold = True +run.font.color.rgb = RGBColor(0x1A, 0x1A, 0x2E) + +subtitle = doc.add_paragraph() +subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER +run = subtitle.add_run('PHP Game Engine') +run.font.size = Pt(24) +run.font.color.rgb = RGBColor(0x55, 0x55, 0x77) + +doc.add_paragraph() + +desc = doc.add_paragraph() +desc.alignment = WD_ALIGN_PARAGRAPH.CENTER +run = desc.add_run('Technische Dokumentation') +run.font.size = Pt(16) + +doc.add_paragraph() + +ver = doc.add_paragraph() +ver.alignment = WD_ALIGN_PARAGRAPH.CENTER +run = ver.add_run('Version 2.0 — März 2026') +run.font.size = Pt(12) +run.font.color.rgb = RGBColor(0x88, 0x88, 0x88) + +doc.add_page_break() + +# ============================================================ +# TABLE OF CONTENTS (manual) +# ============================================================ +doc.add_heading('Inhaltsverzeichnis', level=1) +toc_items = [ + '1. Überblick', + '2. Architektur', + '3. Technischer Stack', + '4. Projektstruktur', + '5. Entity Component System (ECS)', + '6. Scene System', + '7. Render Pipeline', + '8. FlyUI — Immediate-Mode GUI', + '9. UI System (JSON-basiert)', + '10. Audio System', + '11. 2D Collision System', + '12. Camera 2D System', + '13. Save/Load System', + '14. Signal & Event System', + '15. Transpiler (Build-Optimierung)', + '16. Input System', + '17. Distribution', + '18. Entwicklungsumgebung', + '19. Roadmap', +] +for item in toc_items: + p = doc.add_paragraph(item) + p.paragraph_format.space_after = Pt(2) + +doc.add_page_break() + +# ============================================================ +# 1. ÜBERBLICK +# ============================================================ +doc.add_heading('1. Überblick', level=1) + +doc.add_paragraph( + 'VISU ist eine PHP-native Game Engine für 2D- und 3D-Spiele. ' + 'Sie basiert auf dem OpenGL-Framework php-glfw und erweitert dieses ' + 'um ein vollständiges Entity Component System, Scene Management, ' + 'Audio, UI, Collision Detection und mehr.' +) + +doc.add_paragraph( + 'Die Engine ist als CLI-Anwendung konzipiert und nutzt einen Fixed-Timestep ' + 'Game Loop mit variablem Rendering. Das Datenformat ist durchgehend JSON — ' + 'für Szenen, UI-Layouts, Konfiguration und Spielstände.' +) + +doc.add_heading('Projektziele', level=2) +add_bullet('2D Engine — Komplett und produktionsbereit', bold_prefix=None) +add_bullet('3D Engine — In Planung (Mesh-Loading, Lighting, Physics)', bold_prefix=None) +add_bullet('AI-gestütztes Game Authoring — Natürliche Sprache als primäres Authoring-Interface', bold_prefix=None) +add_bullet('Open Source — MIT-Lizenz', bold_prefix=None) + +# ============================================================ +# 2. ARCHITEKTUR +# ============================================================ +doc.add_heading('2. Architektur', level=1) + +doc.add_paragraph('VISU folgt einer geschichteten Architektur:') + +add_code_block( + 'Authoring Layer → LLM (Claude Code) + Vue SPA Editor + Manuell\n' + 'Datenformat → JSON (Szenen, UI-Layouts, Config, Saves)\n' + 'Game-Agnostic Layer → Scene-System, UI-Interpreter, AudioManager,\n' + ' SaveManager, Mod-System\n' + '────────────────────────────────────────────────────────────\n' + 'VISU Engine (src/) → ECS, Game Loop, Input, Render-Pipeline,\n' + ' Namespace: VISU\\ Signal-System, Camera, FlyUI, Graphics,\n' + ' Shader Management, Font Rendering\n' + '────────────────────────────────────────────────────────────\n' + 'C-Extensions → php-glfw (NanoVG 2D, OpenGL 4.1 3D)\n' + 'Hardware → GPU' +) + +doc.add_heading('Kern-Entscheidungen', level=2) +add_table( + ['Thema', 'Entscheidung'], + [ + ['2D Rendering', 'php-glfw + NanoVG'], + ['3D Rendering', 'php-glfw + OpenGL 4.1'], + ['Editor UI', 'Vue SPA (Web-basiert, localhost)'], + ['In-Game UI', 'JSON → UIInterpreter (kein HTML/CSS)'], + ['Szenen-Format', 'JSON (Entity-Hierarchien, Transforms, Components)'], + ['Save-Format', 'JSON (SaveManager mit Slots, Autosave, Migrationen)'], + ['Audio-Format', 'WAV + MP3 (minimp3 via FFI)'], + ['Game Loop', 'PHP CLI, Fixed Timestep + Variable Render'], + ['Distribution', 'Launcher-Binary + statisches PHP-Binary + game.phar'], + ['DI Container', 'ClanCats Container (.ctn Konfigurationsdateien)'], + ] +) + +# ============================================================ +# 3. TECHNISCHER STACK +# ============================================================ +doc.add_heading('3. Technischer Stack', level=1) + +doc.add_heading('Voraussetzungen', level=2) +add_table( + ['Komponente', 'Version', 'Beschreibung'], + [ + ['PHP', '≥ 8.1 (CLI-SAPI)', 'JIT empfohlen für Performance'], + ['php-glfw', '*', 'OpenGL 4.1 + NanoVG 2D (C-Extension)'], + ['ext-ffi', 'optional', 'Für Audio (SDL3/OpenAL) und Gamepad-Support'], + ['ClanCats Container', '^1.3', 'Dependency Injection'], + ['League\\CLImate', '^3.8', 'CLI-Ausgabe'], + ['Composer', 'aktuell', 'PSR-4 Autoloading (VISU\\ → src/)'], + ] +) + +doc.add_heading('Entwicklungswerkzeuge', level=2) +add_table( + ['Tool', 'Zweck', 'Befehl'], + [ + ['PHPUnit', 'Unit Tests (257+ Tests)', './vendor/bin/phpunit'], + ['PHPStan', 'Statische Analyse (Level 8)', './vendor/bin/phpstan analyse'], + ['PHPBench', 'Performance Benchmarks', './vendor/bin/phpbench run'], + ] +) + +doc.add_heading('Bootstrap & DI', level=2) +doc.add_paragraph('Die Engine nutzt Pfad-Konstanten, die vor dem Bootstrap definiert werden:') +add_table( + ['Konstante', 'Beschreibung'], + [ + ['VISU_PATH_ROOT', 'Projekt-Wurzelverzeichnis'], + ['VISU_PATH_CACHE', 'Cache-Verzeichnis'], + ['VISU_PATH_STORE', 'Persistenter Speicher'], + ['VISU_PATH_RESOURCES', 'Anwendungs-Ressourcen'], + ['VISU_PATH_APPCONFIG', 'Pfad zu anwendungsspezifischen .ctn Dateien'], + ] +) + +# ============================================================ +# 4. PROJEKTSTRUKTUR +# ============================================================ +doc.add_heading('4. Projektstruktur', level=1) + +add_code_block( + 'visu/\n' + ' composer.json Package: phpgl/visu\n' + ' visu.ctn DI-Container Konfiguration\n' + ' bootstrap.php Application Bootstrap\n' + ' phpunit.xml PHPUnit Konfiguration\n' + ' phpstan.neon PHPStan Level 8\n' + '\n' + ' src/ Engine-Quellcode (Namespace: VISU\\)\n' + ' Animation/ Transition-Animationen\n' + ' Asset/ AssetManager (Lazy-Loading + Caching)\n' + ' Audio/ AudioManager, Mp3Decoder, Backends\n' + ' Command/ CLI-Kommando-System\n' + ' Component/ ECS-Components\n' + ' ECS/ Entity Component System\n' + ' FlyUI/ Immediate-Mode GUI\n' + ' Geo/ Geometrie (AABB, Ray, Frustum, Transform)\n' + ' Graphics/ OpenGL-Rendering\n' + ' Instrument/ Profiling\n' + ' OS/ Window, Input, GamepadManager\n' + ' Runtime/ GameLoop, DebugConsole\n' + ' Save/ SaveManager, SaveSlot\n' + ' Scene/ SceneLoader, SceneSaver, PrefabManager\n' + ' SDL3/ SDL3 FFI-Bindings\n' + ' Signal/ Event/Signal-System\n' + ' System/ ECS-Systeme (Camera2D, Collision2D, ...)\n' + ' Transpiler/ JSON → PHP Build-Optimierung\n' + ' UI/ UIInterpreter, UIScreenStack\n' + '\n' + ' tests/ PHPUnit Tests (spiegelt src/)\n' + ' resources/ Shader, Fonts, Models, Libraries\n' + ' examples/ Beispiel-Anwendungen\n' + ' bin/visu CLI Entry Point' +) + +# ============================================================ +# 5. ECS +# ============================================================ +doc.add_heading('5. Entity Component System (ECS)', level=1) + +doc.add_paragraph( + 'Das ECS ist das Herzstück der Engine. Entities sind leichtgewichtige IDs, ' + 'Components sind reine Datenobjekte, und Systems enthalten die Logik.' +) + +doc.add_heading('Kernklassen', level=2) +add_table( + ['Klasse', 'Pfad', 'Beschreibung'], + [ + ['EntityRegistry', 'src/ECS/EntityRegistry.php', 'Verwaltet Entities mit Freelist-Pooling für effiziente Wiederverwendung'], + ['ComponentRegistry', 'src/ECS/ComponentRegistry.php', 'Registriert und löst Component-Typen auf (String → Klasse)'], + ['SystemInterface', 'src/ECS/SystemInterface.php', 'Interface für alle ECS-Systeme (register/unregister)'], + ] +) + +doc.add_heading('Verfügbare Components', level=2) +add_table( + ['Component', 'Beschreibung'], + [ + ['SpriteRenderer', 'Sprite-Darstellung mit Texture, Layer, Flip'], + ['SpriteAnimator', 'Frame-basierte Sprite-Animation'], + ['Tilemap', 'Tile-basierte Karten mit Auto-Tiling (Bitmask)'], + ['NameComponent', 'Benennung von Entities'], + ['BoxCollider2D', 'Rechteckiger 2D-Collider (AABB)'], + ['CircleCollider2D', 'Kreisförmiger 2D-Collider'], + ['AnimationComponent', 'Animations-Steuerung'], + ['DirectionalLightComponent', 'Richtungslicht für 3D-Szenen'], + ] +) + +doc.add_heading('Beispiel: Entity erstellen', level=2) +add_code_block( + '$entity = $entities->create();\n' + '$sprite = $entities->attach($entity, new SpriteRenderer());\n' + '$sprite->sprite = "assets/sprites/player.png";\n' + '$sprite->sortingLayer = 10;\n' + '\n' + '$name = $entities->attach($entity, new NameComponent("Player"));' +) + +# ============================================================ +# 6. SCENE SYSTEM +# ============================================================ +doc.add_heading('6. Scene System', level=1) + +doc.add_paragraph( + 'Szenen werden als JSON-Dateien definiert und enthalten Entity-Hierarchien ' + 'mit Transforms, Components und Kinder-Entities. Der SceneLoader/SceneSaver ' + 'konvertiert bidirektional zwischen JSON und ECS-Entities.' +) + +doc.add_heading('Kernklassen', level=2) +add_table( + ['Klasse', 'Pfad', 'Beschreibung'], + [ + ['SceneLoader', 'src/Scene/SceneLoader.php', 'Lädt JSON-Szenen in EntityRegistry'], + ['SceneSaver', 'src/Scene/SceneSaver.php', 'Serialisiert Entities zurück nach JSON'], + ['SceneManager', 'src/Scene/SceneManager.php', 'Verwaltet aktive Szenen und Übergänge'], + ['PrefabManager', 'src/Scene/PrefabManager.php', 'Prefab-Instanziierung aus JSON-Templates'], + ] +) + +doc.add_heading('Szenen-Format', level=2) +add_code_block( + '{\n' + ' "entities": [{\n' + ' "name": "Player",\n' + ' "transform": {\n' + ' "position": [0, 1, 0],\n' + ' "rotation": [0, 0, 0],\n' + ' "scale": [1, 1, 1]\n' + ' },\n' + ' "components": [\n' + ' { "type": "SpriteRenderer", "sprite": "assets/player.png" },\n' + ' { "type": "CharacterController", "speed": 5.0 }\n' + ' ],\n' + ' "children": []\n' + ' }]\n' + '}' +) + +# ============================================================ +# 7. RENDER PIPELINE +# ============================================================ +doc.add_heading('7. Render Pipeline', level=1) + +doc.add_paragraph( + 'Die Render Pipeline basiert auf OpenGL 4.1 via php-glfw. ' + 'Für 2D-Rendering wird NanoVG verwendet, für 3D steht die volle ' + 'OpenGL-Pipeline mit Shader-Management, Framebuffers und Deferred Rendering zur Verfügung.' +) + +doc.add_heading('Kernmodule', level=2) +add_table( + ['Modul', 'Pfad', 'Beschreibung'], + [ + ['ShaderProgram', 'src/Graphics/', 'Shader-Kompilierung und -Verwaltung'], + ['Framebuffer', 'src/Graphics/', 'Off-Screen Rendering, GBuffer'], + ['Texture', 'src/Graphics/', 'Texture-Loading und -Management'], + ['RenderPipeline', 'src/Graphics/Rendering/', 'Multi-Pass Rendering'], + ['SpriteBatchPass', 'src/Graphics/Rendering/', 'Batched 2D-Sprite-Rendering'], + ['Camera', 'src/Graphics/', 'Kamera-Verwaltung (2D/3D)'], + ['Font', 'src/Graphics/Font/', 'Bitmap Font Rendering'], + ] +) + +# ============================================================ +# 8. FlyUI +# ============================================================ +doc.add_heading('8. FlyUI — Immediate-Mode GUI', level=1) + +doc.add_paragraph( + 'FlyUI ist VISUs eingebautes Immediate-Mode GUI-System. ' + 'Widgets werden pro Frame aufgerufen — es gibt keinen persistenten Widget-Tree. ' + 'Das System unterstützt Layout-Management, Theming und Input-Handling.' +) + +doc.add_heading('Verfügbare Widgets', level=2) +add_table( + ['Widget', 'Klasse', 'Beschreibung'], + [ + ['Button', 'FUIButton', 'Klickbare Schaltfläche mit Label'], + ['ButtonGroup', 'FUIButtonGroup', 'Gruppierte Schaltflächen'], + ['Label/Text', 'FUIText / FUILabel', 'Textanzeige'], + ['Card', 'FUICard', 'Container mit optionalem Titel'], + ['Checkbox', 'FUICheckbox', 'Toggle-Schalter'], + ['Select', 'FUISelect', 'Dropdown-Auswahl'], + ['ProgressBar', 'FUIProgressBar', 'Fortschrittsbalken'], + ['Space', 'FUISpace', 'Abstandshalter'], + ['Layout', 'FUILayout', 'Container mit Flow-Richtung'], + ] +) + +doc.add_heading('Layout-System', level=2) +add_table( + ['Enum', 'Werte', 'Beschreibung'], + [ + ['FUILayoutFlow', 'Row, Column', 'Anordnungsrichtung'], + ['FUILayoutAlignment', 'Start, Center, End, Stretch', 'Ausrichtung'], + ['FUILayoutSizing', 'Fixed, Grow, Shrink', 'Größenverhalten'], + ] +) + +# ============================================================ +# 9. UI SYSTEM +# ============================================================ +doc.add_heading('9. UI System (JSON-basiert)', level=1) + +doc.add_paragraph( + 'Für spielspezifische UIs bietet VISU ein JSON-basiertes UI-System. ' + 'JSON-Layouts werden vom UIInterpreter rekursiv in FlyUI-Aufrufe übersetzt. ' + 'Data Binding und Event Handling sind eingebaut.' +) + +doc.add_heading('UI-Format', level=2) +add_code_block( + '{\n' + ' "type": "panel",\n' + ' "layout": "column",\n' + ' "padding": 10,\n' + ' "children": [\n' + ' { "type": "label", "text": "Geld: {economy.money}", "fontSize": 16 },\n' + ' { "type": "progressbar", "value": "{player.health}", "color": "#0088ff" },\n' + ' { "type": "button", "label": "Aktion", "event": "ui.action" }\n' + ' ]\n' + '}' +) + +doc.add_heading('Node-Typen', level=2) +add_table( + ['Typ', 'Beschreibung'], + [ + ['panel', 'Container mit Layout (row/column)'], + ['label', 'Textanzeige mit Data Binding ({path.to.value})'], + ['button', 'Klickbar, löst UIEventSignal aus'], + ['progressbar', 'Wertanzeige 0.0–1.0 mit Farbe'], + ['checkbox', 'Toggle mit Event'], + ['select', 'Dropdown-Auswahl'], + ['image', 'Bildanzeige'], + ['space', 'Abstandshalter'], + ] +) + +doc.add_heading('Kernklassen', level=2) +add_table( + ['Klasse', 'Beschreibung'], + [ + ['UIInterpreter', 'Wandelt JSON-Bäume in FlyUI-Aufrufe um'], + ['UIDataContext', 'Stellt Daten für {path} Bindings bereit'], + ['UIScreenStack', 'Stack-basierte Screen-Verwaltung (push/pop/replace)'], + ['UIScreen', 'Einzelner UI-Screen mit optionaler Transparenz'], + ['UITransition', 'Animierte Übergänge (FadeIn, SlideIn, ScaleIn, etc.)'], + ['UIEventSignal', 'Signal für Button/Checkbox/Select Events'], + ] +) + +# ============================================================ +# 10. AUDIO SYSTEM +# ============================================================ +doc.add_heading('10. Audio System', level=1) + +doc.add_paragraph( + 'Das Audio-System unterstützt WAV- und MP3-Dateien mit automatischer ' + 'Backend-Erkennung. Zwei Backends stehen zur Verfügung: SDL3 (primär) ' + 'und OpenAL (Fallback). MP3-Decoding erfolgt über minimp3 via PHP FFI.' +) + +doc.add_heading('Architektur', level=2) +add_code_block( + 'AudioManager\n' + ' ├── AudioBackendInterface\n' + ' │ ├── SDL3AudioBackend (SDL3 via FFI)\n' + ' │ └── OpenALAudioBackend (OpenAL via FFI)\n' + ' ├── Mp3Decoder (minimp3 via FFI)\n' + ' └── AudioClipData (Backend-agnostische PCM-Daten)' +) + +doc.add_heading('Unterstützte Formate', level=2) +add_table( + ['Format', 'Decoder', 'Beschreibung'], + [ + ['WAV', 'Backend-nativ', 'Unkomprimiertes PCM, von beiden Backends direkt geladen'], + ['MP3', 'minimp3 (FFI)', 'Komprimiert, via minimp3 C-Library dekodiert'], + ] +) + +doc.add_heading('MP3-Support (minimp3)', level=2) +doc.add_paragraph( + 'minimp3 ist eine Header-only C-Library, die als Shared Library ' + 'vorkompiliert für alle Plattformen mitgeliefert wird:' +) +add_table( + ['Plattform', 'Pfad', 'Größe'], + [ + ['macOS arm64', 'resources/lib/minimp3/darwin-arm64/libminimp3.dylib', '~84 KB'], + ['macOS x86_64', 'resources/lib/minimp3/darwin-x86_64/libminimp3.dylib', '~48 KB'], + ['Linux x86_64', 'resources/lib/minimp3/linux-x86_64/libminimp3.so', '~120 KB'], + ['Windows x86_64', 'resources/lib/minimp3/windows-x86_64/minimp3.dll', '~196 KB'], + ] +) + +doc.add_heading('API-Beispiele', level=2) +add_code_block( + '// AudioManager erstellen (Auto-Detection)\n' + '$audio = AudioManager::create($sdl);\n' + '\n' + '// Sound-Effekte abspielen (WAV oder MP3)\n' + '$audio->playSound("assets/sfx/explosion.wav");\n' + '$audio->playSound("assets/sfx/coin.mp3");\n' + '\n' + '// Musik abspielen (automatisches Looping)\n' + '$audio->playMusic("assets/music/theme.mp3");\n' + '$audio->stopMusic();\n' + '\n' + '// Kanal-Lautstärke\n' + '$audio->setChannelVolume(AudioChannel::Music, 0.7);\n' + '$audio->setChannelVolume(AudioChannel::SFX, 1.0);\n' + '\n' + '// Im Game Loop aufrufen (für Music-Looping)\n' + '$audio->update();' +) + +# ============================================================ +# 11. COLLISION SYSTEM +# ============================================================ +doc.add_heading('11. 2D Collision System', level=1) + +doc.add_paragraph( + 'Das 2D-Kollisionssystem verwendet ein Spatial Grid für die Broad Phase ' + 'und AABB/Circle-Tests für die Narrow Phase. Es unterstützt Trigger-Events ' + '(ENTER/STAY/EXIT) und Layer-basierte Filterung.' +) + +doc.add_heading('Components', level=2) +add_table( + ['Component', 'Beschreibung'], + [ + ['BoxCollider2D', 'Rechteckiger Collider (AABB), konfigurierbare Größe und Offset'], + ['CircleCollider2D', 'Kreisförmiger Collider mit Radius und Offset'], + ] +) + +doc.add_heading('Features', level=2) +add_bullet('Spatial Grid Broad Phase — Effiziente Vorauswahl potenzieller Kollisionspaare') +add_bullet('AABB/Circle Narrow Phase — Präzise Kollisionserkennung') +add_bullet('Trigger ENTER/STAY/EXIT — Zustandsbasierte Events') +add_bullet('CollisionSignal / TriggerSignal — Integration ins Signal-System') +add_bullet('Layer/Mask Bitmask — Feingranulare Kollisionsfilterung') +add_bullet('Raycast2D — Point Query und Ray Cast mit maxDistance und Layer-Filter') + +# ============================================================ +# 12. CAMERA 2D +# ============================================================ +doc.add_heading('12. Camera 2D System', level=1) + +doc.add_paragraph( + 'Das Camera2DSystem bietet eine vollständige 2D-Kamera mit Follow-Mechanik, ' + 'Begrenzungen, Zoom und Screen-Shake.' +) + +doc.add_heading('Features', level=2) +add_table( + ['Feature', 'Beschreibung'], + [ + ['Follow Target', 'Kamera folgt einem Entity mit konfigurierbarem Offset'], + ['Smooth Damping', 'Weiche Kamerabewegung mit Dämpfungsfaktor'], + ['Bounds', 'Kamera bleibt innerhalb definierter Grenzen'], + ['Zoom', 'Stufenloses Zoomen'], + ['Shake', 'Bildschirmwackeln mit konfigurierbarer Intensität und Dauer'], + ] +) + +# ============================================================ +# 13. SAVE/LOAD +# ============================================================ +doc.add_heading('13. Save/Load System', level=1) + +doc.add_paragraph( + 'Der SaveManager verwaltet Spielstände als JSON-Dateien in benannten Slots. ' + 'Er unterstützt Autosave, Schema-Versionierung und Daten-Migration.' +) + +doc.add_heading('Kernklassen', level=2) +add_table( + ['Klasse', 'Beschreibung'], + [ + ['SaveManager', 'Hauptklasse: Save/Load/Delete, Autosave, Migrationen'], + ['SaveSlot', 'Einzelner Spielstand: gameState + sceneData + Metadaten'], + ['SaveSlotInfo', 'Kompakte Metadaten (Timestamp, PlayTime, Description)'], + ] +) + +doc.add_heading('Features', level=2) +add_bullet('Benannte Slots — Mehrere Spielstände parallel') +add_bullet('Autosave — Konfigurierbares Intervall und Slot-Name') +add_bullet('Schema-Versionierung — Versions-Nummer pro Spielstand') +add_bullet('Migration-System — registerMigration() für Daten-Upgrades') +add_bullet('Slot-Listing — Nach Timestamp sortiert') +add_bullet('Sicherheit — Slot-Name Sanitization gegen Directory Traversal') +add_bullet('Events — SaveSignal (save.completed, save.loaded, save.deleted)') + +# ============================================================ +# 14. SIGNAL SYSTEM +# ============================================================ +doc.add_heading('14. Signal & Event System', level=1) + +doc.add_paragraph( + 'VISU nutzt ein typsicheres Signal-System für lose Kopplung zwischen ' + 'Engine-Modulen und Spiellogik. Der Dispatcher ermöglicht das Registrieren ' + 'und Auslösen von Events.' +) + +doc.add_heading('Vordefinierte Signale', level=2) +add_table( + ['Kategorie', 'Signale', 'Beschreibung'], + [ + ['Bootstrap', 'BootstrapSignal', 'Engine-Start und -Initialisierung'], + ['Input', 'InputSignal', 'Tastatur, Maus, Gamepad Events'], + ['ECS', 'EntitySpawnedSignal, EntityDestroyedSignal', 'Entity-Lifecycle'], + ['Scene', 'SceneLoadedSignal, SceneUnloadedSignal', 'Szenen-Wechsel'], + ['Collision', 'CollisionSignal, TriggerSignal', 'Kollisionen und Trigger'], + ['UI', 'UIEventSignal', 'Button/Checkbox/Select Interaktionen'], + ['Save', 'SaveSignal', 'Spielstand gespeichert/geladen/gelöscht'], + ] +) + +doc.add_heading('Beispiel', level=2) +add_code_block( + '// Signal registrieren\n' + '$dispatcher->register(CollisionSignal::class, function(CollisionSignal $sig) {\n' + ' echo "Kollision: {$sig->entityA} ↔ {$sig->entityB}";\n' + '});\n' + '\n' + '// Signal auslösen\n' + '$dispatcher->dispatch(new CollisionSignal($entityA, $entityB));' +) + +# ============================================================ +# 15. TRANSPILER +# ============================================================ +doc.add_heading('15. Transpiler (Build-Optimierung)', level=1) + +doc.add_paragraph( + 'Der Transpiler wandelt JSON-Definitionen in optimierte PHP-Factory-Klassen um. ' + 'JSON bleibt Source of Truth (für den Editor), die generierten PHP-Klassen ' + 'dienen als Laufzeit-Cache für maximale Performance.' +) + +add_code_block( + 'JSON (Editor, editierbar) ──transpile──→ PHP-Factory (Laufzeit, schnell)') + +doc.add_heading('Transpiler-Typen', level=2) +add_table( + ['Transpiler', 'Input', 'Output', 'Beschreibung'], + [ + ['SceneTranspiler', 'scenes/*.json', 'Generated\\Scenes\\*.php', 'Direkte Entity-Erzeugung ohne Reflection'], + ['UITranspiler', 'ui/*.json', 'Generated\\UI\\*.php', 'Direkte FlyUI-Aufrufe statt JSON-Parsing'], + ['PrefabTranspiler', 'prefabs/*.json', 'Generated\\Prefabs\\*.php', 'Delegiert an SceneTranspiler'], + ['TranspilerRegistry', '—', '—', 'MD5-Hashing für inkrementelle Builds'], + ] +) + +doc.add_heading('Performance-Vorteile', level=2) +add_bullet('Kein json_decode() zur Laufzeit') +add_bullet('Keine ComponentRegistry String→Klasse Auflösung') +add_bullet('Keine Reflection-basierte Property-Zuweisung') +add_bullet('Keine dynamische Typ-Konvertierung') +add_bullet('PHP Opcache-optimierbar und statisch analysierbar') + +# ============================================================ +# 16. INPUT +# ============================================================ +doc.add_heading('16. Input System', level=1) + +doc.add_paragraph( + 'Das Input-System abstrahiert Tastatur, Maus und Gamepad-Eingaben. ' + 'InputActionMaps ermöglichen die Zuordnung von Aktionen zu beliebigen ' + 'Eingabequellen.' +) + +add_table( + ['Modul', 'Beschreibung'], + [ + ['InputActionMap', 'Abstrakte Aktionen (z.B. "jump") → physische Tasten'], + ['InputContextMap', 'Kontext-abhängige Input-Profile (Menu, Gameplay, etc.)'], + ['GamepadManager', 'SDL3-basierte Gamepad-Unterstützung via FFI'], + ['Key / MouseButton', 'Enums für Tastatur- und Maustasten'], + ] +) + +# ============================================================ +# 17. DISTRIBUTION +# ============================================================ +doc.add_heading('17. Distribution', level=1) + +doc.add_paragraph( + 'VISU-Spiele werden als selbstständige Pakete verteilt. ' + 'Ein Launcher-Binary startet ein statisch kompiliertes PHP-Binary ' + 'mit einem game.phar Archiv.' +) + +add_code_block( + 'game_name/\n' + ' game_name ← Launcher-Binary\n' + ' runtime/php ← Statisches PHP 8.3 Binary (~15–25 MB)\n' + ' game.phar ← Engine + Game Logic (Opcache-geschützt)\n' + ' assets/ ← Sprites, Sounds, UI-JSONs, Szenen\n' + ' saves/ ← Spielstände (SaveManager)\n' + ' mods/ ← Offen für Modder' +) + +# ============================================================ +# 18. ENTWICKLUNG +# ============================================================ +doc.add_heading('18. Entwicklungsumgebung', level=1) + +doc.add_heading('Setup', level=2) +add_code_block( + 'git clone \n' + 'cd visu\n' + 'composer install' +) + +doc.add_heading('Tests ausführen', level=2) +add_code_block( + '# Alle Tests\n' + './vendor/bin/phpunit\n' + '\n' + '# Einzelner Test\n' + './vendor/bin/phpunit --filter Mp3DecoderTest\n' + '\n' + '# Statische Analyse\n' + './vendor/bin/phpstan analyse' +) + +doc.add_heading('minimp3 neu kompilieren (optional)', level=2) +add_code_block( + '# Mit zig (alle Plattformen):\n' + 'cd resources/lib/minimp3\n' + './build.sh\n' + '\n' + '# Oder nativ (nur aktuelle Plattform):\n' + 'cc -shared -O2 -fPIC -o libminimp3.dylib minimp3_wrapper.c' +) + +# ============================================================ +# 19. ROADMAP +# ============================================================ +doc.add_heading('19. Roadmap', level=1) + +add_table( + ['Phase', 'Status', 'Beschreibung'], + [ + ['Phase 1 — Engine-Kern & Scenes', 'Abgeschlossen', 'ECS, SceneLoader, Sprites, Tilemap, Camera, Signals'], + ['Phase 2 — Interaktion & UI', 'Abgeschlossen', '2D Collision, UI System, Audio (WAV+MP3), Camera Shake, Save/Load'], + ['Phase 3 — Transpiler', 'In Arbeit', 'JSON→PHP Transpiler für Scenes, UI, Prefabs; CLI-Tooling ausstehend'], + ['Phase 4 — 3D Grundlagen', 'Offen', 'Mesh-Loading, 3D Camera, Lighting, Materials, 3D Collision, Physics'], + ['Phase 5 — Erweiterte 3D', 'Offen', 'Partikel, Skeletal Animation, Terrain, Post-Processing, AI, Pathfinding'], + ['Editor — Vue SPA', 'Offen', 'Editor-Server, Scene Hierarchy, Property Inspector, UI Layout Editor'], + ] +) + +# ============================================================ +# SAVE +# ============================================================ +output_path = os.path.join(os.path.dirname(__file__), 'VISU_Engine_Documentation.docx') +doc.save(output_path) +print(f'Saved: {output_path}') diff --git a/editor/index.html b/editor/index.html new file mode 100644 index 0000000..b65851a --- /dev/null +++ b/editor/index.html @@ -0,0 +1,17 @@ + + + + + + VISU World Editor + + + +
+ + + diff --git a/editor/package-lock.json b/editor/package-lock.json new file mode 100644 index 0000000..57eb086 --- /dev/null +++ b/editor/package-lock.json @@ -0,0 +1,3003 @@ +{ + "name": "visu-world-editor", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "visu-world-editor", + "version": "1.0.0", + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.8.3", + "vite": "^5.0.0", + "vitest": "^4.0.18" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/happy-dom": { + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz", + "integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/editor/package.json b/editor/package.json new file mode 100644 index 0000000..a666fa8 --- /dev/null +++ b/editor/package.json @@ -0,0 +1,23 @@ +{ + "name": "visu-world-editor", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "pinia": "^2.1.7", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.8.3", + "vite": "^5.0.0", + "vitest": "^4.0.18" + } +} diff --git a/editor/src/App.vue b/editor/src/App.vue new file mode 100644 index 0000000..3d7e4c9 --- /dev/null +++ b/editor/src/App.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/editor/src/__tests__/api.test.js b/editor/src/__tests__/api.test.js new file mode 100644 index 0000000..a83c05b --- /dev/null +++ b/editor/src/__tests__/api.test.js @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +import { + listWorlds, getWorld, saveWorld, deleteWorld, getConfig, + patchEntity, removeEntity, browseAssets, + listScenes, getScene, saveScene, + listUILayouts, getUILayout, saveUILayout, + transpileAll, +} from '../api.js' + +function mockJsonResponse(data, ok = true) { + return { ok, json: () => Promise.resolve(data) } +} + +beforeEach(() => { + mockFetch.mockReset() +}) + +describe('api.js', () => { + // ── Worlds ──────────────────────────────────────────────────────────── + + describe('listWorlds', () => { + it('fetches GET /api/worlds', async () => { + const worlds = [{ name: 'test', modified: '2026-01-01', size: 100 }] + mockFetch.mockResolvedValue(mockJsonResponse(worlds)) + + const result = await listWorlds() + expect(mockFetch).toHaveBeenCalledWith('/api/worlds') + expect(result).toEqual(worlds) + }) + }) + + describe('getWorld', () => { + it('fetches GET /api/worlds/{name}', async () => { + const world = { version: '1.0', meta: { name: 'Test' } } + mockFetch.mockResolvedValue(mockJsonResponse(world)) + + const result = await getWorld('test') + expect(mockFetch).toHaveBeenCalledWith('/api/worlds/test') + expect(result).toEqual(world) + }) + + it('throws on 404', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({}, false)) + await expect(getWorld('missing')).rejects.toThrow('not found') + }) + }) + + describe('saveWorld', () => { + it('sends POST with JSON body', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true })) + const data = { version: '1.0', layers: [] } + + await saveWorld('myworld', data) + expect(mockFetch).toHaveBeenCalledWith('/api/worlds/myworld', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + }) + + it('throws on failure', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({}, false)) + await expect(saveWorld('x', {})).rejects.toThrow('Failed to save') + }) + }) + + describe('deleteWorld', () => { + it('sends DELETE', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true })) + await deleteWorld('old') + expect(mockFetch).toHaveBeenCalledWith('/api/worlds/old', { method: 'DELETE' }) + }) + }) + + describe('getConfig', () => { + it('fetches GET /api/config', async () => { + const config = { tileSize: 32, gridWidth: 32 } + mockFetch.mockResolvedValue(mockJsonResponse(config)) + const result = await getConfig() + expect(result).toEqual(config) + }) + }) + + // ── Entity operations ───────────────────────────────────────────────── + + describe('patchEntity', () => { + it('sends PATCH with entity path', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true })) + const patch = { name: 'Updated' } + + await patchEntity('world1', 'entities', 42, patch) + expect(mockFetch).toHaveBeenCalledWith( + '/api/worlds/world1/entities/entities/42', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify(patch) }) + ) + }) + }) + + describe('removeEntity', () => { + it('sends DELETE with entity path', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true })) + await removeEntity('world1', 'entities', 42) + expect(mockFetch).toHaveBeenCalledWith( + '/api/worlds/world1/entities/entities/42', + { method: 'DELETE' } + ) + }) + }) + + // ── Assets ──────────────────────────────────────────────────────────── + + describe('browseAssets', () => { + it('fetches root without query param', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ path: '', entries: [] })) + await browseAssets() + expect(mockFetch).toHaveBeenCalledWith('/api/assets/browse') + }) + + it('passes dir as query param', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ path: 'shaders', entries: [] })) + await browseAssets('shaders') + expect(mockFetch).toHaveBeenCalledWith('/api/assets/browse?dir=shaders') + }) + }) + + // ── Scenes ──────────────────────────────────────────────────────────── + + describe('listScenes', () => { + it('fetches GET /api/scenes', async () => { + mockFetch.mockResolvedValue(mockJsonResponse([])) + await listScenes() + expect(mockFetch).toHaveBeenCalledWith('/api/scenes') + }) + }) + + describe('getScene', () => { + it('fetches scene by name', async () => { + const scene = { entities: [] } + mockFetch.mockResolvedValue(mockJsonResponse(scene)) + const result = await getScene('level1') + expect(result).toEqual(scene) + }) + }) + + describe('saveScene', () => { + it('sends POST with scene data', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true })) + await saveScene('level1', { entities: [] }) + expect(mockFetch).toHaveBeenCalledWith('/api/scenes/level1', expect.objectContaining({ method: 'POST' })) + }) + }) + + // ── UI Layouts ──────────────────────────────────────────────────────── + + describe('listUILayouts', () => { + it('fetches GET /api/ui', async () => { + mockFetch.mockResolvedValue(mockJsonResponse([])) + await listUILayouts() + expect(mockFetch).toHaveBeenCalledWith('/api/ui') + }) + }) + + describe('getUILayout', () => { + it('fetches layout by name', async () => { + const layout = { type: 'panel', children: [] } + mockFetch.mockResolvedValue(mockJsonResponse(layout)) + const result = await getUILayout('hud') + expect(result).toEqual(layout) + }) + + it('throws on 404', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({}, false)) + await expect(getUILayout('missing')).rejects.toThrow('not found') + }) + }) + + describe('saveUILayout', () => { + it('sends POST with layout data', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true })) + await saveUILayout('hud', { type: 'panel' }) + expect(mockFetch).toHaveBeenCalledWith('/api/ui/hud', expect.objectContaining({ method: 'POST' })) + }) + }) + + // ── Transpile ───────────────────────────────────────────────────────── + + describe('transpileAll', () => { + it('sends POST /api/transpile', async () => { + mockFetch.mockResolvedValue(mockJsonResponse({ ok: true, results: {} })) + const result = await transpileAll() + expect(mockFetch).toHaveBeenCalledWith('/api/transpile', { method: 'POST' }) + expect(result.ok).toBe(true) + }) + }) +}) diff --git a/editor/src/__tests__/store.test.js b/editor/src/__tests__/store.test.js new file mode 100644 index 0000000..b270b92 --- /dev/null +++ b/editor/src/__tests__/store.test.js @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useWorldStore } from '../stores/world.js' + +// Mock api.js +vi.mock('../api.js', () => ({ + getConfig: vi.fn().mockResolvedValue({ tileSize: 32, gridWidth: 32, gridHeight: 32 }), + listWorlds: vi.fn().mockResolvedValue([]), + getWorld: vi.fn().mockResolvedValue({ + version: '1.0', + meta: { name: 'Test', type: '2d_topdown', tileSize: 32 }, + camera: { position: { x: 0, y: 0 }, zoom: 1.0 }, + layers: [ + { id: 'bg', name: 'Background', type: 'tile', visible: true, locked: false, tiles: {} }, + { id: 'entities', name: 'Entities', type: 'entity', visible: true, locked: false, entities: [] }, + ], + lights: [], + tilesets: [], + }), + saveWorld: vi.fn().mockResolvedValue({ ok: true }), + browseAssets: vi.fn().mockResolvedValue({ path: '', entries: [] }), + listScenes: vi.fn().mockResolvedValue([]), +})) + +beforeEach(() => { + setActivePinia(createPinia()) +}) + +describe('useWorldStore', () => { + // ── Initial state ──────────────────────────────────────────────────── + + describe('initial state', () => { + it('starts with no world loaded', () => { + const store = useWorldStore() + expect(store.world).toBeNull() + expect(store.worldName).toBeNull() + expect(store.isDirty).toBe(false) + }) + + it('has default config', () => { + const store = useWorldStore() + expect(store.config.tileSize).toBe(32) + }) + + it('has select as default tool', () => { + const store = useWorldStore() + expect(store.selectedTool).toBe('select') + }) + + it('has no selected entity', () => { + const store = useWorldStore() + expect(store.selectedEntityId).toBeNull() + expect(store.selectedEntity).toBeNull() + }) + }) + + // ── newWorld ────────────────────────────────────────────────────────── + + describe('newWorld', () => { + it('creates a default world with two layers', () => { + const store = useWorldStore() + store.newWorld('Test Level') + + expect(store.world).not.toBeNull() + expect(store.world.meta.name).toBe('Test Level') + expect(store.world.layers).toHaveLength(2) + expect(store.world.layers[0].type).toBe('tile') + expect(store.world.layers[1].type).toBe('entity') + }) + + it('sets worldName as slug', () => { + const store = useWorldStore() + store.newWorld('My World') + expect(store.worldName).toBe('my_world') + }) + + it('marks as dirty', () => { + const store = useWorldStore() + store.newWorld('Test') + expect(store.isDirty).toBe(true) + }) + + it('initializes history with one snapshot', () => { + const store = useWorldStore() + store.newWorld('Test') + // History has initial state + expect(store.world).not.toBeNull() + }) + }) + + // ── loadWorld ───────────────────────────────────────────────────────── + + describe('loadWorld', () => { + it('loads world from API', async () => { + const store = useWorldStore() + await store.loadWorld('test') + expect(store.world).not.toBeNull() + expect(store.world.meta.name).toBe('Test') + expect(store.worldName).toBe('test') + expect(store.isDirty).toBe(false) + }) + + it('sets active layer to first layer', async () => { + const store = useWorldStore() + await store.loadWorld('test') + expect(store.activeLayerId).toBe('bg') + }) + }) + + // ── Layer operations ────────────────────────────────────────────────── + + describe('layer operations', () => { + it('addLayer adds a tile layer', () => { + const store = useWorldStore() + store.newWorld('Test') + store.addLayer('tile') + expect(store.world.layers).toHaveLength(3) + expect(store.world.layers[2].type).toBe('tile') + }) + + it('addLayer adds an entity layer', () => { + const store = useWorldStore() + store.newWorld('Test') + store.addLayer('entity') + expect(store.world.layers).toHaveLength(3) + expect(store.world.layers[2].type).toBe('entity') + }) + + it('removeLayer removes a layer', () => { + const store = useWorldStore() + store.newWorld('Test') + const id = store.world.layers[0].id + store.removeLayer(id) + expect(store.world.layers).toHaveLength(1) + }) + + it('renameLayer changes layer name', () => { + const store = useWorldStore() + store.newWorld('Test') + store.renameLayer('bg', 'My Background') + expect(store.world.layers[0].name).toBe('My Background') + }) + + it('toggleLayerVisibility toggles visible flag', () => { + const store = useWorldStore() + store.newWorld('Test') + expect(store.world.layers[0].visible).toBe(true) + store.toggleLayerVisibility('bg') + expect(store.world.layers[0].visible).toBe(false) + }) + + it('toggleLayerLock toggles locked flag', () => { + const store = useWorldStore() + store.newWorld('Test') + expect(store.world.layers[0].locked).toBe(false) + store.toggleLayerLock('bg') + expect(store.world.layers[0].locked).toBe(true) + }) + + it('setActiveLayer changes active layer', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + expect(store.activeLayerId).toBe('entities') + }) + }) + + // ── Entity operations ───────────────────────────────────────────────── + + describe('entity operations', () => { + it('placeEntity adds an entity to active layer', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'player_spawn' + store.placeEntity(100, 200) + + const layer = store.world.layers.find(l => l.id === 'entities') + expect(layer.entities).toHaveLength(1) + expect(layer.entities[0].position).toEqual({ x: 100, y: 200 }) + expect(layer.entities[0].type).toBe('player_spawn') + }) + + it('placeEntity does nothing on locked layer', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.world.layers[1].locked = true + store.selectedEntityType = 'player_spawn' + store.placeEntity(100, 200) + + expect(store.world.layers[1].entities).toHaveLength(0) + }) + + it('selectEntityAt selects entity within radius', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'npc' + store.placeEntity(100, 100) + + store.selectEntityAt(105, 105) // within default 16px radius + expect(store.selectedEntityId).not.toBeNull() + }) + + it('selectEntityAt deselects when no hit', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'npc' + store.placeEntity(100, 100) + + store.selectEntityAt(500, 500) // far away + expect(store.selectedEntityId).toBeNull() + }) + + it('deleteSelectedEntity removes the entity', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'enemy_spawn' + store.placeEntity(50, 50) + + const entityId = store.selectedEntityId + expect(entityId).not.toBeNull() + + store.deleteSelectedEntity() + expect(store.selectedEntityId).toBeNull() + expect(store.world.layers[1].entities).toHaveLength(0) + }) + + it('duplicateEntity creates a copy offset by 32px', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'item' + store.placeEntity(100, 100) + + store.duplicateEntity() + expect(store.world.layers[1].entities).toHaveLength(2) + const copy = store.world.layers[1].entities[1] + expect(copy.position.x).toBe(132) + expect(copy.position.y).toBe(132) + expect(copy.name).toContain('(copy)') + }) + + it('moveEntityTo updates position', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'prop' + store.placeEntity(10, 20) + + const eid = store.world.layers[1].entities[0].id + store.moveEntityTo(eid, 300, 400) + expect(store.world.layers[1].entities[0].position).toEqual({ x: 300, y: 400 }) + }) + + it('updateSelectedEntity patches properties', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'npc' + store.placeEntity(0, 0) + + store.updateSelectedEntity({ name: 'Guard', rotation: 45 }) + expect(store.selectedEntity.name).toBe('Guard') + expect(store.selectedEntity.rotation).toBe(45) + }) + }) + + // ── Tile operations ─────────────────────────────────────────────────── + + describe('tile operations', () => { + it('placeTile sets tile at grid position', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('bg') + store.selectedTile = { tilesetId: 'ts1', tx: 0, ty: 0 } + store.placeTile(3, 5) + + expect(store.world.layers[0].tiles['3,5']).toBeDefined() + expect(store.world.layers[0].tiles['3,5'].tilesetId).toBe('ts1') + }) + + it('eraseTile removes tile at grid position', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('bg') + store.selectedTile = { tilesetId: 'ts1', tx: 0, ty: 0 } + store.placeTile(3, 5) + store.eraseTile(3, 5) + + expect(store.world.layers[0].tiles['3,5']).toBeUndefined() + }) + }) + + // ── Undo/Redo ───────────────────────────────────────────────────────── + + describe('undo/redo', () => { + it('undo reverts to previous state', () => { + const store = useWorldStore() + store.newWorld('Test') + + store.setActiveLayer('entities') + store.selectedEntityType = 'npc' + store.placeEntity(100, 100) // This calls snapshot() + + expect(store.world.layers[1].entities).toHaveLength(1) + + store.undo() + expect(store.world.layers[1].entities).toHaveLength(0) + }) + + it('redo re-applies undone state', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'npc' + store.placeEntity(100, 100) + + store.undo() + expect(store.world.layers[1].entities).toHaveLength(0) + + store.redo() + expect(store.world.layers[1].entities).toHaveLength(1) + }) + + it('undo at beginning does nothing', () => { + const store = useWorldStore() + store.newWorld('Test') + const before = JSON.stringify(store.world) + store.undo() + expect(JSON.stringify(store.world)).toBe(before) + }) + }) + + // ── Tool switching ──────────────────────────────────────────────────── + + describe('tool switching', () => { + it('setActiveTool changes tool and deselects', () => { + const store = useWorldStore() + store.newWorld('Test') + store.setActiveLayer('entities') + store.selectedEntityType = 'npc' + store.placeEntity(0, 0) + + store.setActiveTool('erase') + expect(store.selectedTool).toBe('erase') + expect(store.selectedEntityId).toBeNull() + }) + }) + + // ── Asset browser ───────────────────────────────────────────────────── + + describe('asset browser', () => { + it('browseAssets updates state', async () => { + const store = useWorldStore() + await store.browseAssets('shaders') + expect(store.assetPath).toBe('') + expect(store.assetEntries).toEqual([]) + expect(store.assetLoading).toBe(false) + }) + }) +}) diff --git a/editor/src/__tests__/ws.test.js b/editor/src/__tests__/ws.test.js new file mode 100644 index 0000000..27c211e --- /dev/null +++ b/editor/src/__tests__/ws.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { on, send, getState, disconnect, connect } from '../ws.js' + +// Mock WebSocket +class MockWebSocket { + static OPEN = 1 + static CLOSED = 3 + readyState = MockWebSocket.OPEN + onopen = null + onclose = null + onerror = null + onmessage = null + + constructor(url) { + this.url = url + this.sent = [] + // Auto-fire onopen in next tick + setTimeout(() => this.onopen?.(), 0) + } + + send(data) { + this.sent.push(data) + } + + close() { + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + } +} + +beforeEach(() => { + disconnect() + global.WebSocket = MockWebSocket +}) + +afterEach(() => { + disconnect() +}) + +describe('ws.js', () => { + describe('getState', () => { + it('starts disconnected', () => { + expect(getState()).toBe('disconnected') + }) + }) + + describe('on', () => { + it('registers a handler and returns unsubscribe function', () => { + const handler = vi.fn() + const unsub = on('test', handler) + expect(typeof unsub).toBe('function') + }) + + it('unsubscribe removes the handler', () => { + const handler = vi.fn() + const unsub = on('test', handler) + unsub() + // Handler removed - no way to test without triggering, but no error + }) + }) + + describe('send', () => { + it('does nothing when not connected', () => { + // Not connected, should not throw + send('test', { foo: 'bar' }) + }) + }) + + describe('disconnect', () => { + it('can be called multiple times safely', () => { + disconnect() + disconnect() + expect(getState()).toBe('disconnected') + }) + }) + + describe('message handling', () => { + it('dispatches typed messages to registered handlers', async () => { + const handler = vi.fn() + on('scene.changed', handler) + + connect(19999) + // Wait for onopen + await new Promise(r => setTimeout(r, 10)) + + expect(getState()).toBe('connected') + }) + }) + + describe('connection events', () => { + it('fires _connected on open', async () => { + const onConnected = vi.fn() + on('_connected', onConnected) + + connect(19998) + await new Promise(r => setTimeout(r, 10)) + + expect(onConnected).toHaveBeenCalled() + }) + + it('fires _disconnected on close', async () => { + const onDisconnected = vi.fn() + on('_disconnected', onDisconnected) + + connect(19997) + await new Promise(r => setTimeout(r, 10)) + + disconnect() + expect(onDisconnected).toHaveBeenCalled() + }) + }) +}) diff --git a/editor/src/api.js b/editor/src/api.js new file mode 100644 index 0000000..ca11aa5 --- /dev/null +++ b/editor/src/api.js @@ -0,0 +1,110 @@ +const BASE = '/api' + +export async function listWorlds() { + const r = await fetch(`${BASE}/worlds`) + return r.json() +} + +export async function getWorld(name) { + const r = await fetch(`${BASE}/worlds/${name}`) + if (!r.ok) throw new Error(`World "${name}" not found`) + return r.json() +} + +export async function saveWorld(name, data) { + const r = await fetch(`${BASE}/worlds/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!r.ok) throw new Error('Failed to save world') + return r.json() +} + +export async function deleteWorld(name) { + const r = await fetch(`${BASE}/worlds/${name}`, { method: 'DELETE' }) + if (!r.ok) throw new Error('Failed to delete world') + return r.json() +} + +export async function getConfig() { + const r = await fetch(`${BASE}/config`) + return r.json() +} + +// Entity PATCH +export async function patchEntity(worldName, layerId, entityId, patch) { + const r = await fetch(`${BASE}/worlds/${worldName}/entities/${layerId}/${entityId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + if (!r.ok) throw new Error('Failed to patch entity') + return r.json() +} + +// Entity DELETE +export async function removeEntity(worldName, layerId, entityId) { + const r = await fetch(`${BASE}/worlds/${worldName}/entities/${layerId}/${entityId}`, { + method: 'DELETE', + }) + if (!r.ok) throw new Error('Failed to delete entity') + return r.json() +} + +// Asset browser +export async function browseAssets(dir = '') { + const params = dir ? `?dir=${encodeURIComponent(dir)}` : '' + const r = await fetch(`${BASE}/assets/browse${params}`) + return r.json() +} + +// Scene API +export async function listScenes() { + const r = await fetch(`${BASE}/scenes`) + return r.json() +} + +export async function getScene(name) { + const r = await fetch(`${BASE}/scenes/${name}`) + if (!r.ok) throw new Error(`Scene "${name}" not found`) + return r.json() +} + +export async function saveScene(name, data) { + const r = await fetch(`${BASE}/scenes/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!r.ok) throw new Error('Failed to save scene') + return r.json() +} + +// Transpile +export async function transpileAll() { + const r = await fetch(`${BASE}/transpile`, { method: 'POST' }) + return r.json() +} + +// UI Layout API +export async function listUILayouts() { + const r = await fetch(`${BASE}/ui`) + return r.json() +} + +export async function getUILayout(name) { + const r = await fetch(`${BASE}/ui/${name}`) + if (!r.ok) throw new Error(`UI layout "${name}" not found`) + return r.json() +} + +export async function saveUILayout(name, data) { + const r = await fetch(`${BASE}/ui/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!r.ok) throw new Error('Failed to save UI layout') + return r.json() +} diff --git a/editor/src/components/AssetBrowser.vue b/editor/src/components/AssetBrowser.vue new file mode 100644 index 0000000..aaa3543 --- /dev/null +++ b/editor/src/components/AssetBrowser.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/editor/src/components/EditorCanvas.vue b/editor/src/components/EditorCanvas.vue new file mode 100644 index 0000000..ab6afe0 --- /dev/null +++ b/editor/src/components/EditorCanvas.vue @@ -0,0 +1,414 @@ + + + + + diff --git a/editor/src/components/EntityPalette.vue b/editor/src/components/EntityPalette.vue new file mode 100644 index 0000000..fc75860 --- /dev/null +++ b/editor/src/components/EntityPalette.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/editor/src/components/InspectorPanel.vue b/editor/src/components/InspectorPanel.vue new file mode 100644 index 0000000..3864370 --- /dev/null +++ b/editor/src/components/InspectorPanel.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/editor/src/components/LayerPanel.vue b/editor/src/components/LayerPanel.vue new file mode 100644 index 0000000..7a728f5 --- /dev/null +++ b/editor/src/components/LayerPanel.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/editor/src/components/MenuBar.vue b/editor/src/components/MenuBar.vue new file mode 100644 index 0000000..2aab03e --- /dev/null +++ b/editor/src/components/MenuBar.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/editor/src/components/TilesetPanel.vue b/editor/src/components/TilesetPanel.vue new file mode 100644 index 0000000..d669caa --- /dev/null +++ b/editor/src/components/TilesetPanel.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/editor/src/components/UILayoutEditor.vue b/editor/src/components/UILayoutEditor.vue new file mode 100644 index 0000000..14dd7e5 --- /dev/null +++ b/editor/src/components/UILayoutEditor.vue @@ -0,0 +1,1329 @@ + + + + + diff --git a/editor/src/main.js b/editor/src/main.js new file mode 100644 index 0000000..27b8b78 --- /dev/null +++ b/editor/src/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +const app = createApp(App) +app.use(createPinia()) +app.mount('#app') diff --git a/editor/src/stores/world.js b/editor/src/stores/world.js new file mode 100644 index 0000000..2917006 --- /dev/null +++ b/editor/src/stores/world.js @@ -0,0 +1,346 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import * as api from '../api.js' + +function makeDefaultWorld(name = 'Untitled') { + const now = new Date().toISOString() + return { + version: '1.0', + meta: { name, type: '2d_topdown', tileSize: 32, created: now, modified: now }, + camera: { position: { x: 0, y: 0 }, zoom: 1.0 }, + layers: [ + { id: 'bg', name: 'Background', type: 'tile', visible: true, locked: false, tiles: {} }, + { id: 'entities', name: 'Entities', type: 'entity', visible: true, locked: false, entities: [] }, + ], + lights: [], + tilesets: [], + } +} + +export const useWorldStore = defineStore('world', () => { + // ─── State ──────────────────────────────────────────────────────────────── + const world = ref(null) // current WorldFile data + const worldName = ref(null) // filename slug (no extension) + const isDirty = ref(false) + const activeLayerId = ref(null) + const selectedTool = ref('select') // 'select' | 'place_tile' | 'place_entity' | 'erase' + const selectedEntityType = ref(null) + const selectedTile = ref(null) // { tilesetId, tx, ty } + const selectedEntityId = ref(null) // id within entity layer + const worldList = ref([]) + const config = ref({ tileSize: 32, gridWidth: 32, gridHeight: 32 }) + + // undo/redo + const history = ref([]) + const historyIndex = ref(-1) + + // asset browser + const assetPath = ref('') + const assetEntries = ref([]) + const assetLoading = ref(false) + + // scene list + const sceneList = ref([]) + + // ─── Derived ────────────────────────────────────────────────────────────── + const activeLayer = computed(() => + world.value?.layers.find(l => l.id === activeLayerId.value) ?? null + ) + + const selectedEntity = computed(() => { + if (!world.value) return null + for (const layer of world.value.layers) { + if (layer.type !== 'entity') continue + const e = layer.entities?.find(e => e.id === selectedEntityId.value) + if (e) return e + } + return null + }) + + // ─── Actions ────────────────────────────────────────────────────────────── + async function fetchConfig() { + config.value = await api.getConfig() + } + + async function fetchWorldList() { + worldList.value = await api.listWorlds() + } + + async function loadWorld(name) { + const data = await api.getWorld(name) + world.value = data + worldName.value = name + activeLayerId.value = data.layers?.[0]?.id ?? null + isDirty.value = false + history.value = [JSON.stringify(data)] + historyIndex.value = 0 + } + + function newWorld(name = 'Untitled') { + const data = makeDefaultWorld(name) + world.value = data + worldName.value = name.toLowerCase().replace(/\s+/g, '_') + activeLayerId.value = data.layers[0].id + isDirty.value = true + history.value = [JSON.stringify(data)] + historyIndex.value = 0 + } + + async function saveCurrentWorld() { + if (!world.value || !worldName.value) return + world.value.meta.modified = new Date().toISOString() + await api.saveWorld(worldName.value, world.value) + isDirty.value = false + await fetchWorldList() + } + + function snapshot() { + if (!world.value) return + const snap = JSON.stringify(world.value) + // truncate future history + history.value = history.value.slice(0, historyIndex.value + 1) + history.value.push(snap) + historyIndex.value = history.value.length - 1 + isDirty.value = true + } + + function undo() { + if (historyIndex.value <= 0) return + historyIndex.value-- + world.value = JSON.parse(history.value[historyIndex.value]) + isDirty.value = true + } + + function redo() { + if (historyIndex.value >= history.value.length - 1) return + historyIndex.value++ + world.value = JSON.parse(history.value[historyIndex.value]) + isDirty.value = true + } + + function setActiveTool(tool) { + selectedTool.value = tool + selectedEntityId.value = null + } + + function setActiveLayer(id) { + activeLayerId.value = id + selectedEntityId.value = null + } + + function toggleLayerVisibility(id) { + const layer = world.value?.layers.find(l => l.id === id) + if (layer) { layer.visible = !layer.visible; isDirty.value = true } + } + + function toggleLayerLock(id) { + const layer = world.value?.layers.find(l => l.id === id) + if (layer) { layer.locked = !layer.locked; isDirty.value = true } + } + + function addLayer(type = 'tile') { + if (!world.value) return + const id = 'layer_' + Date.now() + const layer = type === 'tile' + ? { id, name: 'New Layer', type: 'tile', visible: true, locked: false, tiles: {} } + : { id, name: 'New Layer', type: 'entity', visible: true, locked: false, entities: [] } + world.value.layers.push(layer) + activeLayerId.value = id + snapshot() + } + + function removeLayer(id) { + if (!world.value) return + world.value.layers = world.value.layers.filter(l => l.id !== id) + if (activeLayerId.value === id) { + activeLayerId.value = world.value.layers[0]?.id ?? null + } + snapshot() + } + + function renameLayer(id, name) { + const layer = world.value?.layers.find(l => l.id === id) + if (layer) { + layer.name = name + isDirty.value = true + } + } + + function moveLayerUp(id) { + if (!world.value) return + const idx = world.value.layers.findIndex(l => l.id === id) + if (idx < world.value.layers.length - 1) { + const temp = world.value.layers[idx] + world.value.layers[idx] = world.value.layers[idx + 1] + world.value.layers[idx + 1] = temp + snapshot() + } + } + + function moveLayerDown(id) { + if (!world.value) return + const idx = world.value.layers.findIndex(l => l.id === id) + if (idx > 0) { + const temp = world.value.layers[idx] + world.value.layers[idx] = world.value.layers[idx - 1] + world.value.layers[idx - 1] = temp + snapshot() + } + } + + function placeTile(gridX, gridY) { + const layer = activeLayer.value + if (!layer || layer.type !== 'tile' || layer.locked) return + if (!selectedTile.value) return + if (!layer.tiles) layer.tiles = {} + layer.tiles[`${gridX},${gridY}`] = { ...selectedTile.value } + isDirty.value = true + } + + function eraseTile(gridX, gridY) { + const layer = activeLayer.value + if (!layer || layer.type !== 'tile' || layer.locked) return + if (layer.tiles) delete layer.tiles[`${gridX},${gridY}`] + isDirty.value = true + } + + function placeEntity(worldX, worldY) { + const layer = activeLayer.value + if (!layer || layer.type !== 'entity' || layer.locked) return + if (!selectedEntityType.value) return + const id = Date.now() + if (!layer.entities) layer.entities = [] + layer.entities.push({ + id, + name: selectedEntityType.value, + type: selectedEntityType.value, + position: { x: worldX, y: worldY }, + rotation: 0.0, + scale: { x: 1.0, y: 1.0 }, + properties: {}, + }) + selectedEntityId.value = id + snapshot() + } + + function eraseEntityAt(worldX, worldY, radius = 16) { + const layer = activeLayer.value + if (!layer || layer.type !== 'entity' || layer.locked) return + if (!layer.entities) return + layer.entities = layer.entities.filter(e => { + const dx = e.position.x - worldX + const dy = e.position.y - worldY + return Math.sqrt(dx * dx + dy * dy) > radius + }) + snapshot() + } + + function updateSelectedEntity(patch) { + if (!selectedEntity.value) return + Object.assign(selectedEntity.value, patch) + isDirty.value = true + } + + function selectEntityAt(worldX, worldY, radius = 16) { + if (!world.value) return + // Search all visible entity layers, not just active + for (const layer of world.value.layers) { + if (layer.type !== 'entity' || !layer.visible) continue + const hit = layer.entities?.find(e => { + const dx = e.position.x - worldX + const dy = e.position.y - worldY + return Math.sqrt(dx * dx + dy * dy) <= radius + }) + if (hit) { + activeLayerId.value = layer.id + selectedEntityId.value = hit.id + return + } + } + selectedEntityId.value = null + } + + function moveEntityTo(entityId, x, y) { + if (!world.value) return + for (const layer of world.value.layers) { + if (layer.type !== 'entity') continue + const entity = layer.entities?.find(e => e.id === entityId) + if (entity) { + entity.position.x = x + entity.position.y = y + isDirty.value = true + return + } + } + } + + function deleteSelectedEntity() { + if (!selectedEntity.value || !world.value) return + const eid = selectedEntityId.value + for (const layer of world.value.layers) { + if (layer.type !== 'entity' || !layer.entities) continue + const idx = layer.entities.findIndex(e => e.id === eid) + if (idx !== -1) { + layer.entities.splice(idx, 1) + selectedEntityId.value = null + snapshot() + return + } + } + } + + function duplicateEntity() { + if (!selectedEntity.value || !world.value) return + const src = selectedEntity.value + // Find which layer it belongs to + for (const layer of world.value.layers) { + if (layer.type !== 'entity' || !layer.entities) continue + if (!layer.entities.find(e => e.id === src.id)) continue + const copy = JSON.parse(JSON.stringify(src)) + copy.id = Date.now() + copy.name = src.name + ' (copy)' + copy.position.x += 32 + copy.position.y += 32 + layer.entities.push(copy) + selectedEntityId.value = copy.id + snapshot() + return + } + } + + // ─── Asset Browser ────────────────────────────────────────────────────── + async function browseAssets(dir = '') { + assetLoading.value = true + try { + const result = await api.browseAssets(dir) + assetPath.value = result.path ?? '' + assetEntries.value = result.entries ?? [] + } finally { + assetLoading.value = false + } + } + + // ─── Scene List ───────────────────────────────────────────────────────── + async function fetchScenes() { + sceneList.value = await api.listScenes() + } + + return { + world, worldName, isDirty, activeLayerId, activeLayer, + selectedTool, selectedEntityType, selectedTile, + selectedEntityId, selectedEntity, + worldList, config, + assetPath, assetEntries, assetLoading, + sceneList, + fetchConfig, fetchWorldList, + loadWorld, newWorld, saveCurrentWorld, + snapshot, undo, redo, + setActiveTool, setActiveLayer, + toggleLayerVisibility, toggleLayerLock, + addLayer, removeLayer, renameLayer, moveLayerUp, moveLayerDown, + placeTile, eraseTile, placeEntity, eraseEntityAt, + updateSelectedEntity, selectEntityAt, + moveEntityTo, deleteSelectedEntity, duplicateEntity, + browseAssets, fetchScenes, + } +}) diff --git a/editor/src/ws.js b/editor/src/ws.js new file mode 100644 index 0000000..e9bc38d --- /dev/null +++ b/editor/src/ws.js @@ -0,0 +1,127 @@ +/** + * WebSocket client for VISU World Editor live-preview communication. + * + * Connects to the WebSocket server started by `bin/visu world-editor`. + * Handles automatic reconnection and message routing. + */ + +let ws = null +let reconnectTimer = null +const handlers = {} +let connectionState = 'disconnected' // 'connecting' | 'connected' | 'disconnected' + +/** + * Connect to the WebSocket server. + * @param {number} [port=8766] WebSocket server port + */ +export function connect(port = 8766) { + if (ws && ws.readyState === WebSocket.OPEN) return + + connectionState = 'connecting' + const url = `ws://${location.hostname}:${port}` + + try { + ws = new WebSocket(url) + } catch (e) { + connectionState = 'disconnected' + scheduleReconnect(port) + return + } + + ws.onopen = () => { + connectionState = 'connected' + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + dispatch('_connected', {}) + } + + ws.onclose = () => { + connectionState = 'disconnected' + ws = null + dispatch('_disconnected', {}) + scheduleReconnect(port) + } + + ws.onerror = () => { + // onclose will fire after this + } + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) + if (msg.type) { + dispatch(msg.type, msg.data || {}) + } + } catch (e) { + // Ignore malformed messages + } + } +} + +/** + * Send a message to the WebSocket server. + * @param {string} type Message type + * @param {object} [data={}] Message data + */ +export function send(type, data = {}) { + if (!ws || ws.readyState !== WebSocket.OPEN) return + ws.send(JSON.stringify({ type, data })) +} + +/** + * Register a handler for a message type. + * @param {string} type Message type + * @param {function} callback Handler function(data) + * @returns {function} Unsubscribe function + */ +export function on(type, callback) { + if (!handlers[type]) handlers[type] = [] + handlers[type].push(callback) + return () => { + handlers[type] = handlers[type].filter(h => h !== callback) + } +} + +/** + * Get current connection state. + * @returns {'connecting'|'connected'|'disconnected'} + */ +export function getState() { + return connectionState +} + +/** + * Disconnect from the WebSocket server. + */ +export function disconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + if (ws) { + ws.close() + ws = null + } + connectionState = 'disconnected' +} + +function dispatch(type, data) { + const typeHandlers = handlers[type] || [] + for (const handler of typeHandlers) { + try { + handler(data) + } catch (e) { + console.error(`[WS] Handler error for "${type}":`, e) + } + } +} + +function scheduleReconnect(port) { + if (reconnectTimer) return + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect(port) + }, 3000) +} diff --git a/editor/vite.config.js b/editor/vite.config.js new file mode 100644 index 0000000..d3f306f --- /dev/null +++ b/editor/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + build: { + outDir: '../resources/editor/dist', + emptyOutDir: true, + }, + server: { + proxy: { + '/api': 'http://127.0.0.1:8765', + }, + }, + test: { + environment: 'happy-dom', + globals: true, + }, +}) diff --git a/examples/office_demo/office_demo.php b/examples/office_demo/office_demo.php new file mode 100644 index 0000000..f770e8a --- /dev/null +++ b/examples/office_demo/office_demo.php @@ -0,0 +1,279 @@ +register('SpriteRenderer', SpriteRenderer::class); +$componentRegistry->register('SpriteAnimator', SpriteAnimator::class); + +$sceneLoader = new SceneLoader($componentRegistry); +$sortingLayer = new SortingLayer(); + +// --- Color mapping for sprite types (since we have no actual textures) --- +$spriteColors = [ + 'sprites/floor_large.png' => VGColor::rgb(0.85, 0.82, 0.75), // warm beige floor + 'sprites/wall_h.png' => VGColor::rgb(0.55, 0.52, 0.48), // gray walls + 'sprites/wall_v.png' => VGColor::rgb(0.55, 0.52, 0.48), + 'sprites/desk.png' => VGColor::rgb(0.60, 0.45, 0.30), // brown desks + 'sprites/employee.png' => VGColor::rgb(0.30, 0.55, 0.85), // blue employees + 'sprites/chair.png' => VGColor::rgb(0.25, 0.25, 0.25), // dark chairs + 'sprites/server.png' => VGColor::rgb(0.20, 0.20, 0.30), // dark blue servers + 'sprites/led_green.png' => VGColor::rgb(0.10, 0.90, 0.20), // green LEDs + 'sprites/coffee_machine.png'=> VGColor::rgb(0.50, 0.35, 0.25), // coffee brown + 'sprites/fridge.png' => VGColor::rgb(0.80, 0.82, 0.85), // silver fridge + 'sprites/table_round.png' => VGColor::rgb(0.65, 0.50, 0.35), // lighter wood + 'sprites/plant.png' => VGColor::rgb(0.20, 0.65, 0.25), // green plants + 'sprites/water_cooler.png' => VGColor::rgb(0.60, 0.78, 0.90), // light blue + 'sprites/whiteboard.png' => VGColor::rgb(0.95, 0.95, 0.98), // white + 'sprites/poster.png' => VGColor::rgb(0.90, 0.50, 0.30), // orange poster + 'sprites/trashbin.png' => VGColor::rgb(0.45, 0.45, 0.45), // gray bin +]; + +// Camera state +$camX = 0.0; +$camY = 0.0; +$camZoom = 1.5; +$dragStartX = null; +$dragStartY = null; +$dragCamStart = null; + +/** @var SignalQueue|null $scrollQueue */ +$scrollQueue = null; + +// --- UI Interpreter for JSON-driven HUD --- +/** @var UIInterpreter|null $uiInterpreter */ +$uiInterpreter = null; +$gameData = new UIDataContext(); +$gameData->setAll([ + 'economy.money' => 12500, + 'company.employees' => 9, + 'company.morale' => 0.82, + 'project.progress' => 0.35, +]); + +$quickstart = new Quickstart(function(QuickstartOptions $app) use($sceneLoader, $componentRegistry, &$scrollQueue, $sortingLayer, $spriteColors, &$camX, &$camY, &$camZoom, &$dragStartX, &$dragStartY, &$dragCamStart, &$uiInterpreter, $gameData) +{ + $app->windowTitle = 'Office Demo'; + $app->windowWidth = 1280; + $app->windowHeight = 720; + + $app->ready = function(QuickstartApp $app) use(&$scrollQueue, &$uiInterpreter, $gameData) { + // Create scroll signal queue for zoom + $scrollQueue = $app->dispatcher->createSignalQueue(Input::EVENT_SCROLL); + + // Create UI interpreter with data binding + $uiInterpreter = new UIInterpreter($app->dispatcher, $gameData); + + // Listen for UI events from JSON buttons + $app->dispatcher->register('ui.event', function(UIEventSignal $signal) use ($gameData) { + echo "UI Event: {$signal->event}\n"; + if ($signal->event === 'ui.hire_employee') { + $employees = (int) $gameData->get('company.employees', 0); + $gameData->set('company.employees', $employees + 1); + $money = (int) $gameData->get('economy.money', 0); + $gameData->set('economy.money', $money - 1000); + } + }); + }; + + $app->initializeScene = function(QuickstartApp $app) use($sceneLoader) { + // Register components + $app->entities->registerComponent(Transform::class); + $app->entities->registerComponent(NameComponent::class); + $app->entities->registerComponent(SpriteRenderer::class); + $app->entities->registerComponent(SpriteAnimator::class); + + // Load the office scene + $scenePath = __DIR__ . '/scenes/office_level1.json'; + $entityIds = $sceneLoader->loadFile($scenePath, $app->entities); + echo "Loaded " . count($entityIds) . " entities from office_level1.json\n"; + }; + + $app->update = function(QuickstartApp $app) use(&$camX, &$camY, &$camZoom, &$dragStartX, &$dragStartY, &$dragCamStart, &$scrollQueue) { + // Scroll to zoom via signal queue + if ($scrollQueue !== null) { + foreach ($scrollQueue->poll() as $signal) { + /** @var ScrollSignal $signal */ + $camZoom += $signal->y * 0.15; + $camZoom = max(0.3, min(5.0, $camZoom)); + } + } + + // Middle mouse / right mouse drag to pan + $cursorPos = $app->input->getCursorPosition(); + if ($app->input->isMouseButtonPressed(GLFW_MOUSE_BUTTON_RIGHT) || $app->input->isMouseButtonPressed(GLFW_MOUSE_BUTTON_MIDDLE)) { + if ($dragStartX === null) { + $dragStartX = $cursorPos->x; + $dragStartY = $cursorPos->y; + $dragCamStart = [$camX, $camY]; + } + $dx = ($cursorPos->x - $dragStartX) / $camZoom; + $dy = ($cursorPos->y - $dragStartY) / $camZoom; + $camX = $dragCamStart[0] - $dx; + $camY = $dragCamStart[1] - $dy; + } else { + $dragStartX = null; + $dragStartY = null; + $dragCamStart = null; + } + + // Arrow keys / WASD to pan + $panSpeed = 3.0 / $camZoom; + if ($app->input->isKeyPressed(GLFW_KEY_W) || $app->input->isKeyPressed(GLFW_KEY_UP)) $camY -= $panSpeed; + if ($app->input->isKeyPressed(GLFW_KEY_S) || $app->input->isKeyPressed(GLFW_KEY_DOWN)) $camY += $panSpeed; + if ($app->input->isKeyPressed(GLFW_KEY_A) || $app->input->isKeyPressed(GLFW_KEY_LEFT)) $camX -= $panSpeed; + if ($app->input->isKeyPressed(GLFW_KEY_D) || $app->input->isKeyPressed(GLFW_KEY_RIGHT)) $camX += $panSpeed; + }; + + $app->draw = function(QuickstartApp $app, RenderContext $context, RenderTarget $target) use(&$camX, &$camY, &$camZoom, $sortingLayer, $spriteColors, &$uiInterpreter, $gameData) + { + $vg = $app->vg; + $screenW = $target->effectiveWidth(); + $screenH = $target->effectiveHeight(); + + // Clear background + $vg->beginPath(); + $vg->rect(0, 0, $screenW, $screenH); + $vg->fillColor(VGColor::rgb(0.12, 0.12, 0.15)); + $vg->fill(); + + $cx = $screenW / 2.0; + $cy = $screenH / 2.0; + + // Collect and sort sprites + $sprites = []; + foreach ($app->entities->view(SpriteRenderer::class) as $entityId => $sprite) { + $transform = $app->entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + // Compute world position (walk parent chain) + $worldX = $transform->position->x; + $worldY = $transform->position->y; + $parent = $transform->parent; + while ($parent !== null) { + $parentTransform = $app->entities->tryGet($parent, Transform::class); + if ($parentTransform === null) break; + $worldX += $parentTransform->position->x; + $worldY += $parentTransform->position->y; + $parent = $parentTransform->parent; + } + + $sortKey = $sortingLayer->getSortKey($sprite->sortingLayer, $sprite->orderInLayer, $worldY); + $sprites[] = [ + 'key' => $sortKey, + 'sprite' => $sprite, + 'worldX' => $worldX, + 'worldY' => $worldY, + 'entityId' => $entityId, + ]; + } + + usort($sprites, fn($a, $b) => $a['key'] <=> $b['key']); + + // Render sprites as colored rectangles + foreach ($sprites as $entry) { + $sprite = $entry['sprite']; + $worldX = $entry['worldX']; + $worldY = $entry['worldY']; + + $w = ($sprite->width > 0 ? $sprite->width : 32); + $h = ($sprite->height > 0 ? $sprite->height : 32); + + $drawW = $w * $camZoom; + $drawH = $h * $camZoom; + $drawX = $cx + ($worldX - $camX) * $camZoom - $drawW / 2; + $drawY = $cy + ($worldY - $camY) * $camZoom - $drawH / 2; + + // Get color for this sprite type + $color = $spriteColors[$sprite->sprite] ?? VGColor::rgb(0.7, 0.3, 0.7); + + $vg->beginPath(); + $vg->roundedRect($drawX, $drawY, $drawW, $drawH, 2.0 * $camZoom); + $vg->fillColor($color); + $vg->fill(); + + // Draw a subtle border + $vg->strokeColor(VGColor::rgba(0, 0, 0, 0.2)); + $vg->strokeWidth(1.0); + $vg->stroke(); + } + + // --- HUD overlay from JSON --- + if ($uiInterpreter !== null) { + $uiInterpreter->renderFile(__DIR__ . '/ui/hud.json'); + } + + // --- Status bar --- + FlyUI::beginLayout(new Vec4(15)) + ->flow(FUILayoutFlow::vertical) + ->horizontalFit() + ->verticalFit() + ->alignBottomLeft(); + + FlyUI::text(sprintf('Entities: %d | Zoom: %.1fx', count($sprites), $camZoom), VGColor::rgb(0.5, 0.5, 0.5))->fontSize(11); + FlyUI::text('WASD/Arrows: Pan | Scroll: Zoom | RMB: Drag', VGColor::rgb(0.4, 0.4, 0.4))->fontSize(10); + + FlyUI::end(); + + // --- Legend --- + $legendItems = [ + ['Floor', VGColor::rgb(0.85, 0.82, 0.75)], + ['Walls', VGColor::rgb(0.55, 0.52, 0.48)], + ['Desks', VGColor::rgb(0.60, 0.45, 0.30)], + ['Employees', VGColor::rgb(0.30, 0.55, 0.85)], + ['Servers', VGColor::rgb(0.20, 0.20, 0.30)], + ['Plants', VGColor::rgb(0.20, 0.65, 0.25)], + ['Furniture', VGColor::rgb(0.65, 0.50, 0.35)], + ]; + + $legendX = $screenW - 150; + $legendY = 15; + + $vg->fontSize(12); + foreach ($legendItems as $i => $item) { + $y = $legendY + $i * 20; + $vg->beginPath(); + $vg->roundedRect($legendX, $y, 14, 14, 2); + $vg->fillColor($item[1]); + $vg->fill(); + + $vg->fillColor(VGColor::rgb(0.8, 0.8, 0.8)); + $vg->text($legendX + 20, $y + 11, $item[0]); + } + }; +}); + +$quickstart->run(); diff --git a/examples/office_demo/prefabs/desk.json b/examples/office_demo/prefabs/desk.json new file mode 100644 index 0000000..b108ffa --- /dev/null +++ b/examples/office_demo/prefabs/desk.json @@ -0,0 +1,18 @@ +{ + "name": "Desk", + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0], + "scale": [1, 1, 1] + }, + "components": [ + { + "type": "SpriteRenderer", + "sprite": "sprites/desk.png", + "sortingLayer": "Default", + "orderInLayer": -1, + "width": 32, + "height": 32 + } + ] +} diff --git a/examples/office_demo/prefabs/employee.json b/examples/office_demo/prefabs/employee.json new file mode 100644 index 0000000..0d6c96a --- /dev/null +++ b/examples/office_demo/prefabs/employee.json @@ -0,0 +1,29 @@ +{ + "name": "Employee", + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0], + "scale": [1, 1, 1] + }, + "components": [ + { + "type": "SpriteRenderer", + "sprite": "sprites/employee.png", + "sortingLayer": "Default", + "orderInLayer": 0, + "width": 16, + "height": 24 + }, + { + "type": "SpriteAnimator", + "currentAnimation": "idle", + "animations": { + "idle": { + "frames": [[0, 0, 0.25, 1], [0.25, 0, 0.25, 1], [0.5, 0, 0.25, 1], [0.75, 0, 0.25, 1]], + "fps": 4, + "loop": true + } + } + } + ] +} diff --git a/examples/office_demo/scenes/office_level1.json b/examples/office_demo/scenes/office_level1.json new file mode 100644 index 0000000..63f851a --- /dev/null +++ b/examples/office_demo/scenes/office_level1.json @@ -0,0 +1,124 @@ +{ + "entities": [ + { + "name": "Office", + "transform": { "position": [0, 0, 0], "scale": [1, 1, 1] }, + "children": [ + { + "name": "Floor", + "transform": { "position": [0, 0, 0], "scale": [1, 1, 1] }, + "components": [ + { "type": "SpriteRenderer", "sprite": "sprites/floor_large.png", "sortingLayer": "Background", "width": 640, "height": 480 } + ] + }, + { + "name": "Wall_North", + "transform": { "position": [0, -200, 0], "scale": [1, 1, 1] }, + "components": [ + { "type": "SpriteRenderer", "sprite": "sprites/wall_h.png", "sortingLayer": "Background", "orderInLayer": 1, "width": 640, "height": 32 } + ] + }, + { + "name": "Wall_South", + "transform": { "position": [0, 200, 0], "scale": [1, 1, 1] }, + "components": [ + { "type": "SpriteRenderer", "sprite": "sprites/wall_h.png", "sortingLayer": "Background", "orderInLayer": 1, "width": 640, "height": 32 } + ] + }, + { + "name": "Wall_West", + "transform": { "position": [-300, 0, 0], "scale": [1, 1, 1] }, + "components": [ + { "type": "SpriteRenderer", "sprite": "sprites/wall_v.png", "sortingLayer": "Background", "orderInLayer": 1, "width": 32, "height": 480 } + ] + }, + { + "name": "Wall_East", + "transform": { "position": [300, 0, 0], "scale": [1, 1, 1] }, + "components": [ + { "type": "SpriteRenderer", "sprite": "sprites/wall_v.png", "sortingLayer": "Background", "orderInLayer": 1, "width": 32, "height": 480 } + ] + }, + + { + "name": "WorkArea_A", + "transform": { "position": [-180, -80, 0] }, + "children": [ + { "name": "Desk_A1", "transform": { "position": [0, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_A2", "transform": { "position": [40, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_A3", "transform": { "position": [80, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_A4", "transform": { "position": [0, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_A5", "transform": { "position": [40, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_A6", "transform": { "position": [80, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Employee_A1", "transform": { "position": [0, -8, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_A2", "transform": { "position": [40, -8, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_A3", "transform": { "position": [80, -8, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_A4", "transform": { "position": [0, 48, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_A5", "transform": { "position": [40, 48, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_A6", "transform": { "position": [80, 48, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Chair_A1", "transform": { "position": [0, -16, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/chair.png", "sortingLayer": "Default", "orderInLayer": -2, "width": 16, "height": 16 }] }, + { "name": "Chair_A2", "transform": { "position": [40, -16, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/chair.png", "sortingLayer": "Default", "orderInLayer": -2, "width": 16, "height": 16 }] }, + { "name": "Chair_A3", "transform": { "position": [80, -16, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/chair.png", "sortingLayer": "Default", "orderInLayer": -2, "width": 16, "height": 16 }] } + ] + }, + + { + "name": "WorkArea_B", + "transform": { "position": [80, -80, 0] }, + "children": [ + { "name": "Desk_B1", "transform": { "position": [0, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_B2", "transform": { "position": [40, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_B3", "transform": { "position": [80, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_B4", "transform": { "position": [0, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_B5", "transform": { "position": [40, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Desk_B6", "transform": { "position": [80, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/desk.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Employee_B1", "transform": { "position": [0, -8, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_B2", "transform": { "position": [40, -8, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Employee_B3", "transform": { "position": [80, -8, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/employee.png", "sortingLayer": "Default", "width": 16, "height": 24 }] } + ] + }, + + { + "name": "ServerRoom", + "transform": { "position": [200, 80, 0] }, + "children": [ + { "name": "Server_1", "transform": { "position": [0, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/server.png", "sortingLayer": "Default", "width": 24, "height": 48 }] }, + { "name": "Server_2", "transform": { "position": [30, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/server.png", "sortingLayer": "Default", "width": 24, "height": 48 }] }, + { "name": "Server_3", "transform": { "position": [60, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/server.png", "sortingLayer": "Default", "width": 24, "height": 48 }] }, + { "name": "ServerLight_1", "transform": { "position": [6, -10, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/led_green.png", "sortingLayer": "Foreground", "width": 4, "height": 4 }] }, + { "name": "ServerLight_2", "transform": { "position": [36, -10, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/led_green.png", "sortingLayer": "Foreground", "width": 4, "height": 4 }] }, + { "name": "ServerLight_3", "transform": { "position": [66, -10, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/led_green.png", "sortingLayer": "Foreground", "width": 4, "height": 4 }] } + ] + }, + + { + "name": "BreakRoom", + "transform": { "position": [-200, 100, 0] }, + "children": [ + { "name": "CoffeeMachine", "transform": { "position": [0, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/coffee_machine.png", "sortingLayer": "Default", "width": 16, "height": 24 }] }, + { "name": "Fridge", "transform": { "position": [30, 0, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/fridge.png", "sortingLayer": "Default", "width": 24, "height": 32 }] }, + { "name": "Table", "transform": { "position": [0, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/table_round.png", "sortingLayer": "Default", "orderInLayer": -1, "width": 32, "height": 32 }] }, + { "name": "Chair_Break1", "transform": { "position": [-16, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/chair.png", "sortingLayer": "Default", "orderInLayer": -2, "width": 16, "height": 16 }] }, + { "name": "Chair_Break2", "transform": { "position": [16, 40, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/chair.png", "sortingLayer": "Default", "orderInLayer": -2, "width": 16, "height": 16, "flipX": true }] } + ] + }, + + { + "name": "Decoration", + "transform": { "position": [0, 0, 0] }, + "children": [ + { "name": "Plant_1", "transform": { "position": [-270, -170, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/plant.png", "sortingLayer": "Default", "orderInLayer": 5, "width": 16, "height": 24 }] }, + { "name": "Plant_2", "transform": { "position": [270, -170, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/plant.png", "sortingLayer": "Default", "orderInLayer": 5, "width": 16, "height": 24 }] }, + { "name": "Plant_3", "transform": { "position": [-270, 170, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/plant.png", "sortingLayer": "Default", "orderInLayer": 5, "width": 16, "height": 24 }] }, + { "name": "Plant_4", "transform": { "position": [270, 170, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/plant.png", "sortingLayer": "Default", "orderInLayer": 5, "width": 16, "height": 24 }] }, + { "name": "WaterCooler", "transform": { "position": [0, -170, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/water_cooler.png", "sortingLayer": "Default", "width": 12, "height": 24 }] }, + { "name": "Whiteboard", "transform": { "position": [-50, -180, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/whiteboard.png", "sortingLayer": "Default", "orderInLayer": 2, "width": 48, "height": 32 }] }, + { "name": "Poster_1", "transform": { "position": [150, -185, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/poster.png", "sortingLayer": "Default", "orderInLayer": 2, "width": 24, "height": 32 }] }, + { "name": "TrashBin_1", "transform": { "position": [-100, 50, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/trashbin.png", "sortingLayer": "Default", "width": 12, "height": 16 }] }, + { "name": "TrashBin_2", "transform": { "position": [150, 50, 0] }, "components": [{ "type": "SpriteRenderer", "sprite": "sprites/trashbin.png", "sortingLayer": "Default", "width": 12, "height": 16 }] } + ] + } + ] + } + ] +} diff --git a/examples/office_demo/ui/hud.json b/examples/office_demo/ui/hud.json new file mode 100644 index 0000000..723baa5 --- /dev/null +++ b/examples/office_demo/ui/hud.json @@ -0,0 +1,29 @@ +{ + "type": "panel", + "layout": "column", + "padding": 15, + "spacing": 8, + "verticalSizing": "fit", + "horizontalSizing": "fit", + "children": [ + { "type": "label", "text": "Office Demo", "fontSize": 20, "bold": true, "color": "#ffffff" }, + { "type": "label", "text": "Money: ${economy.money}", "fontSize": 14, "color": "#cccccc" }, + { "type": "label", "text": "Employees: {company.employees}", "fontSize": 14, "color": "#cccccc" }, + { "type": "space", "height": 5 }, + { "type": "label", "text": "Morale", "fontSize": 11, "color": "#999999" }, + { "type": "progressbar", "value": "{company.morale}", "color": "#33cc55", "height": 12 }, + { "type": "space", "height": 5 }, + { "type": "label", "text": "Project Progress", "fontSize": 11, "color": "#999999" }, + { "type": "progressbar", "value": "{project.progress}", "color": "#0088ff", "height": 12 }, + { "type": "space", "height": 10 }, + { "type": "button", "label": "Hire Employee", "event": "ui.hire_employee", "id": "btn_hire" }, + { "type": "button", "label": "Start Project", "event": "ui.start_project", "style": "secondary", "id": "btn_project" }, + { "type": "space", "height": 5 }, + { + "type": "checkbox", + "text": "Auto-Assign Tasks", + "id": "cb_auto_assign", + "event": "ui.toggle_auto_assign" + } + ] +} diff --git a/examples/rendering/camera3d_demo.php b/examples/rendering/camera3d_demo.php new file mode 100644 index 0000000..c4d30db --- /dev/null +++ b/examples/rendering/camera3d_demo.php @@ -0,0 +1,221 @@ +push($x * $radius); $buffer->push($y * $radius); $buffer->push($z * $radius); + $buffer->push($x); $buffer->push($y); $buffer->push($z); + $buffer->push($ss / $segments); $buffer->push($rr / $rings); + $buffer->push(-sin($phi)); $buffer->push(0.0); $buffer->push(cos($phi)); $buffer->push(1.0); + } + } + } + return $buffer; +} + +function genPlaneDemo(float $size = 40.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + $verts = [ + [-$h, 0, -$h], [$h, 0, -$h], [$h, 0, $h], + [-$h, 0, -$h], [$h, 0, $h], [-$h, 0, $h], + ]; + foreach ($verts as $p) { + $buffer->push($p[0]); $buffer->push($p[1]); $buffer->push($p[2]); + $buffer->push(0); $buffer->push(1); $buffer->push(0); + $buffer->push(($p[0] + $h) / $size); $buffer->push(($p[2] + $h) / $size); + $buffer->push(1); $buffer->push(0); $buffer->push(0); $buffer->push(1); + } + return $buffer; +} + +function genCubeDemo(float $size = 1.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + $faces = [ + [[-$h,-$h,$h], [$h,-$h,$h], [$h,$h,$h], [-$h,-$h,$h], [$h,$h,$h], [-$h,$h,$h], [0,0,1], [1,0,0]], + [[$h,-$h,-$h], [-$h,-$h,-$h], [-$h,$h,-$h], [$h,-$h,-$h], [-$h,$h,-$h], [$h,$h,-$h], [0,0,-1], [-1,0,0]], + [[$h,-$h,$h], [$h,-$h,-$h], [$h,$h,-$h], [$h,-$h,$h], [$h,$h,-$h], [$h,$h,$h], [1,0,0], [0,0,-1]], + [[-$h,-$h,-$h], [-$h,-$h,$h], [-$h,$h,$h], [-$h,-$h,-$h], [-$h,$h,$h], [-$h,$h,-$h], [-1,0,0], [0,0,1]], + [[-$h,$h,$h], [$h,$h,$h], [$h,$h,-$h], [-$h,$h,$h], [$h,$h,-$h], [-$h,$h,-$h], [0,1,0], [1,0,0]], + [[-$h,-$h,-$h], [$h,-$h,-$h], [$h,-$h,$h], [-$h,-$h,-$h], [$h,-$h,$h], [-$h,-$h,$h], [0,-1,0], [1,0,0]], + ]; + foreach ($faces as $face) { + $n = $face[6]; $t = $face[7]; + for ($i = 0; $i < 6; $i++) { + $p = $face[$i]; + $buffer->push($p[0]); $buffer->push($p[1]); $buffer->push($p[2]); + $buffer->push($n[0]); $buffer->push($n[1]); $buffer->push($n[2]); + $buffer->push(0.0); $buffer->push(0.0); + $buffer->push($t[0]); $buffer->push($t[1]); $buffer->push($t[2]); $buffer->push(1.0); + } + } + return $buffer; +} + +$quickstart = new Quickstart(function (QuickstartOptions $app) use (&$state, $container) { + $app->container = $container; + $app->windowTitle = 'VISU — 3D Camera Demo (1=Orbit, 2=FP, 3=TP, F=Toggle follow)'; + + $app->ready = function (QuickstartApp $app) use (&$state) { + $state->models = new ModelCollection(); + $state->renderingSystem = new Rendering3DSystem($app->gl, $app->shaders, $state->models); + $state->cameraSystem = new Camera3DSystem($app->input, $app->dispatcher); + + $app->bindSystems([ + $state->renderingSystem, + $state->cameraSystem, + ]); + + // floor + $floorMat = new Material('floor', new Vec4(0.35, 0.35, 0.4, 1.0), 0.0, 0.9); + $floorMesh = new Mesh3D($app->gl, $floorMat, new AABB(new Vec3(-20, -0.01, -20), new Vec3(20, 0.01, 20))); + $floorMesh->uploadVertices(genPlaneDemo(40.0)); + $floorModel = new Model3D('floor'); + $floorModel->addMesh($floorMesh); + $floorModel->recalculateAABB(); + $state->models->add($floorModel); + + // sphere (follow target) + $sphereMat = new Material('sphere', new Vec4(0.9, 0.3, 0.2, 1.0), 0.0, 0.3); + $sphereMesh = new Mesh3D($app->gl, $sphereMat, new AABB(new Vec3(-0.5, -0.5, -0.5), new Vec3(0.5, 0.5, 0.5))); + $sphereMesh->uploadVertices(genSphereDemo(24, 12, 0.5)); + $sphereModel = new Model3D('sphere'); + $sphereModel->addMesh($sphereMesh); + $sphereModel->recalculateAABB(); + $state->models->add($sphereModel); + + // pillars + $pillarMat = new Material('pillar', new Vec4(0.7, 0.7, 0.75, 1.0), 0.0, 0.5); + $pillarMesh = new Mesh3D($app->gl, $pillarMat, new AABB(new Vec3(-0.3, -1, -0.3), new Vec3(0.3, 1, 0.3))); + $pillarMesh->uploadVertices(genCubeDemo(0.6)); + $pillarModel = new Model3D('pillar'); + $pillarModel->addMesh($pillarMesh); + $pillarModel->recalculateAABB(); + $state->models->add($pillarModel); + }; + + $app->initializeScene = function (QuickstartApp $app) use (&$state) { + // start in orbit mode + $state->cameraSystem->spawnCamera($app->entities, Camera3DMode::orbit, new Vec3(0.0, 5.0, 15.0)); + + $sun = $app->entities->getSingleton(DirectionalLightComponent::class); + $sun->direction = new Vec3(0.3, 1.0, 0.5); + $sun->direction->normalize(); + $sun->intensity = 1.5; + + // floor + $floor = $app->entities->create(); + $app->entities->attach($floor, new MeshRendererComponent('floor')); + $app->entities->attach($floor, new Transform()); + + // follow target (red sphere) + $state->followEntity = $app->entities->create(); + $app->entities->attach($state->followEntity, new MeshRendererComponent('sphere')); + $transform = $app->entities->attach($state->followEntity, new Transform()); + $transform->position->y = 0.5; + $transform->markDirty(); + + // scene pillars in a circle + for ($i = 0; $i < 8; $i++) { + $angle = ($i / 8.0) * M_PI * 2.0; + $pillar = $app->entities->create(); + $app->entities->attach($pillar, new MeshRendererComponent('pillar')); + $pt = $app->entities->attach($pillar, new Transform()); + $pt->position->x = cos($angle) * 8.0; + $pt->position->y = 1.0; + $pt->position->z = sin($angle) * 8.0; + $pt->scale->y = 2.0; + $pt->markDirty(); + } + }; + + $app->update = function (QuickstartApp $app) use (&$state) { + // mode switching + if ($app->input->hasKeyBeenPressed(Key::NUM_1)) { + $state->cameraSystem->setMode(Camera3DMode::orbit); + } + if ($app->input->hasKeyBeenPressed(Key::NUM_2)) { + $state->cameraSystem->setMode(Camera3DMode::firstPerson); + } + if ($app->input->hasKeyBeenPressed(Key::NUM_3)) { + $state->cameraSystem->setMode(Camera3DMode::thirdPerson); + // set follow target + $comp = $app->entities->get($state->cameraSystem->getCameraEntity(), \VISU\Component\Camera3DComponent::class); + $comp->followTarget = $state->followEntity; + } + if ($app->input->hasKeyBeenPressed(Key::F)) { + $state->followMoving = !$state->followMoving; + } + + // move follow target in a circle + if ($state->followMoving && $state->followEntity !== 0) { + $time = glfwGetTime(); + $transform = $app->entities->get($state->followEntity, Transform::class); + $transform->position->x = cos($time * 0.5) * 5.0; + $transform->position->z = sin($time * 0.5) * 5.0; + $transform->position->y = 0.5; + $transform->markDirty(); + } + + $app->updateSystem($state->cameraSystem); + }; + + $app->render = function (QuickstartApp $app, RenderContext $context, RenderTargetResource $target) use (&$state) { + $state->renderingSystem->setRenderTarget($target); + $app->renderSystem($state->cameraSystem, $context); + $app->renderSystem($state->renderingSystem, $context); + }; +}); + +$quickstart->run(); diff --git a/examples/rendering/gltf_loader_demo.php b/examples/rendering/gltf_loader_demo.php new file mode 100644 index 0000000..cf4d534 --- /dev/null +++ b/examples/rendering/gltf_loader_demo.php @@ -0,0 +1,117 @@ +\n"; + echo "\nNo model specified. The demo will start with an empty scene.\n"; + echo "You can download test models from: https://github.com/KhronosGroup/glTF-Sample-Models\n\n"; +} + +class GltfDemoState +{ + public VISUCameraSystem $cameraSystem; + public Rendering3DSystem $renderingSystem; + public ModelCollection $models; + public ?string $loadedModelName = null; +} + +$state = new GltfDemoState; + +$quickstart = new Quickstart(function (QuickstartOptions $app) use (&$state, $container, $modelPath) { + $app->container = $container; + $app->windowTitle = 'VISU — glTF Loader Demo'; + + $app->ready = function (QuickstartApp $app) use (&$state, $modelPath) { + $state->models = new ModelCollection(); + $state->renderingSystem = new Rendering3DSystem($app->gl, $app->shaders, $state->models); + $state->cameraSystem = new VISUCameraSystem($app->input, $app->dispatcher); + + $app->bindSystems([ + $state->renderingSystem, + $state->cameraSystem, + ]); + + // load glTF model + if ($modelPath !== null && file_exists($modelPath)) { + $loader = new GltfLoader($app->gl); + $model = $loader->load($modelPath); + $state->models->add($model); + $state->loadedModelName = $model->name; + + echo "Loaded model: {$model->name}\n"; + echo "Meshes: " . count($model->meshes) . "\n"; + echo "AABB: ({$model->aabb->min->x}, {$model->aabb->min->y}, {$model->aabb->min->z}) -> ({$model->aabb->max->x}, {$model->aabb->max->y}, {$model->aabb->max->z})\n"; + } + }; + + $app->initializeScene = function (QuickstartApp $app) use (&$state) { + $state->cameraSystem->spawnDefaultFlyingCamera($app->entities, new Vec3(0.0, 2.0, 5.0)); + + // sun + $sun = $app->entities->getSingleton(DirectionalLightComponent::class); + $sun->direction = new Vec3(0.5, 1.0, 0.3); + $sun->direction->normalize(); + $sun->intensity = 2.0; + + // spawn model if loaded + if ($state->loadedModelName !== null) { + $entity = $app->entities->create(); + $app->entities->attach($entity, new MeshRendererComponent($state->loadedModelName)); + $app->entities->attach($entity, new Transform()); + } + + // add fill lights + $fillLights = [ + [new Vec3(1, 0.95, 0.9), new Vec3(5, 4, 3)], + [new Vec3(0.9, 0.95, 1), new Vec3(-5, 3, -2)], + ]; + foreach ($fillLights as [$color, $pos]) { + $light = $app->entities->create(); + $lightComp = new PointLightComponent($color, 3.0, 20.0); + $lightComp->setAttenuationFromRange(); + $app->entities->attach($light, $lightComp); + $transform = $app->entities->attach($light, new Transform()); + $transform->position = $pos; + $transform->markDirty(); + } + }; + + $app->update = function (QuickstartApp $app) use (&$state) { + $app->updateSystem($state->cameraSystem); + }; + + $app->render = function (QuickstartApp $app, RenderContext $context, RenderTargetResource $target) use (&$state) { + $state->renderingSystem->setRenderTarget($target); + $app->renderSystem($state->cameraSystem, $context); + $app->renderSystem($state->renderingSystem, $context); + }; +}); + +$quickstart->run(); diff --git a/examples/rendering/multi_light_demo.php b/examples/rendering/multi_light_demo.php new file mode 100644 index 0000000..30820cc --- /dev/null +++ b/examples/rendering/multi_light_demo.php @@ -0,0 +1,287 @@ + point light entity IDs */ + public array $lightEntities = []; +} + +$state = new MultiLightDemoState; + +/** + * Generate a UV sphere + */ +function genSphere(int $segments = 32, int $rings = 16, float $radius = 1.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + for ($j = 0; $j < $rings; $j++) { + for ($i = 0; $i < $segments; $i++) { + foreach ([[$j, $i], [$j + 1, $i], [$j + 1, $i + 1], [$j, $i], [$j + 1, $i + 1], [$j, $i + 1]] as [$rr, $ss]) { + $theta = $rr * M_PI / $rings; + $phi = $ss * 2.0 * M_PI / $segments; + $x = sin($theta) * cos($phi); + $y = cos($theta); + $z = sin($theta) * sin($phi); + $buffer->push($x * $radius); $buffer->push($y * $radius); $buffer->push($z * $radius); + $buffer->push($x); $buffer->push($y); $buffer->push($z); + $buffer->push($ss / $segments); $buffer->push($rr / $rings); + $buffer->push(-sin($phi)); $buffer->push(0.0); $buffer->push(cos($phi)); $buffer->push(1.0); + } + } + } + return $buffer; +} + +/** + * Generate a cube + */ +function genCube(float $size = 1.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + + $faces = [ + // front (z+) + [[-$h,-$h,$h], [$h,-$h,$h], [$h,$h,$h], [-$h,-$h,$h], [$h,$h,$h], [-$h,$h,$h], [0,0,1], [1,0,0]], + // back (z-) + [[$h,-$h,-$h], [-$h,-$h,-$h], [-$h,$h,-$h], [$h,-$h,-$h], [-$h,$h,-$h], [$h,$h,-$h], [0,0,-1], [-1,0,0]], + // right (x+) + [[$h,-$h,$h], [$h,-$h,-$h], [$h,$h,-$h], [$h,-$h,$h], [$h,$h,-$h], [$h,$h,$h], [1,0,0], [0,0,-1]], + // left (x-) + [[-$h,-$h,-$h], [-$h,-$h,$h], [-$h,$h,$h], [-$h,-$h,-$h], [-$h,$h,$h], [-$h,$h,-$h], [-1,0,0], [0,0,1]], + // top (y+) + [[-$h,$h,$h], [$h,$h,$h], [$h,$h,-$h], [-$h,$h,$h], [$h,$h,-$h], [-$h,$h,-$h], [0,1,0], [1,0,0]], + // bottom (y-) + [[-$h,-$h,-$h], [$h,-$h,-$h], [$h,-$h,$h], [-$h,-$h,-$h], [$h,-$h,$h], [-$h,-$h,$h], [0,-1,0], [1,0,0]], + ]; + + foreach ($faces as $face) { + $n = $face[6]; + $t = $face[7]; + for ($i = 0; $i < 6; $i++) { + $p = $face[$i]; + $buffer->push($p[0]); $buffer->push($p[1]); $buffer->push($p[2]); // pos + $buffer->push($n[0]); $buffer->push($n[1]); $buffer->push($n[2]); // normal + $buffer->push(0.0); $buffer->push(0.0); // uv (simplified) + $buffer->push($t[0]); $buffer->push($t[1]); $buffer->push($t[2]); $buffer->push(1.0); // tangent + } + } + + return $buffer; +} + +/** + * Generate a floor plane + */ +function genPlane(float $size = 20.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + $verts = [ + [-$h, 0, -$h], [$h, 0, -$h], [$h, 0, $h], + [-$h, 0, -$h], [$h, 0, $h], [-$h, 0, $h], + ]; + foreach ($verts as $p) { + $buffer->push($p[0]); $buffer->push($p[1]); $buffer->push($p[2]); + $buffer->push(0); $buffer->push(1); $buffer->push(0); + $buffer->push(($p[0] + $h) / $size); $buffer->push(($p[2] + $h) / $size); + $buffer->push(1); $buffer->push(0); $buffer->push(0); $buffer->push(1); + } + return $buffer; +} + +$quickstart = new Quickstart(function (QuickstartOptions $app) use (&$state, $container) { + $app->container = $container; + $app->windowTitle = 'VISU — Multi-Light PBR Demo'; + + $app->ready = function (QuickstartApp $app) use (&$state) { + $state->models = new ModelCollection(); + $state->renderingSystem = new Rendering3DSystem($app->gl, $app->shaders, $state->models); + $state->cameraSystem = new VISUCameraSystem($app->input, $app->dispatcher); + + $app->bindSystems([ + $state->renderingSystem, + $state->cameraSystem, + ]); + + // -- create models -- + + // gold sphere + $goldMat = new Material('gold', new Vec4(1.0, 0.766, 0.336, 1.0), 1.0, 0.3); + $goldMesh = new Mesh3D($app->gl, $goldMat, new AABB(new Vec3(-0.8, -0.8, -0.8), new Vec3(0.8, 0.8, 0.8))); + $goldMesh->uploadVertices(genSphere(32, 16, 0.8)); + $goldModel = new Model3D('gold_sphere'); + $goldModel->addMesh($goldMesh); + $goldModel->recalculateAABB(); + $state->models->add($goldModel); + + // silver cube + $silverMat = new Material('silver', new Vec4(0.95, 0.93, 0.88, 1.0), 1.0, 0.15); + $silverMesh = new Mesh3D($app->gl, $silverMat, new AABB(new Vec3(-0.6, -0.6, -0.6), new Vec3(0.6, 0.6, 0.6))); + $silverMesh->uploadVertices(genCube(1.2)); + $silverModel = new Model3D('silver_cube'); + $silverModel->addMesh($silverMesh); + $silverModel->recalculateAABB(); + $state->models->add($silverModel); + + // plastic sphere + $plasticMat = new Material('plastic', new Vec4(0.1, 0.4, 0.9, 1.0), 0.0, 0.4); + $plasticMesh = new Mesh3D($app->gl, $plasticMat, new AABB(new Vec3(-0.7, -0.7, -0.7), new Vec3(0.7, 0.7, 0.7))); + $plasticMesh->uploadVertices(genSphere(32, 16, 0.7)); + $plasticModel = new Model3D('plastic_sphere'); + $plasticModel->addMesh($plasticMesh); + $plasticModel->recalculateAABB(); + $state->models->add($plasticModel); + + // emissive sphere + $emissiveMat = new Material('emissive', new Vec4(0.05, 0.05, 0.05, 1.0), 0.0, 0.9); + $emissiveMat->emissiveColor = new Vec3(2.0, 0.5, 0.1); + $emissiveMesh = new Mesh3D($app->gl, $emissiveMat, new AABB(new Vec3(-0.5, -0.5, -0.5), new Vec3(0.5, 0.5, 0.5))); + $emissiveMesh->uploadVertices(genSphere(24, 12, 0.5)); + $emissiveModel = new Model3D('emissive_sphere'); + $emissiveModel->addMesh($emissiveMesh); + $emissiveModel->recalculateAABB(); + $state->models->add($emissiveModel); + + // floor + $floorMat = new Material('floor', new Vec4(0.4, 0.4, 0.45, 1.0), 0.0, 0.9); + $floorMesh = new Mesh3D($app->gl, $floorMat, new AABB(new Vec3(-10, -0.01, -10), new Vec3(10, 0.01, 10))); + $floorMesh->uploadVertices(genPlane(20.0)); + $floorModel = new Model3D('floor'); + $floorModel->addMesh($floorMesh); + $floorModel->recalculateAABB(); + $state->models->add($floorModel); + + // small sphere for light markers + $lightMarkerMat = new Material('light_marker', new Vec4(1, 1, 1, 1), 0.0, 1.0); + $lightMarkerMat->emissiveColor = new Vec3(5.0, 5.0, 5.0); + $lightMarkerMesh = new Mesh3D($app->gl, $lightMarkerMat, new AABB(new Vec3(-0.1, -0.1, -0.1), new Vec3(0.1, 0.1, 0.1))); + $lightMarkerMesh->uploadVertices(genSphere(12, 6, 0.1)); + $lightMarkerModel = new Model3D('light_marker'); + $lightMarkerModel->addMesh($lightMarkerMesh); + $lightMarkerModel->recalculateAABB(); + $state->models->add($lightMarkerModel); + }; + + $app->initializeScene = function (QuickstartApp $app) use (&$state) { + $state->cameraSystem->spawnDefaultFlyingCamera($app->entities, new Vec3(0.0, 5.0, 12.0)); + + // sun (dimmed, let point lights be the main illumination) + $sun = $app->entities->getSingleton(DirectionalLightComponent::class); + $sun->direction = new Vec3(0.3, 1.0, 0.5); + $sun->direction->normalize(); + $sun->intensity = 0.3; + + // scene objects + $objects = [ + ['gold_sphere', 0.0, 1.0, 0.0], + ['silver_cube', -3.0, 0.8, -2.0], + ['plastic_sphere', 3.0, 0.9, -1.5], + ['emissive_sphere', 0.0, 0.7, -3.5], + ]; + foreach ($objects as [$model, $x, $y, $z]) { + $entity = $app->entities->create(); + $app->entities->attach($entity, new MeshRendererComponent($model)); + $transform = $app->entities->attach($entity, new Transform()); + $transform->position->x = $x; + $transform->position->y = $y; + $transform->position->z = $z; + $transform->markDirty(); + } + + // floor + $floor = $app->entities->create(); + $app->entities->attach($floor, new MeshRendererComponent('floor')); + $app->entities->attach($floor, new Transform()); + + // orbiting point lights (8 lights in a ring) + $numLights = 8; + $lightColors = [ + new Vec3(1.0, 0.3, 0.3), // red + new Vec3(0.3, 1.0, 0.3), // green + new Vec3(0.3, 0.3, 1.0), // blue + new Vec3(1.0, 1.0, 0.3), // yellow + new Vec3(1.0, 0.3, 1.0), // magenta + new Vec3(0.3, 1.0, 1.0), // cyan + new Vec3(1.0, 0.6, 0.2), // orange + new Vec3(0.8, 0.4, 1.0), // purple + ]; + + for ($i = 0; $i < $numLights; $i++) { + $light = $app->entities->create(); + $lightComp = new PointLightComponent($lightColors[$i], 3.0, 12.0); + $lightComp->setAttenuationFromRange(); + $app->entities->attach($light, $lightComp); + $transform = $app->entities->attach($light, new Transform()); + $transform->position->y = 2.0; + $transform->markDirty(); + $state->lightEntities[] = $light; + + // light marker (small emissive sphere at light position) + $marker = $app->entities->create(); + $markerMat = new Material("light_marker_{$i}", new Vec4(0.01, 0.01, 0.01, 1.0), 0.0, 1.0); + $markerMat->emissiveColor = $lightColors[$i] * 5.0; + $markerRenderer = new MeshRendererComponent('light_marker'); + $markerRenderer->materialOverride = $markerMat; + $app->entities->attach($marker, $markerRenderer); + $markerTransform = $app->entities->attach($marker, new Transform()); + $markerTransform->setParent($app->entities, $light); + } + }; + + $app->update = function (QuickstartApp $app) use (&$state) { + $app->updateSystem($state->cameraSystem); + + // orbit the lights around the scene + $time = glfwGetTime(); + $numLights = count($state->lightEntities); + $radius = 6.0; + + for ($i = 0; $i < $numLights; $i++) { + $angle = ($i / $numLights) * M_PI * 2.0 + $time * 0.5; + $transform = $app->entities->get($state->lightEntities[$i], Transform::class); + $transform->position->x = cos($angle) * $radius; + $transform->position->z = sin($angle) * $radius; + $transform->position->y = 2.0 + sin($time * 0.8 + $i) * 1.0; + $transform->markDirty(); + } + }; + + $app->render = function (QuickstartApp $app, RenderContext $context, RenderTargetResource $target) use (&$state) { + $state->renderingSystem->setRenderTarget($target); + $app->renderSystem($state->cameraSystem, $context); + $app->renderSystem($state->renderingSystem, $context); + }; +}); + +$quickstart->run(); diff --git a/examples/rendering/pbr_demo.php b/examples/rendering/pbr_demo.php new file mode 100644 index 0000000..3f8c812 --- /dev/null +++ b/examples/rendering/pbr_demo.php @@ -0,0 +1,250 @@ +push($x * $radius); + $buffer->push($y * $radius); + $buffer->push($z * $radius); + // normal + $buffer->push($x); + $buffer->push($y); + $buffer->push($z); + // uv + $buffer->push($u); + $buffer->push($v); + // tangent (vec4, w=1 for handedness) + $buffer->push($tx); + $buffer->push($ty); + $buffer->push($tz); + $buffer->push(1.0); + } + } + } + + return $buffer; +} + +/** + * Generate a flat plane mesh + */ +function generatePlane(float $size = 10.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + + $vertices = [ + [-$h, 0, -$h, 0, 1, 0, 0, 0, 1, 0, 0, 1], + [ $h, 0, -$h, 0, 1, 0, 1, 0, 1, 0, 0, 1], + [ $h, 0, $h, 0, 1, 0, 1, 1, 1, 0, 0, 1], + [-$h, 0, -$h, 0, 1, 0, 0, 0, 1, 0, 0, 1], + [ $h, 0, $h, 0, 1, 0, 1, 1, 1, 0, 0, 1], + [-$h, 0, $h, 0, 1, 0, 0, 1, 1, 0, 0, 1], + ]; + + foreach ($vertices as $v) { + foreach ($v as $f) $buffer->push($f); + } + + return $buffer; +} + +$quickstart = new Quickstart(function (QuickstartOptions $app) use (&$state, $container) { + $app->container = $container; + $app->windowTitle = 'VISU — PBR Material Demo'; + + $app->ready = function (QuickstartApp $app) use (&$state) { + $state->models = new ModelCollection(); + $state->renderingSystem = new Rendering3DSystem($app->gl, $app->shaders, $state->models); + $state->cameraSystem = new VISUCameraSystem($app->input, $app->dispatcher); + + $app->bindSystems([ + $state->renderingSystem, + $state->cameraSystem, + ]); + + // create sphere models with varying PBR properties + $rows = 7; // metallic steps + $cols = 7; // roughness steps + + for ($m = 0; $m < $rows; $m++) { + for ($r = 0; $r < $cols; $r++) { + $metallic = $m / ($rows - 1); + $roughness = max(0.05, $r / ($cols - 1)); // clamp min roughness + + $matName = "sphere_m{$m}_r{$r}"; + $material = new Material( + name: $matName, + albedoColor: new Vec4(0.9, 0.2, 0.2, 1.0), + metallic: $metallic, + roughness: $roughness, + ); + + $sphereData = generateSphere(24, 12, 0.4); + $mesh = new Mesh3D($app->gl, $material, new AABB( + new Vec3(-0.4, -0.4, -0.4), + new Vec3(0.4, 0.4, 0.4), + )); + $mesh->uploadVertices($sphereData); + + $model = new Model3D($matName); + $model->addMesh($mesh); + $model->recalculateAABB(); + $state->models->add($model); + } + } + + // floor plane + $floorMat = new Material( + name: 'floor', + albedoColor: new Vec4(0.3, 0.3, 0.35, 1.0), + metallic: 0.0, + roughness: 0.8, + ); + $planeMesh = new Mesh3D($app->gl, $floorMat, new AABB( + new Vec3(-10, -0.01, -10), new Vec3(10, 0.01, 10) + )); + $planeMesh->uploadVertices(generatePlane(20.0)); + $floorModel = new Model3D('floor'); + $floorModel->addMesh($planeMesh); + $floorModel->recalculateAABB(); + $state->models->add($floorModel); + }; + + $app->initializeScene = function (QuickstartApp $app) use (&$state) { + $state->cameraSystem->spawnDefaultFlyingCamera($app->entities, new Vec3(3.5, 4.0, 10.0)); + + // configure sun + $sun = $app->entities->getSingleton(DirectionalLightComponent::class); + $sun->direction = new Vec3(0.5, 1.0, 0.3); + $sun->direction->normalize(); + $sun->intensity = 2.0; + + // spawn sphere grid + $rows = 7; + $cols = 7; + $spacing = 1.1; + $startX = -($cols - 1) * $spacing * 0.5; + $startZ = -($rows - 1) * $spacing * 0.5; + + for ($m = 0; $m < $rows; $m++) { + for ($r = 0; $r < $cols; $r++) { + $entity = $app->entities->create(); + $app->entities->attach($entity, new MeshRendererComponent("sphere_m{$m}_r{$r}")); + $transform = $app->entities->attach($entity, new Transform()); + $transform->position->x = $startX + $r * $spacing; + $transform->position->y = 1.5; + $transform->position->z = $startZ + $m * $spacing; + $transform->markDirty(); + } + } + + // floor + $floor = $app->entities->create(); + $app->entities->attach($floor, new MeshRendererComponent('floor')); + $app->entities->attach($floor, new Transform()); + + // point lights + $lightColors = [ + new Vec3(1.0, 0.8, 0.6), // warm + new Vec3(0.6, 0.8, 1.0), // cool + new Vec3(0.2, 1.0, 0.4), // green + new Vec3(1.0, 0.3, 0.7), // pink + ]; + $lightPositions = [ + new Vec3(-4.0, 3.0, 4.0), + new Vec3(4.0, 3.0, -4.0), + new Vec3(-4.0, 3.0, -4.0), + new Vec3(4.0, 3.0, 4.0), + ]; + + for ($i = 0; $i < 4; $i++) { + $light = $app->entities->create(); + $lightComp = new PointLightComponent($lightColors[$i], 5.0, 15.0); + $lightComp->setAttenuationFromRange(); + $app->entities->attach($light, $lightComp); + $transform = $app->entities->attach($light, new Transform()); + $transform->position = $lightPositions[$i]; + $transform->markDirty(); + } + }; + + $app->update = function (QuickstartApp $app) use (&$state) { + $app->updateSystem($state->cameraSystem); + }; + + $app->render = function (QuickstartApp $app, RenderContext $context, RenderTargetResource $target) use (&$state) { + $state->renderingSystem->setRenderTarget($target); + $app->renderSystem($state->cameraSystem, $context); + $app->renderSystem($state->renderingSystem, $context); + + }; +}); + +$quickstart->run(); diff --git a/examples/rendering/point_shadows_demo.php b/examples/rendering/point_shadows_demo.php new file mode 100644 index 0000000..7ff8568 --- /dev/null +++ b/examples/rendering/point_shadows_demo.php @@ -0,0 +1,385 @@ + shadow-casting light entity IDs */ + public array $shadowLightEntities = []; + /** @var array non-shadow fill light entity IDs */ + public array $fillLightEntities = []; + public int $shadowResolution = 512; +} + +$state = new PointShadowDemoState; + +// ── Geometry generators ────────────────────────────────────────────── + +function genSpherePS(int $segments = 32, int $rings = 16, float $radius = 1.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + for ($j = 0; $j < $rings; $j++) { + for ($i = 0; $i < $segments; $i++) { + foreach ([[$j, $i], [$j + 1, $i], [$j + 1, $i + 1], [$j, $i], [$j + 1, $i + 1], [$j, $i + 1]] as [$rr, $ss]) { + $theta = $rr * M_PI / $rings; + $phi = $ss * 2.0 * M_PI / $segments; + $x = sin($theta) * cos($phi); + $y = cos($theta); + $z = sin($theta) * sin($phi); + $buffer->push($x * $radius); $buffer->push($y * $radius); $buffer->push($z * $radius); + $buffer->push($x); $buffer->push($y); $buffer->push($z); + $buffer->push($ss / $segments); $buffer->push($rr / $rings); + $buffer->push(-sin($phi)); $buffer->push(0.0); $buffer->push(cos($phi)); $buffer->push(1.0); + } + } + } + return $buffer; +} + +function genCubePS(float $size = 1.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + $faces = [ + [[-$h,-$h,$h], [$h,-$h,$h], [$h,$h,$h], [-$h,-$h,$h], [$h,$h,$h], [-$h,$h,$h], [0,0,1], [1,0,0]], + [[$h,-$h,-$h], [-$h,-$h,-$h], [-$h,$h,-$h], [$h,-$h,-$h], [-$h,$h,-$h], [$h,$h,-$h], [0,0,-1], [-1,0,0]], + [[$h,-$h,$h], [$h,-$h,-$h], [$h,$h,-$h], [$h,-$h,$h], [$h,$h,-$h], [$h,$h,$h], [1,0,0], [0,0,-1]], + [[-$h,-$h,-$h], [-$h,-$h,$h], [-$h,$h,$h], [-$h,-$h,-$h], [-$h,$h,$h], [-$h,$h,-$h], [-1,0,0], [0,0,1]], + [[-$h,$h,$h], [$h,$h,$h], [$h,$h,-$h], [-$h,$h,$h], [$h,$h,-$h], [-$h,$h,-$h], [0,1,0], [1,0,0]], + [[-$h,-$h,-$h], [$h,-$h,-$h], [$h,-$h,$h], [-$h,-$h,-$h], [$h,-$h,$h], [-$h,-$h,$h], [0,-1,0], [1,0,0]], + ]; + foreach ($faces as $face) { + $n = $face[6]; $t = $face[7]; + for ($i = 0; $i < 6; $i++) { + $p = $face[$i]; + $buffer->push($p[0]); $buffer->push($p[1]); $buffer->push($p[2]); + $buffer->push($n[0]); $buffer->push($n[1]); $buffer->push($n[2]); + $buffer->push(0.0); $buffer->push(0.0); + $buffer->push($t[0]); $buffer->push($t[1]); $buffer->push($t[2]); $buffer->push(1.0); + } + } + return $buffer; +} + +function genPlanePS(float $size = 20.0): FloatBuffer +{ + $buffer = new FloatBuffer(); + $h = $size * 0.5; + $verts = [[-$h, 0, -$h], [$h, 0, -$h], [$h, 0, $h], [-$h, 0, -$h], [$h, 0, $h], [-$h, 0, $h]]; + foreach ($verts as $p) { + $buffer->push($p[0]); $buffer->push($p[1]); $buffer->push($p[2]); + $buffer->push(0); $buffer->push(1); $buffer->push(0); + $buffer->push(($p[0] + $h) / $size); $buffer->push(($p[2] + $h) / $size); + $buffer->push(1); $buffer->push(0); $buffer->push(0); $buffer->push(1); + } + return $buffer; +} + +function genCylinderPS(float $radius = 0.3, float $height = 3.0, int $segments = 24): FloatBuffer +{ + $buffer = new FloatBuffer(); + $halfH = $height * 0.5; + for ($i = 0; $i < $segments; $i++) { + $a0 = ($i / $segments) * M_PI * 2; + $a1 = (($i + 1) / $segments) * M_PI * 2; + $x0 = cos($a0) * $radius; $z0 = sin($a0) * $radius; + $x1 = cos($a1) * $radius; $z1 = sin($a1) * $radius; + $nx0 = cos($a0); $nz0 = sin($a0); + $nx1 = cos($a1); $nz1 = sin($a1); + + // Two triangles per segment (side wall) + $verts = [ + [$x0, -$halfH, $z0, $nx0, 0, $nz0], + [$x1, -$halfH, $z1, $nx1, 0, $nz1], + [$x1, $halfH, $z1, $nx1, 0, $nz1], + [$x0, -$halfH, $z0, $nx0, 0, $nz0], + [$x1, $halfH, $z1, $nx1, 0, $nz1], + [$x0, $halfH, $z0, $nx0, 0, $nz0], + ]; + foreach ($verts as $v) { + $buffer->push($v[0]); $buffer->push($v[1]); $buffer->push($v[2]); + $buffer->push($v[3]); $buffer->push($v[4]); $buffer->push($v[5]); + $buffer->push(0.0); $buffer->push(0.0); + $buffer->push(0.0); $buffer->push(1.0); $buffer->push(0.0); $buffer->push(1.0); + } + } + return $buffer; +} + +// ── App setup ──────────────────────────────────────────────────────── + +$quickstart = new Quickstart(function (QuickstartOptions $app) use (&$state, $container) { + $app->container = $container; + $app->windowTitle = 'VISU — Point Light Cubemap Shadows Demo'; + + $app->ready = function (QuickstartApp $app) use (&$state) { + $state->models = new ModelCollection(); + $state->renderingSystem = new Rendering3DSystem($app->gl, $app->shaders, $state->models); + $state->renderingSystem->pointShadowsEnabled = true; + $state->renderingSystem->pointShadowResolution = $state->shadowResolution; + $state->renderingSystem->shadowsEnabled = true; + $state->cameraSystem = new VISUCameraSystem($app->input, $app->dispatcher); + + $app->bindSystems([$state->renderingSystem, $state->cameraSystem]); + + // -- Models -- + + // Floor + $floorMat = new Material('floor', new Vec4(0.5, 0.5, 0.55, 1.0), 0.0, 0.85); + $floorMesh = new Mesh3D($app->gl, $floorMat, new AABB(new Vec3(-15, -0.01, -15), new Vec3(15, 0.01, 15))); + $floorMesh->uploadVertices(genPlanePS(30.0)); + $floorModel = new Model3D('floor'); + $floorModel->addMesh($floorMesh); + $floorModel->recalculateAABB(); + $state->models->add($floorModel); + + // Central pillar (receives shadows nicely) + $pillarMat = new Material('pillar', new Vec4(0.8, 0.75, 0.7, 1.0), 0.0, 0.6); + $pillarMesh = new Mesh3D($app->gl, $pillarMat, new AABB(new Vec3(-0.3, -1.5, -0.3), new Vec3(0.3, 1.5, 0.3))); + $pillarMesh->uploadVertices(genCylinderPS(0.3, 3.0)); + $pillarModel = new Model3D('pillar'); + $pillarModel->addMesh($pillarMesh); + $pillarModel->recalculateAABB(); + $state->models->add($pillarModel); + + // Cube (shadow caster + receiver) + $cubeMat = new Material('cube', new Vec4(0.9, 0.3, 0.2, 1.0), 0.0, 0.5); + $cubeMesh = new Mesh3D($app->gl, $cubeMat, new AABB(new Vec3(-0.5, -0.5, -0.5), new Vec3(0.5, 0.5, 0.5))); + $cubeMesh->uploadVertices(genCubePS(1.0)); + $cubeModel = new Model3D('cube'); + $cubeModel->addMesh($cubeMesh); + $cubeModel->recalculateAABB(); + $state->models->add($cubeModel); + + // Sphere + $sphereMat = new Material('sphere', new Vec4(0.2, 0.5, 0.9, 1.0), 0.3, 0.3); + $sphereMesh = new Mesh3D($app->gl, $sphereMat, new AABB(new Vec3(-0.6, -0.6, -0.6), new Vec3(0.6, 0.6, 0.6))); + $sphereMesh->uploadVertices(genSpherePS(32, 16, 0.6)); + $sphereModel = new Model3D('sphere'); + $sphereModel->addMesh($sphereMesh); + $sphereModel->recalculateAABB(); + $state->models->add($sphereModel); + + // Wall (large shadow receiver behind objects) + $wallMat = new Material('wall', new Vec4(0.6, 0.6, 0.65, 1.0), 0.0, 0.9); + $wallMesh = new Mesh3D($app->gl, $wallMat, new AABB(new Vec3(-0.1, -3, -5), new Vec3(0.1, 3, 5))); + $wallMesh->uploadVertices(genCubePS(1.0)); + $wallModel = new Model3D('wall'); + $wallModel->addMesh($wallMesh); + $wallModel->recalculateAABB(); + $state->models->add($wallModel); + + // Small emissive marker for light positions + $markerMat = new Material('marker', new Vec4(1, 1, 1, 1), 0.0, 1.0); + $markerMat->emissiveColor = new Vec3(5.0, 5.0, 5.0); + $markerMesh = new Mesh3D($app->gl, $markerMat, new AABB(new Vec3(-0.08, -0.08, -0.08), new Vec3(0.08, 0.08, 0.08))); + $markerMesh->uploadVertices(genSpherePS(8, 4, 0.08)); + $markerModel = new Model3D('light_marker'); + $markerModel->addMesh($markerMesh); + $markerModel->recalculateAABB(); + $state->models->add($markerModel); + }; + + $app->initializeScene = function (QuickstartApp $app) use (&$state) { + // Camera + $state->cameraSystem->spawnDefaultFlyingCamera($app->entities, new Vec3(0.0, 5.0, 12.0)); + + // Dim sun (let point lights dominate) + $sun = $app->entities->getSingleton(DirectionalLightComponent::class); + $sun->direction = new Vec3(0.3, 1.0, 0.5); + $sun->direction->normalize(); + $sun->intensity = 0.15; + + // Floor + $floor = $app->entities->create(); + $app->entities->attach($floor, new MeshRendererComponent('floor')); + $app->entities->attach($floor, new Transform()); + + // Central pillar + $pillar = $app->entities->create(); + $app->entities->attach($pillar, new MeshRendererComponent('pillar')); + $t = $app->entities->attach($pillar, new Transform()); + $t->position->y = 1.5; + $t->markDirty(); + + // Surrounding cubes (shadow casters) + $cubePositions = [ + [-3.0, 0.5, -3.0], [3.0, 0.5, -3.0], + [-3.0, 0.5, 3.0], [3.0, 0.5, 3.0], + [0.0, 0.5, -5.0], [0.0, 0.5, 5.0], + ]; + foreach ($cubePositions as [$x, $y, $z]) { + $e = $app->entities->create(); + $app->entities->attach($e, new MeshRendererComponent('cube')); + $t = $app->entities->attach($e, new Transform()); + $t->position->x = $x; $t->position->y = $y; $t->position->z = $z; + $t->markDirty(); + } + + // Spheres + $spherePositions = [[-2.0, 0.6, 0.0], [2.0, 0.6, 0.0], [0.0, 0.6, -2.0], [0.0, 0.6, 2.0]]; + foreach ($spherePositions as [$x, $y, $z]) { + $e = $app->entities->create(); + $app->entities->attach($e, new MeshRendererComponent('sphere')); + $t = $app->entities->attach($e, new Transform()); + $t->position->x = $x; $t->position->y = $y; $t->position->z = $z; + $t->markDirty(); + } + + // Back wall (receives projected shadows) + $wall = $app->entities->create(); + $app->entities->attach($wall, new MeshRendererComponent('wall')); + $t = $app->entities->attach($wall, new Transform()); + $t->position->z = -7.0; + $t->position->y = 3.0; + $t->scale = new Vec3(1.0, 6.0, 10.0); + $t->markDirty(); + + // ── Shadow-casting point lights (4 orbiting) ── + $shadowColors = [ + new Vec3(1.0, 0.4, 0.2), // warm orange + new Vec3(0.2, 0.6, 1.0), // cool blue + new Vec3(0.3, 1.0, 0.4), // green + new Vec3(1.0, 0.8, 0.3), // yellow + ]; + + for ($i = 0; $i < 4; $i++) { + $e = $app->entities->create(); + $light = new PointLightComponent($shadowColors[$i], 4.0, 18.0); + $light->castsShadows = true; + $light->setAttenuationFromRange(); + $app->entities->attach($e, $light); + $t = $app->entities->attach($e, new Transform()); + $t->position->y = 3.0; + $t->markDirty(); + $state->shadowLightEntities[] = $e; + + // Marker sphere + $marker = $app->entities->create(); + $markerMat = new Material("marker_{$i}", new Vec4(0.01, 0.01, 0.01, 1.0), 0.0, 1.0); + $markerMat->emissiveColor = $shadowColors[$i] * 8.0; + $markerRenderer = new MeshRendererComponent('light_marker'); + $markerRenderer->materialOverride = $markerMat; + $markerRenderer->castsShadows = false; + $app->entities->attach($marker, $markerRenderer); + $mt = $app->entities->attach($marker, new Transform()); + $mt->setParent($app->entities, $e); + } + + // ── Non-shadow fill lights (4 stationary, dimmer) ── + $fillPositions = [ + [5.0, 1.0, 5.0], [-5.0, 1.0, 5.0], + [5.0, 1.0, -5.0], [-5.0, 1.0, -5.0], + ]; + foreach ($fillPositions as $i => [$x, $y, $z]) { + $e = $app->entities->create(); + $light = new PointLightComponent(new Vec3(0.8, 0.8, 0.9), 1.0, 10.0); + // castsShadows stays false (fill lights) + $light->setAttenuationFromRange(); + $app->entities->attach($e, $light); + $t = $app->entities->attach($e, new Transform()); + $t->position->x = $x; $t->position->y = $y; $t->position->z = $z; + $t->markDirty(); + $state->fillLightEntities[] = $e; + } + }; + + $app->update = function (QuickstartApp $app) use (&$state) { + $app->updateSystem($state->cameraSystem); + + $time = glfwGetTime(); + + // Orbit shadow lights at different heights and speeds + for ($i = 0; $i < count($state->shadowLightEntities); $i++) { + $entity = $state->shadowLightEntities[$i]; + $light = $app->entities->get($entity, PointLightComponent::class); + + // Skip disabled lights + if (!$light->castsShadows && $light->intensity === 0.0) { + continue; + } + + $angle = ($i / 4.0) * M_PI * 2.0 + $time * (0.3 + $i * 0.1); + $radius = 4.0 + sin($time * 0.5 + $i) * 1.5; + $height = 2.5 + sin($time * 0.7 + $i * 1.5) * 1.5; + + $transform = $app->entities->get($entity, Transform::class); + $transform->position->x = cos($angle) * $radius; + $transform->position->z = sin($angle) * $radius; + $transform->position->y = $height; + $transform->markDirty(); + } + + // Toggle individual shadow lights with keys 1-4 + for ($i = 0; $i < 4; $i++) { + if ($app->input->isKeyPressed(GLFW_KEY_1 + $i)) { + $entity = $state->shadowLightEntities[$i]; + $light = $app->entities->get($entity, PointLightComponent::class); + $light->castsShadows = !$light->castsShadows; + } + } + + // Toggle point shadows globally with P + if ($app->input->isKeyPressed(GLFW_KEY_P)) { + $state->renderingSystem->pointShadowsEnabled = !$state->renderingSystem->pointShadowsEnabled; + } + + // Toggle sun shadows with T + if ($app->input->isKeyPressed(GLFW_KEY_T)) { + $state->renderingSystem->shadowsEnabled = !$state->renderingSystem->shadowsEnabled; + } + + // Cycle shadow resolution with R + if ($app->input->isKeyPressed(GLFW_KEY_R)) { + $resolutions = [256, 512, 1024]; + $idx = array_search($state->shadowResolution, $resolutions); + $state->shadowResolution = $resolutions[($idx + 1) % count($resolutions)]; + $state->renderingSystem->pointShadowResolution = $state->shadowResolution; + } + }; + + $app->render = function (QuickstartApp $app, RenderContext $context, RenderTargetResource $target) use (&$state) { + $state->renderingSystem->setRenderTarget($target); + $app->renderSystem($state->cameraSystem, $context); + $app->renderSystem($state->renderingSystem, $context); + }; +}); + +$quickstart->run(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c6779ea --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "visu", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/phpstan.neon b/phpstan.neon index 97cae0e..067b702 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,7 +7,9 @@ parameters: - src/System/VISUTransitionAnimationSystem.php paths: - src + reportUnmatchedIgnoredErrors: false ignoreErrors: + # php-glfw math types (property access, operator overloading) - '/Access to an undefined property GL\\Math\\Vec[2-4]::\$[x|y|z|w|r|g|b|a]\./' - '/Access to an undefined property GL\\Math\\Quat::\$[w|x|y|z]\./' - '/Cannot access property \$([a-zA-Z0-9]+) on VISU\\Graphics\\TextureOptions\|null\./' @@ -17,8 +19,23 @@ parameters: - '/Cannot access offset [0-9|int]+ on GL\\Math\\Mat4\./' - '/\(VISU\\Signal\\SignalQueue<.*>\) does not accept VISU\\Signal\\SignalQueue/' - '/expects +VISU\\Signal\\SignalQueue, VISU\\Signal\\SignalQueue<.*>/' - - '/.*expects GL\\Buffer\\GL\\.*/' + - '/.*expects GL\\Buffer\\GL\\.*/' - '/Parameter .* expects GL\\Math\\Vec[0-4], \((array\|)?float\|int\) given\./' - '/constructor expects GL\\Math\\Vec[0-4], float given\./' - '/.*[copy]\(\) on \(float\|int\).*/' - - '/should return GL\\Math\\Vec[0-4] but returns float\./' \ No newline at end of file + - '/should return GL\\Math\\Vec[0-4] but returns float\./' + # php-glfw NanoVG (VGContext methods not visible to PHPStan) + - '/Call to an undefined (static )?method GL\\VectorGraphics\\VGContext::/' + - '/Cannot call method .* on GL\\VectorGraphics\\VGContext\|null\./' + # FFI — PHPStan cannot statically analyze FFI method calls and CData access + - '/Call to an undefined method FFI::/' + - '/Access to an undefined property FFI\\CData::\$\w+/' + - '/Cannot access property \$\w+ on FFI.CData\|null/' + - '/Parameter .* expects FFI.CData, FFI.CData\|null given/' + - '/Cannot cast .* to int\.$/' + - '/Offset .* does not exist on FFI.CData\|null/' + - '/Offset .* on array\|false/' + - '/Binary operation .* between .* FFI.CData/' + - '/does not accept array{for(const o of l)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&s(r)}).observe(document,{childList:!0,subtree:!0});function n(l){const o={};return l.integrity&&(o.integrity=l.integrity),l.referrerPolicy&&(o.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?o.credentials="include":l.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(l){if(l.ep)return;l.ep=!0;const o=n(l);fetch(l.href,o)}})();/** +* @vue/shared v3.5.29 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Ss(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const ce={},Ft=[],Ge=()=>{},kl=()=>!1,Fn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),Cs=e=>e.startsWith("onUpdate:"),Ce=Object.assign,ks=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Ho=Object.prototype.hasOwnProperty,oe=(e,t)=>Ho.call(e,t),V=Array.isArray,jt=e=>dn(e)==="[object Map]",jn=e=>dn(e)==="[object Set]",Ks=e=>dn(e)==="[object Date]",Q=e=>typeof e=="function",_e=e=>typeof e=="string",Xe=e=>typeof e=="symbol",ue=e=>e!==null&&typeof e=="object",El=e=>(ue(e)||Q(e))&&Q(e.then)&&Q(e.catch),Tl=Object.prototype.toString,dn=e=>Tl.call(e),Ko=e=>dn(e).slice(8,-1),Il=e=>dn(e)==="[object Object]",Wn=e=>_e(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Xt=Ss(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Hn=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Uo=/-\w/g,_t=Hn(e=>e.replace(Uo,t=>t.slice(1).toUpperCase())),Bo=/\B([A-Z])/g,xt=Hn(e=>e.replace(Bo,"-$1").toLowerCase()),$l=Hn(e=>e.charAt(0).toUpperCase()+e.slice(1)),Qn=Hn(e=>e?`on${$l(e)}`:""),mt=(e,t)=>!Object.is(e,t),wn=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},Kn=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let Us;const Un=()=>Us||(Us=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Es(e){if(V(e)){const t={};for(let n=0;n{if(n){const s=n.split(zo);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function Oe(e){let t="";if(_e(e))t=e;else if(V(e))for(let n=0;npn(n,t))}const Al=e=>!!(e&&e.__v_isRef===!0),ne=e=>_e(e)?e:e==null?"":V(e)||ue(e)&&(e.toString===Tl||!Q(e.toString))?Al(e)?ne(e.value):JSON.stringify(e,Dl,2):String(e),Dl=(e,t)=>Al(t)?Dl(e,t.value):jt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,l],o)=>(n[es(s,o)+" =>"]=l,n),{})}:jn(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>es(n))}:Xe(t)?es(t):ue(t)&&!V(t)&&!Il(t)?String(t):t,es=(e,t="")=>{var n;return Xe(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.29 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Ee;class Ml{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=Ee,!t&&Ee&&(this.index=(Ee.scopes||(Ee.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0&&(Ee=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if(Qt){let t=Qt;for(Qt=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Zt;){let t=Zt;for(Zt=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function Wl(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Hl(e){let t,n=e.depsTail,s=n;for(;s;){const l=s.prevDep;s.version===-1?(s===n&&(n=l),$s(s),ei(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=l}e.deps=t,e.depsTail=n}function fs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Kl(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Kl(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===rn)||(e.globalVersion=rn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!fs(e))))return;e.flags|=2;const t=e.dep,n=pe,s=He;pe=e,He=!0;try{Wl(e);const l=e.fn(e._value);(t.version===0||mt(l,e._value))&&(e.flags|=128,e._value=l,t.version++)}catch(l){throw t.version++,l}finally{pe=n,He=s,Hl(e),e.flags&=-3}}function $s(e,t=!1){const{dep:n,prevSub:s,nextSub:l}=e;if(s&&(s.nextSub=l,e.prevSub=void 0),l&&(l.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let o=n.computed.deps;o;o=o.nextDep)$s(o,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function ei(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let He=!0;const Ul=[];function ut(){Ul.push(He),He=!1}function at(){const e=Ul.pop();He=e===void 0?!0:e}function Bs(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=pe;pe=void 0;try{t()}finally{pe=n}}}let rn=0;class ti{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Ps{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!pe||!He||pe===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==pe)n=this.activeLink=new ti(pe,this),pe.deps?(n.prevDep=pe.depsTail,pe.depsTail.nextDep=n,pe.depsTail=n):pe.deps=pe.depsTail=n,Bl(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=pe.depsTail,n.nextDep=void 0,pe.depsTail.nextDep=n,pe.depsTail=n,pe.deps===n&&(pe.deps=s)}return n}trigger(t){this.version++,rn++,this.notify(t)}notify(t){Ts();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{Is()}}}function Bl(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)Bl(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const Cn=new WeakMap,Pt=Symbol(""),ds=Symbol(""),un=Symbol("");function Te(e,t,n){if(He&&pe){let s=Cn.get(e);s||Cn.set(e,s=new Map);let l=s.get(n);l||(s.set(n,l=new Ps),l.map=s,l.key=n),l.track()}}function lt(e,t,n,s,l,o){const r=Cn.get(e);if(!r){rn++;return}const a=p=>{p&&p.trigger()};if(Ts(),t==="clear")r.forEach(a);else{const p=V(e),h=p&&Wn(n);if(p&&n==="length"){const f=Number(s);r.forEach((y,S)=>{(S==="length"||S===un||!Xe(S)&&S>=f)&&a(y)})}else switch((n!==void 0||r.has(void 0))&&a(r.get(n)),h&&a(r.get(un)),t){case"add":p?h&&a(r.get("length")):(a(r.get(Pt)),jt(e)&&a(r.get(ds)));break;case"delete":p||(a(r.get(Pt)),jt(e)&&a(r.get(ds)));break;case"set":jt(e)&&a(r.get(Pt));break}}Is()}function ni(e,t){const n=Cn.get(e);return n&&n.get(t)}function Mt(e){const t=se(e);return t===e?t:(Te(t,"iterate",un),Ne(e)?t:t.map(Ke))}function Bn(e){return Te(e=se(e),"iterate",un),e}function yt(e,t){return ct(e)?Kt(it(e)?Ke(t):t):Ke(t)}const si={__proto__:null,[Symbol.iterator](){return ns(this,Symbol.iterator,e=>yt(this,e))},concat(...e){return Mt(this).concat(...e.map(t=>V(t)?Mt(t):t))},entries(){return ns(this,"entries",e=>(e[1]=yt(this,e[1]),e))},every(e,t){return tt(this,"every",e,t,void 0,arguments)},filter(e,t){return tt(this,"filter",e,t,n=>n.map(s=>yt(this,s)),arguments)},find(e,t){return tt(this,"find",e,t,n=>yt(this,n),arguments)},findIndex(e,t){return tt(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return tt(this,"findLast",e,t,n=>yt(this,n),arguments)},findLastIndex(e,t){return tt(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return tt(this,"forEach",e,t,void 0,arguments)},includes(...e){return ss(this,"includes",e)},indexOf(...e){return ss(this,"indexOf",e)},join(e){return Mt(this).join(e)},lastIndexOf(...e){return ss(this,"lastIndexOf",e)},map(e,t){return tt(this,"map",e,t,void 0,arguments)},pop(){return Jt(this,"pop")},push(...e){return Jt(this,"push",e)},reduce(e,...t){return Vs(this,"reduce",e,t)},reduceRight(e,...t){return Vs(this,"reduceRight",e,t)},shift(){return Jt(this,"shift")},some(e,t){return tt(this,"some",e,t,void 0,arguments)},splice(...e){return Jt(this,"splice",e)},toReversed(){return Mt(this).toReversed()},toSorted(e){return Mt(this).toSorted(e)},toSpliced(...e){return Mt(this).toSpliced(...e)},unshift(...e){return Jt(this,"unshift",e)},values(){return ns(this,"values",e=>yt(this,e))}};function ns(e,t,n){const s=Bn(e),l=s[t]();return s!==e&&!Ne(e)&&(l._next=l.next,l.next=()=>{const o=l._next();return o.done||(o.value=n(o.value)),o}),l}const li=Array.prototype;function tt(e,t,n,s,l,o){const r=Bn(e),a=r!==e&&!Ne(e),p=r[t];if(p!==li[t]){const y=p.apply(e,o);return a?Ke(y):y}let h=n;r!==e&&(a?h=function(y,S){return n.call(this,yt(e,y),S,e)}:n.length>2&&(h=function(y,S){return n.call(this,y,S,e)}));const f=p.call(r,h,s);return a&&l?l(f):f}function Vs(e,t,n,s){const l=Bn(e);let o=n;return l!==e&&(Ne(e)?n.length>3&&(o=function(r,a,p){return n.call(this,r,a,p,e)}):o=function(r,a,p){return n.call(this,r,yt(e,a),p,e)}),l[t](o,...s)}function ss(e,t,n){const s=se(e);Te(s,"iterate",un);const l=s[t](...n);return(l===-1||l===!1)&&Vn(n[0])?(n[0]=se(n[0]),s[t](...n)):l}function Jt(e,t,n=[]){ut(),Ts();const s=se(e)[t].apply(e,n);return Is(),at(),s}const oi=Ss("__proto__,__v_isRef,__isVue"),Vl=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Xe));function ii(e){Xe(e)||(e=String(e));const t=se(this);return Te(t,"has",e),t.hasOwnProperty(e)}class zl{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const l=this._isReadonly,o=this._isShallow;if(n==="__v_isReactive")return!l;if(n==="__v_isReadonly")return l;if(n==="__v_isShallow")return o;if(n==="__v_raw")return s===(l?o?yi:Gl:o?Yl:ql).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const r=V(t);if(!l){let p;if(r&&(p=si[n]))return p;if(n==="hasOwnProperty")return ii}const a=Reflect.get(t,n,ge(t)?t:s);if((Xe(n)?Vl.has(n):oi(n))||(l||Te(t,"get",n),o))return a;if(ge(a)){const p=r&&Wn(n)?a:a.value;return l&&ue(p)?hs(p):p}return ue(a)?l?hs(a):hn(a):a}}class Jl extends zl{constructor(t=!1){super(!1,t)}set(t,n,s,l){let o=t[n];const r=V(t)&&Wn(n);if(!this._isShallow){const h=ct(o);if(!Ne(s)&&!ct(s)&&(o=se(o),s=se(s)),!r&&ge(o)&&!ge(s))return h||(o.value=s),!0}const a=r?Number(n)e,mn=e=>Reflect.getPrototypeOf(e);function fi(e,t,n){return function(...s){const l=this.__v_raw,o=se(l),r=jt(o),a=e==="entries"||e===Symbol.iterator&&r,p=e==="keys"&&r,h=l[e](...s),f=n?ps:t?Kt:Ke;return!t&&Te(o,"iterate",p?ds:Pt),Ce(Object.create(h),{next(){const{value:y,done:S}=h.next();return S?{value:y,done:S}:{value:a?[f(y[0]),f(y[1])]:f(y),done:S}}})}}function bn(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function di(e,t){const n={get(l){const o=this.__v_raw,r=se(o),a=se(l);e||(mt(l,a)&&Te(r,"get",l),Te(r,"get",a));const{has:p}=mn(r),h=t?ps:e?Kt:Ke;if(p.call(r,l))return h(o.get(l));if(p.call(r,a))return h(o.get(a));o!==r&&o.get(l)},get size(){const l=this.__v_raw;return!e&&Te(se(l),"iterate",Pt),l.size},has(l){const o=this.__v_raw,r=se(o),a=se(l);return e||(mt(l,a)&&Te(r,"has",l),Te(r,"has",a)),l===a?o.has(l):o.has(l)||o.has(a)},forEach(l,o){const r=this,a=r.__v_raw,p=se(a),h=t?ps:e?Kt:Ke;return!e&&Te(p,"iterate",Pt),a.forEach((f,y)=>l.call(o,h(f),h(y),r))}};return Ce(n,e?{add:bn("add"),set:bn("set"),delete:bn("delete"),clear:bn("clear")}:{add(l){!t&&!Ne(l)&&!ct(l)&&(l=se(l));const o=se(this);return mn(o).has.call(o,l)||(o.add(l),lt(o,"add",l,l)),this},set(l,o){!t&&!Ne(o)&&!ct(o)&&(o=se(o));const r=se(this),{has:a,get:p}=mn(r);let h=a.call(r,l);h||(l=se(l),h=a.call(r,l));const f=p.call(r,l);return r.set(l,o),h?mt(o,f)&<(r,"set",l,o):lt(r,"add",l,o),this},delete(l){const o=se(this),{has:r,get:a}=mn(o);let p=r.call(o,l);p||(l=se(l),p=r.call(o,l)),a&&a.call(o,l);const h=o.delete(l);return p&<(o,"delete",l,void 0),h},clear(){const l=se(this),o=l.size!==0,r=l.clear();return o&<(l,"clear",void 0,void 0),r}}),["keys","values","entries",Symbol.iterator].forEach(l=>{n[l]=fi(l,e,t)}),n}function Os(e,t){const n=di(e,t);return(s,l,o)=>l==="__v_isReactive"?!e:l==="__v_isReadonly"?e:l==="__v_raw"?s:Reflect.get(oe(n,l)&&l in s?n:s,l,o)}const pi={get:Os(!1,!1)},hi={get:Os(!1,!0)},vi={get:Os(!0,!1)};const ql=new WeakMap,Yl=new WeakMap,Gl=new WeakMap,yi=new WeakMap;function gi(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function mi(e){return e.__v_skip||!Object.isExtensible(e)?0:gi(Ko(e))}function hn(e){return ct(e)?e:As(e,!1,ui,pi,ql)}function bi(e){return As(e,!1,ci,hi,Yl)}function hs(e){return As(e,!0,ai,vi,Gl)}function As(e,t,n,s,l){if(!ue(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const o=mi(e);if(o===0)return e;const r=l.get(e);if(r)return r;const a=new Proxy(e,o===2?s:n);return l.set(e,a),a}function it(e){return ct(e)?it(e.__v_raw):!!(e&&e.__v_isReactive)}function ct(e){return!!(e&&e.__v_isReadonly)}function Ne(e){return!!(e&&e.__v_isShallow)}function Vn(e){return e?!!e.__v_raw:!1}function se(e){const t=e&&e.__v_raw;return t?se(t):e}function Ds(e){return!oe(e,"__v_skip")&&Object.isExtensible(e)&&Pl(e,"__v_skip",!0),e}const Ke=e=>ue(e)?hn(e):e,Kt=e=>ue(e)?hs(e):e;function ge(e){return e?e.__v_isRef===!0:!1}function Z(e){return _i(e,!1)}function _i(e,t){return ge(e)?e:new wi(e,t)}class wi{constructor(t,n){this.dep=new Ps,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:se(t),this._value=n?t:Ke(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Ne(t)||ct(t);t=s?t:se(t),mt(t,n)&&(this._rawValue=t,this._value=s?t:Ke(t),this.dep.trigger())}}function A(e){return ge(e)?e.value:e}const xi={get:(e,t,n)=>t==="__v_raw"?e:A(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const l=e[t];return ge(l)&&!ge(n)?(l.value=n,!0):Reflect.set(e,t,n,s)}};function Xl(e){return it(e)?e:new Proxy(e,xi)}function Si(e){const t=V(e)?new Array(e.length):{};for(const n in e)t[n]=ki(e,n);return t}class Ci{constructor(t,n,s){this._object=t,this._key=n,this._defaultValue=s,this.__v_isRef=!0,this._value=void 0,this._raw=se(t);let l=!0,o=t;if(!V(t)||!Wn(String(n)))do l=!Vn(o)||Ne(o);while(l&&(o=o.__v_raw));this._shallow=l}get value(){let t=this._object[this._key];return this._shallow&&(t=A(t)),this._value=t===void 0?this._defaultValue:t}set value(t){if(this._shallow&&ge(this._raw[this._key])){const n=this._object[this._key];if(ge(n)){n.value=t;return}}this._object[this._key]=t}get dep(){return ni(this._raw,this._key)}}function ki(e,t,n){return new Ci(e,t,n)}class Ei{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new Ps(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=rn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&pe!==this)return jl(this,!0),!0}get value(){const t=this.dep.track();return Kl(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Ti(e,t,n=!1){let s,l;return Q(e)?s=e:(s=e.get,l=e.set),new Ei(s,l,n)}const _n={},kn=new WeakMap;let It;function Ii(e,t=!1,n=It){if(n){let s=kn.get(n);s||kn.set(n,s=[]),s.push(e)}}function $i(e,t,n=ce){const{immediate:s,deep:l,once:o,scheduler:r,augmentJob:a,call:p}=n,h=F=>l?F:Ne(F)||l===!1||l===0?ot(F,1):ot(F);let f,y,S,$,D=!1,m=!1;if(ge(e)?(y=()=>e.value,D=Ne(e)):it(e)?(y=()=>h(e),D=!0):V(e)?(m=!0,D=e.some(F=>it(F)||Ne(F)),y=()=>e.map(F=>{if(ge(F))return F.value;if(it(F))return h(F);if(Q(F))return p?p(F,2):F()})):Q(e)?t?y=p?()=>p(e,2):e:y=()=>{if(S){ut();try{S()}finally{at()}}const F=It;It=f;try{return p?p(e,3,[$]):e($)}finally{It=F}}:y=Ge,t&&l){const F=y,K=l===!0?1/0:l;y=()=>ot(F(),K)}const q=Ll(),j=()=>{f.stop(),q&&q.active&&ks(q.effects,f)};if(o&&t){const F=t;t=(...K)=>{F(...K),j()}}let N=m?new Array(e.length).fill(_n):_n;const B=F=>{if(!(!(f.flags&1)||!f.dirty&&!F))if(t){const K=f.run();if(l||D||(m?K.some((ae,le)=>mt(ae,N[le])):mt(K,N))){S&&S();const ae=It;It=f;try{const le=[K,N===_n?void 0:m&&N[0]===_n?[]:N,$];N=K,p?p(t,3,le):t(...le)}finally{It=ae}}}else f.run()};return a&&a(B),f=new Nl(y),f.scheduler=r?()=>r(B,!1):B,$=F=>Ii(F,!1,f),S=f.onStop=()=>{const F=kn.get(f);if(F){if(p)p(F,4);else for(const K of F)K();kn.delete(f)}},t?s?B(!0):N=f.run():r?r(B.bind(null,!0),!0):f.run(),j.pause=f.pause.bind(f),j.resume=f.resume.bind(f),j.stop=j,j}function ot(e,t=1/0,n){if(t<=0||!ue(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,ge(e))ot(e.value,t,n);else if(V(e))for(let s=0;s{ot(s,t,n)});else if(Il(e)){for(const s in e)ot(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&ot(e[s],t,n)}return e}/** +* @vue/runtime-core v3.5.29 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function vn(e,t,n,s){try{return s?e(...s):e()}catch(l){zn(l,t,n)}}function Ze(e,t,n,s){if(Q(e)){const l=vn(e,t,n,s);return l&&El(l)&&l.catch(o=>{zn(o,t,n)}),l}if(V(e)){const l=[];for(let o=0;o>>1,l=$e[s],o=an(l);o=an(n)?$e.push(e):$e.splice(Oi(t),0,e),e.flags|=1,Ql()}}function Ql(){En||(En=Zl.then(to))}function Ai(e){V(e)?Wt.push(...e):gt&&e.id===-1?gt.splice(Lt+1,0,e):e.flags&1||(Wt.push(e),e.flags|=1),Ql()}function zs(e,t,n=Je+1){for(;n<$e.length;n++){const s=$e[n];if(s&&s.flags&2){if(e&&s.id!==e.uid)continue;$e.splice(n,1),n--,s.flags&4&&(s.flags&=-2),s(),s.flags&4||(s.flags&=-2)}}}function eo(e){if(Wt.length){const t=[...new Set(Wt)].sort((n,s)=>an(n)-an(s));if(Wt.length=0,gt){gt.push(...t);return}for(gt=t,Lt=0;Lte.id==null?e.flags&2?-1:1/0:e.id;function to(e){try{for(Je=0;Je<$e.length;Je++){const t=$e[Je];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),vn(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Je<$e.length;Je++){const t=$e[Je];t&&(t.flags&=-2)}Je=-1,$e.length=0,eo(),En=null,($e.length||Wt.length)&&to()}}let Fe=null,no=null;function Tn(e){const t=Fe;return Fe=e,no=e&&e.type.__scopeId||null,t}function Di(e,t=Fe,n){if(!t||e._n)return e;const s=(...l)=>{s._d&&Pn(-1);const o=Tn(t);let r;try{r=e(...l)}finally{Tn(o),s._d&&Pn(1)}return r};return s._n=!0,s._c=!0,s._d=!0,s}function Ut(e,t){if(Fe===null)return e;const n=Xn(Fe),s=e.dirs||(e.dirs=[]);for(let l=0;l1)return n&&Q(t)?t.call(s&&s.proxy):t}}function Ri(){return!!(Io()||Ot)}const Li=Symbol.for("v-scx"),Ni=()=>en(Li);function bt(e,t,n){return so(e,t,n)}function so(e,t,n=ce){const{immediate:s,deep:l,flush:o,once:r}=n,a=Ce({},n),p=t&&s||!t&&o!=="post";let h;if(fn){if(o==="sync"){const $=Ni();h=$.__watcherHandles||($.__watcherHandles=[])}else if(!p){const $=()=>{};return $.stop=Ge,$.resume=Ge,$.pause=Ge,$}}const f=Pe;a.call=($,D,m)=>Ze($,f,D,m);let y=!1;o==="post"?a.scheduler=$=>{Me($,f&&f.suspense)}:o!=="sync"&&(y=!0,a.scheduler=($,D)=>{D?$():Ms($)}),a.augmentJob=$=>{t&&($.flags|=4),y&&($.flags|=2,f&&($.id=f.uid,$.i=f))};const S=$i(e,t,a);return fn&&(h?h.push(S):p&&S()),S}function Fi(e,t,n){const s=this.proxy,l=_e(e)?e.includes(".")?lo(s,e):()=>s[e]:e.bind(s,s);let o;Q(t)?o=t:(o=t.handler,n=t);const r=gn(this),a=so(l,o.bind(s),n);return r(),a}function lo(e,t){const n=t.split(".");return()=>{let s=e;for(let l=0;le.__isTeleport,Hi=Symbol("_leaveCb");function Rs(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Rs(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Js(e,t){return Q(e)?Ce({name:e.name},t,{setup:e}):e}function oo(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function qs(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}const In=new WeakMap;function tn(e,t,n,s,l=!1){if(V(e)){e.forEach((m,q)=>tn(m,t&&(V(t)?t[q]:t),n,s,l));return}if(nn(s)&&!l){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&tn(e,t,n,s.component.subTree);return}const o=s.shapeFlag&4?Xn(s.component):s.el,r=l?null:o,{i:a,r:p}=e,h=t&&t.r,f=a.refs===ce?a.refs={}:a.refs,y=a.setupState,S=se(y),$=y===ce?kl:m=>qs(f,m)?!1:oe(S,m),D=(m,q)=>!(q&&qs(f,q));if(h!=null&&h!==p){if(Ys(t),_e(h))f[h]=null,$(h)&&(y[h]=null);else if(ge(h)){const m=t;D(h,m.k)&&(h.value=null),m.k&&(f[m.k]=null)}}if(Q(p))vn(p,a,12,[r,f]);else{const m=_e(p),q=ge(p);if(m||q){const j=()=>{if(e.f){const N=m?$(p)?y[p]:f[p]:D()||!e.k?p.value:f[e.k];if(l)V(N)&&ks(N,o);else if(V(N))N.includes(o)||N.push(o);else if(m)f[p]=[o],$(p)&&(y[p]=f[p]);else{const B=[o];D(p,e.k)&&(p.value=B),e.k&&(f[e.k]=B)}}else m?(f[p]=r,$(p)&&(y[p]=r)):q&&(D(p,e.k)&&(p.value=r),e.k&&(f[e.k]=r))};if(r){const N=()=>{j(),In.delete(e)};N.id=-1,In.set(e,N),Me(N,n)}else Ys(e),j()}}}function Ys(e){const t=In.get(e);t&&(t.flags|=8,In.delete(e))}Un().requestIdleCallback;Un().cancelIdleCallback;const nn=e=>!!e.type.__asyncLoader,io=e=>e.type.__isKeepAlive;function Ki(e,t){ro(e,"a",t)}function Ui(e,t){ro(e,"da",t)}function ro(e,t,n=Pe){const s=e.__wdc||(e.__wdc=()=>{let l=n;for(;l;){if(l.isDeactivated)return;l=l.parent}return e()});if(Jn(t,s,n),n){let l=n.parent;for(;l&&l.parent;)io(l.parent.vnode)&&Bi(s,t,n,l),l=l.parent}}function Bi(e,t,n,s){const l=Jn(t,e,s,!0);qn(()=>{ks(s[t],l)},n)}function Jn(e,t,n=Pe,s=!1){if(n){const l=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...r)=>{ut();const a=gn(n),p=Ze(t,n,e,r);return a(),at(),p});return s?l.unshift(o):l.push(o),o}}const ft=e=>(t,n=Pe)=>{(!fn||e==="sp")&&Jn(e,(...s)=>t(...s),n)},Vi=ft("bm"),Vt=ft("m"),zi=ft("bu"),Ji=ft("u"),qi=ft("bum"),qn=ft("um"),Yi=ft("sp"),Gi=ft("rtg"),Xi=ft("rtc");function Zi(e,t=Pe){Jn("ec",e,t)}const Qi=Symbol.for("v-ndc");function je(e,t,n,s){let l;const o=n,r=V(e);if(r||_e(e)){const a=r&&it(e);let p=!1,h=!1;a&&(p=!Ne(e),h=ct(e),e=Bn(e)),l=new Array(e.length);for(let f=0,y=e.length;ft(a,p,void 0,o));else{const a=Object.keys(e);l=new Array(a.length);for(let p=0,h=a.length;pe?$o(e)?Xn(e):vs(e.parent):null,sn=Ce(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>vs(e.parent),$root:e=>vs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>ao(e),$forceUpdate:e=>e.f||(e.f=()=>{Ms(e.update)}),$nextTick:e=>e.n||(e.n=yn.bind(e.proxy)),$watch:e=>Fi.bind(e)}),ls=(e,t)=>e!==ce&&!e.__isScriptSetup&&oe(e,t),er={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:l,props:o,accessCache:r,type:a,appContext:p}=e;if(t[0]!=="$"){const S=r[t];if(S!==void 0)switch(S){case 1:return s[t];case 2:return l[t];case 4:return n[t];case 3:return o[t]}else{if(ls(s,t))return r[t]=1,s[t];if(l!==ce&&oe(l,t))return r[t]=2,l[t];if(oe(o,t))return r[t]=3,o[t];if(n!==ce&&oe(n,t))return r[t]=4,n[t];ys&&(r[t]=0)}}const h=sn[t];let f,y;if(h)return t==="$attrs"&&Te(e.attrs,"get",""),h(e);if((f=a.__cssModules)&&(f=f[t]))return f;if(n!==ce&&oe(n,t))return r[t]=4,n[t];if(y=p.config.globalProperties,oe(y,t))return y[t]},set({_:e},t,n){const{data:s,setupState:l,ctx:o}=e;return ls(l,t)?(l[t]=n,!0):s!==ce&&oe(s,t)?(s[t]=n,!0):oe(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:l,props:o,type:r}},a){let p;return!!(n[a]||e!==ce&&a[0]!=="$"&&oe(e,a)||ls(t,a)||oe(o,a)||oe(s,a)||oe(sn,a)||oe(l.config.globalProperties,a)||(p=r.__cssModules)&&p[a])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:oe(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Gs(e){return V(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let ys=!0;function tr(e){const t=ao(e),n=e.proxy,s=e.ctx;ys=!1,t.beforeCreate&&Xs(t.beforeCreate,e,"bc");const{data:l,computed:o,methods:r,watch:a,provide:p,inject:h,created:f,beforeMount:y,mounted:S,beforeUpdate:$,updated:D,activated:m,deactivated:q,beforeDestroy:j,beforeUnmount:N,destroyed:B,unmounted:F,render:K,renderTracked:ae,renderTriggered:le,errorCaptured:z,serverPrefetch:ee,expose:fe,inheritAttrs:we,components:Se,directives:_,filters:T}=t;if(h&&nr(h,s,null),r)for(const M in r){const H=r[M];Q(H)&&(s[M]=H.bind(n))}if(l){const M=l.call(n,n);ue(M)&&(e.data=hn(M))}if(ys=!0,o)for(const M in o){const H=o[M],ve=Q(H)?H.bind(n,n):Q(H.get)?H.get.bind(n,n):Ge,G=!Q(H)&&Q(H.set)?H.set.bind(n):Ge,ie=xe({get:ve,set:G});Object.defineProperty(s,M,{enumerable:!0,configurable:!0,get:()=>ie.value,set:ke=>ie.value=ke})}if(a)for(const M in a)uo(a[M],s,n,M);if(p){const M=Q(p)?p.call(n):p;Reflect.ownKeys(M).forEach(H=>{Mi(H,M[H])})}f&&Xs(f,e,"c");function L(M,H){V(H)?H.forEach(ve=>M(ve.bind(n))):H&&M(H.bind(n))}if(L(Vi,y),L(Vt,S),L(zi,$),L(Ji,D),L(Ki,m),L(Ui,q),L(Zi,z),L(Xi,ae),L(Gi,le),L(qi,N),L(qn,F),L(Yi,ee),V(fe))if(fe.length){const M=e.exposed||(e.exposed={});fe.forEach(H=>{Object.defineProperty(M,H,{get:()=>n[H],set:ve=>n[H]=ve,enumerable:!0})})}else e.exposed||(e.exposed={});K&&e.render===Ge&&(e.render=K),we!=null&&(e.inheritAttrs=we),Se&&(e.components=Se),_&&(e.directives=_),ee&&oo(e)}function nr(e,t,n=Ge){V(e)&&(e=gs(e));for(const s in e){const l=e[s];let o;ue(l)?"default"in l?o=en(l.from||s,l.default,!0):o=en(l.from||s):o=en(l),ge(o)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>o.value,set:r=>o.value=r}):t[s]=o}}function Xs(e,t,n){Ze(V(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function uo(e,t,n,s){let l=s.includes(".")?lo(n,s):()=>n[s];if(_e(e)){const o=t[e];Q(o)&&bt(l,o)}else if(Q(e))bt(l,e.bind(n));else if(ue(e))if(V(e))e.forEach(o=>uo(o,t,n,s));else{const o=Q(e.handler)?e.handler.bind(n):t[e.handler];Q(o)&&bt(l,o,e)}}function ao(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:l,optionsCache:o,config:{optionMergeStrategies:r}}=e.appContext,a=o.get(t);let p;return a?p=a:!l.length&&!n&&!s?p=t:(p={},l.length&&l.forEach(h=>$n(p,h,r,!0)),$n(p,t,r)),ue(t)&&o.set(t,p),p}function $n(e,t,n,s=!1){const{mixins:l,extends:o}=t;o&&$n(e,o,n,!0),l&&l.forEach(r=>$n(e,r,n,!0));for(const r in t)if(!(s&&r==="expose")){const a=sr[r]||n&&n[r];e[r]=a?a(e[r],t[r]):t[r]}return e}const sr={data:Zs,props:Qs,emits:Qs,methods:Gt,computed:Gt,beforeCreate:Ie,created:Ie,beforeMount:Ie,mounted:Ie,beforeUpdate:Ie,updated:Ie,beforeDestroy:Ie,beforeUnmount:Ie,destroyed:Ie,unmounted:Ie,activated:Ie,deactivated:Ie,errorCaptured:Ie,serverPrefetch:Ie,components:Gt,directives:Gt,watch:or,provide:Zs,inject:lr};function Zs(e,t){return t?e?function(){return Ce(Q(e)?e.call(this,this):e,Q(t)?t.call(this,this):t)}:t:e}function lr(e,t){return Gt(gs(e),gs(t))}function gs(e){if(V(e)){const t={};for(let n=0;nt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${_t(t)}Modifiers`]||e[`${xt(t)}Modifiers`];function ar(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||ce;let l=n;const o=t.startsWith("update:"),r=o&&ur(s,t.slice(7));r&&(r.trim&&(l=n.map(f=>_e(f)?f.trim():f)),r.number&&(l=n.map(Kn)));let a,p=s[a=Qn(t)]||s[a=Qn(_t(t))];!p&&o&&(p=s[a=Qn(xt(t))]),p&&Ze(p,e,6,l);const h=s[a+"Once"];if(h){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,Ze(h,e,6,l)}}const cr=new WeakMap;function fo(e,t,n=!1){const s=n?cr:t.emitsCache,l=s.get(e);if(l!==void 0)return l;const o=e.emits;let r={},a=!1;if(!Q(e)){const p=h=>{const f=fo(h,t,!0);f&&(a=!0,Ce(r,f))};!n&&t.mixins.length&&t.mixins.forEach(p),e.extends&&p(e.extends),e.mixins&&e.mixins.forEach(p)}return!o&&!a?(ue(e)&&s.set(e,null),null):(V(o)?o.forEach(p=>r[p]=null):Ce(r,o),ue(e)&&s.set(e,r),r)}function Yn(e,t){return!e||!Fn(t)?!1:(t=t.slice(2).replace(/Once$/,""),oe(e,t[0].toLowerCase()+t.slice(1))||oe(e,xt(t))||oe(e,t))}function el(e){const{type:t,vnode:n,proxy:s,withProxy:l,propsOptions:[o],slots:r,attrs:a,emit:p,render:h,renderCache:f,props:y,data:S,setupState:$,ctx:D,inheritAttrs:m}=e,q=Tn(e);let j,N;try{if(n.shapeFlag&4){const F=l||s,K=F;j=Ye(h.call(K,F,f,y,$,S,D)),N=a}else{const F=t;j=Ye(F.length>1?F(y,{attrs:a,slots:r,emit:p}):F(y,null)),N=t.props?a:fr(a)}}catch(F){ln.length=0,zn(F,e,1),j=be(wt)}let B=j;if(N&&m!==!1){const F=Object.keys(N),{shapeFlag:K}=B;F.length&&K&7&&(o&&F.some(Cs)&&(N=dr(N,o)),B=Bt(B,N,!1,!0))}return n.dirs&&(B=Bt(B,null,!1,!0),B.dirs=B.dirs?B.dirs.concat(n.dirs):n.dirs),n.transition&&Rs(B,n.transition),j=B,Tn(q),j}const fr=e=>{let t;for(const n in e)(n==="class"||n==="style"||Fn(n))&&((t||(t={}))[n]=e[n]);return t},dr=(e,t)=>{const n={};for(const s in e)(!Cs(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function pr(e,t,n){const{props:s,children:l,component:o}=e,{props:r,children:a,patchFlag:p}=t,h=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&p>=0){if(p&1024)return!0;if(p&16)return s?tl(s,r,h):!!r;if(p&8){const f=t.dynamicProps;for(let y=0;yObject.create(ho),yo=e=>Object.getPrototypeOf(e)===ho;function vr(e,t,n,s=!1){const l={},o=vo();e.propsDefaults=Object.create(null),go(e,t,l,o);for(const r in e.propsOptions[0])r in l||(l[r]=void 0);n?e.props=s?l:bi(l):e.type.props?e.props=l:e.props=o,e.attrs=o}function yr(e,t,n,s){const{props:l,attrs:o,vnode:{patchFlag:r}}=e,a=se(l),[p]=e.propsOptions;let h=!1;if((s||r>0)&&!(r&16)){if(r&8){const f=e.vnode.dynamicProps;for(let y=0;y{p=!0;const[S,$]=mo(y,t,!0);Ce(r,S),$&&a.push(...$)};!n&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}if(!o&&!p)return ue(e)&&s.set(e,Ft),Ft;if(V(o))for(let f=0;fe==="_"||e==="_ctx"||e==="$stable",Ns=e=>V(e)?e.map(Ye):[Ye(e)],mr=(e,t,n)=>{if(t._n)return t;const s=Di((...l)=>Ns(t(...l)),n);return s._c=!1,s},bo=(e,t,n)=>{const s=e._ctx;for(const l in e){if(Ls(l))continue;const o=e[l];if(Q(o))t[l]=mr(l,o,s);else if(o!=null){const r=Ns(o);t[l]=()=>r}}},_o=(e,t)=>{const n=Ns(t);e.slots.default=()=>n},wo=(e,t,n)=>{for(const s in t)(n||!Ls(s))&&(e[s]=t[s])},br=(e,t,n)=>{const s=e.slots=vo();if(e.vnode.shapeFlag&32){const l=t._;l?(wo(s,t,n),n&&Pl(s,"_",l,!0)):bo(t,s)}else t&&_o(e,t)},_r=(e,t,n)=>{const{vnode:s,slots:l}=e;let o=!0,r=ce;if(s.shapeFlag&32){const a=t._;a?n&&a===1?o=!1:wo(l,t,n):(o=!t.$stable,bo(t,l)),r=t}else t&&(_o(e,t),r={default:1});if(o)for(const a in l)!Ls(a)&&r[a]==null&&delete l[a]},Me=kr;function wr(e){return xr(e)}function xr(e,t){const n=Un();n.__VUE__=!0;const{insert:s,remove:l,patchProp:o,createElement:r,createText:a,createComment:p,setText:h,setElementText:f,parentNode:y,nextSibling:S,setScopeId:$=Ge,insertStaticContent:D}=e,m=(i,c,g,w=null,b=null,x=null,k=void 0,I=null,E=!!c.dynamicChildren)=>{if(i===c)return;i&&!qt(i,c)&&(w=et(i),ke(i,b,x,!0),i=null),c.patchFlag===-2&&(E=!1,c.dynamicChildren=null);const{type:C,ref:U,shapeFlag:R}=c;switch(C){case Gn:q(i,c,g,w);break;case wt:j(i,c,g,w);break;case is:i==null&&N(c,g,w,k);break;case he:Se(i,c,g,w,b,x,k,I,E);break;default:R&1?K(i,c,g,w,b,x,k,I,E):R&6?_(i,c,g,w,b,x,k,I,E):(R&64||R&128)&&C.process(i,c,g,w,b,x,k,I,E,v)}U!=null&&b?tn(U,i&&i.ref,x,c||i,!c):U==null&&i&&i.ref!=null&&tn(i.ref,null,x,i,!0)},q=(i,c,g,w)=>{if(i==null)s(c.el=a(c.children),g,w);else{const b=c.el=i.el;c.children!==i.children&&h(b,c.children)}},j=(i,c,g,w)=>{i==null?s(c.el=p(c.children||""),g,w):c.el=i.el},N=(i,c,g,w)=>{[i.el,i.anchor]=D(i.children,c,g,w,i.el,i.anchor)},B=({el:i,anchor:c},g,w)=>{let b;for(;i&&i!==c;)b=S(i),s(i,g,w),i=b;s(c,g,w)},F=({el:i,anchor:c})=>{let g;for(;i&&i!==c;)g=S(i),l(i),i=g;l(c)},K=(i,c,g,w,b,x,k,I,E)=>{if(c.type==="svg"?k="svg":c.type==="math"&&(k="mathml"),i==null)ae(c,g,w,b,x,k,I,E);else{const C=i.el&&i.el._isVueCE?i.el:null;try{C&&C._beginPatch(),ee(i,c,b,x,k,I,E)}finally{C&&C._endPatch()}}},ae=(i,c,g,w,b,x,k,I)=>{let E,C;const{props:U,shapeFlag:R,transition:W,dirs:X}=i;if(E=i.el=r(i.type,x,U&&U.is,U),R&8?f(E,i.children):R&16&&z(i.children,E,null,w,b,os(i,x),k,I),X&&Et(i,null,w,"created"),le(E,i,i.scopeId,k,w),U){for(const de in U)de!=="value"&&!Xt(de)&&o(E,de,null,U[de],x,w);"value"in U&&o(E,"value",null,U.value,x),(C=U.onVnodeBeforeMount)&&ze(C,w,i)}X&&Et(i,null,w,"beforeMount");const te=Sr(b,W);te&&W.beforeEnter(E),s(E,c,g),((C=U&&U.onVnodeMounted)||te||X)&&Me(()=>{C&&ze(C,w,i),te&&W.enter(E),X&&Et(i,null,w,"mounted")},b)},le=(i,c,g,w,b)=>{if(g&&$(i,g),w)for(let x=0;x{for(let C=E;C{const I=c.el=i.el;let{patchFlag:E,dynamicChildren:C,dirs:U}=c;E|=i.patchFlag&16;const R=i.props||ce,W=c.props||ce;let X;if(g&&Tt(g,!1),(X=W.onVnodeBeforeUpdate)&&ze(X,g,c,i),U&&Et(c,i,g,"beforeUpdate"),g&&Tt(g,!0),(R.innerHTML&&W.innerHTML==null||R.textContent&&W.textContent==null)&&f(I,""),C?fe(i.dynamicChildren,C,I,g,w,os(c,b),x):k||H(i,c,I,null,g,w,os(c,b),x,!1),E>0){if(E&16)we(I,R,W,g,b);else if(E&2&&R.class!==W.class&&o(I,"class",null,W.class,b),E&4&&o(I,"style",R.style,W.style,b),E&8){const te=c.dynamicProps;for(let de=0;de{X&&ze(X,g,c,i),U&&Et(c,i,g,"updated")},w)},fe=(i,c,g,w,b,x,k)=>{for(let I=0;I{if(c!==g){if(c!==ce)for(const x in c)!Xt(x)&&!(x in g)&&o(i,x,c[x],null,b,w);for(const x in g){if(Xt(x))continue;const k=g[x],I=c[x];k!==I&&x!=="value"&&o(i,x,I,k,b,w)}"value"in g&&o(i,"value",c.value,g.value,b)}},Se=(i,c,g,w,b,x,k,I,E)=>{const C=c.el=i?i.el:a(""),U=c.anchor=i?i.anchor:a("");let{patchFlag:R,dynamicChildren:W,slotScopeIds:X}=c;X&&(I=I?I.concat(X):X),i==null?(s(C,g,w),s(U,g,w),z(c.children||[],g,U,b,x,k,I,E)):R>0&&R&64&&W&&i.dynamicChildren&&i.dynamicChildren.length===W.length?(fe(i.dynamicChildren,W,g,b,x,k,I),(c.key!=null||b&&c===b.subTree)&&xo(i,c,!0)):H(i,c,g,U,b,x,k,I,E)},_=(i,c,g,w,b,x,k,I,E)=>{c.slotScopeIds=I,i==null?c.shapeFlag&512?b.ctx.activate(c,g,w,k,E):T(c,g,w,b,x,k,E):Y(i,c,E)},T=(i,c,g,w,b,x,k)=>{const I=i.component=Ar(i,w,b);if(io(i)&&(I.ctx.renderer=v),Dr(I,!1,k),I.asyncDep){if(b&&b.registerDep(I,L,k),!i.el){const E=I.subTree=be(wt);j(null,E,c,g),i.placeholder=E.el}}else L(I,i,c,g,b,x,k)},Y=(i,c,g)=>{const w=c.component=i.component;if(pr(i,c,g))if(w.asyncDep&&!w.asyncResolved){M(w,c,g);return}else w.next=c,w.update();else c.el=i.el,w.vnode=c},L=(i,c,g,w,b,x,k)=>{const I=()=>{if(i.isMounted){let{next:R,bu:W,u:X,parent:te,vnode:de}=i;{const Be=So(i);if(Be){R&&(R.el=de.el,M(i,R,k)),Be.asyncDep.then(()=>{Me(()=>{i.isUnmounted||C()},b)});return}}let re=R,Ae;Tt(i,!1),R?(R.el=de.el,M(i,R,k)):R=de,W&&wn(W),(Ae=R.props&&R.props.onVnodeBeforeUpdate)&&ze(Ae,te,R,de),Tt(i,!0);const De=el(i),Ue=i.subTree;i.subTree=De,m(Ue,De,y(Ue.el),et(Ue),i,b,x),R.el=De.el,re===null&&hr(i,De.el),X&&Me(X,b),(Ae=R.props&&R.props.onVnodeUpdated)&&Me(()=>ze(Ae,te,R,de),b)}else{let R;const{el:W,props:X}=c,{bm:te,m:de,parent:re,root:Ae,type:De}=i,Ue=nn(c);Tt(i,!1),te&&wn(te),!Ue&&(R=X&&X.onVnodeBeforeMount)&&ze(R,re,c),Tt(i,!0);{Ae.ce&&Ae.ce._hasShadowRoot()&&Ae.ce._injectChildStyle(De);const Be=i.subTree=el(i);m(null,Be,g,w,i,b,x),c.el=Be.el}if(de&&Me(de,b),!Ue&&(R=X&&X.onVnodeMounted)){const Be=c;Me(()=>ze(R,re,Be),b)}(c.shapeFlag&256||re&&nn(re.vnode)&&re.vnode.shapeFlag&256)&&i.a&&Me(i.a,b),i.isMounted=!0,c=g=w=null}};i.scope.on();const E=i.effect=new Nl(I);i.scope.off();const C=i.update=E.run.bind(E),U=i.job=E.runIfDirty.bind(E);U.i=i,U.id=i.uid,E.scheduler=()=>Ms(U),Tt(i,!0),C()},M=(i,c,g)=>{c.component=i;const w=i.vnode.props;i.vnode=c,i.next=null,yr(i,c.props,w,g),_r(i,c.children,g),ut(),zs(i),at()},H=(i,c,g,w,b,x,k,I,E=!1)=>{const C=i&&i.children,U=i?i.shapeFlag:0,R=c.children,{patchFlag:W,shapeFlag:X}=c;if(W>0){if(W&128){G(C,R,g,w,b,x,k,I,E);return}else if(W&256){ve(C,R,g,w,b,x,k,I,E);return}}X&8?(U&16&&Qe(C,b,x),R!==C&&f(g,R)):U&16?X&16?G(C,R,g,w,b,x,k,I,E):Qe(C,b,x,!0):(U&8&&f(g,""),X&16&&z(R,g,w,b,x,k,I,E))},ve=(i,c,g,w,b,x,k,I,E)=>{i=i||Ft,c=c||Ft;const C=i.length,U=c.length,R=Math.min(C,U);let W;for(W=0;WU?Qe(i,b,x,!0,!1,R):z(c,g,w,b,x,k,I,E,R)},G=(i,c,g,w,b,x,k,I,E)=>{let C=0;const U=c.length;let R=i.length-1,W=U-1;for(;C<=R&&C<=W;){const X=i[C],te=c[C]=E?st(c[C]):Ye(c[C]);if(qt(X,te))m(X,te,g,null,b,x,k,I,E);else break;C++}for(;C<=R&&C<=W;){const X=i[R],te=c[W]=E?st(c[W]):Ye(c[W]);if(qt(X,te))m(X,te,g,null,b,x,k,I,E);else break;R--,W--}if(C>R){if(C<=W){const X=W+1,te=XW)for(;C<=R;)ke(i[C],b,x,!0),C++;else{const X=C,te=C,de=new Map;for(C=te;C<=W;C++){const Re=c[C]=E?st(c[C]):Ye(c[C]);Re.key!=null&&de.set(Re.key,C)}let re,Ae=0;const De=W-te+1;let Ue=!1,Be=0;const zt=new Array(De);for(C=0;C=De){ke(Re,b,x,!0);continue}let Ve;if(Re.key!=null)Ve=de.get(Re.key);else for(re=te;re<=W;re++)if(zt[re-te]===0&&qt(Re,c[re])){Ve=re;break}Ve===void 0?ke(Re,b,x,!0):(zt[Ve-te]=C+1,Ve>=Be?Be=Ve:Ue=!0,m(Re,c[Ve],g,null,b,x,k,I,E),Ae++)}const js=Ue?Cr(zt):Ft;for(re=js.length-1,C=De-1;C>=0;C--){const Re=te+C,Ve=c[Re],Ws=c[Re+1],Hs=Re+1{const{el:x,type:k,transition:I,children:E,shapeFlag:C}=i;if(C&6){ie(i.component.subTree,c,g,w);return}if(C&128){i.suspense.move(c,g,w);return}if(C&64){k.move(i,c,g,v);return}if(k===he){s(x,c,g);for(let R=0;RI.enter(x),b);else{const{leave:R,delayLeave:W,afterLeave:X}=I,te=()=>{i.ctx.isUnmounted?l(x):s(x,c,g)},de=()=>{x._isLeaving&&x[Hi](!0),R(x,()=>{te(),X&&X()})};W?W(x,te,de):de()}else s(x,c,g)},ke=(i,c,g,w=!1,b=!1)=>{const{type:x,props:k,ref:I,children:E,dynamicChildren:C,shapeFlag:U,patchFlag:R,dirs:W,cacheIndex:X}=i;if(R===-2&&(b=!1),I!=null&&(ut(),tn(I,null,g,i,!0),at()),X!=null&&(c.renderCache[X]=void 0),U&256){c.ctx.deactivate(i);return}const te=U&1&&W,de=!nn(i);let re;if(de&&(re=k&&k.onVnodeBeforeUnmount)&&ze(re,c,i),U&6)Dt(i.component,g,w);else{if(U&128){i.suspense.unmount(g,w);return}te&&Et(i,null,c,"beforeUnmount"),U&64?i.type.remove(i,c,g,v,w):C&&!C.hasOnce&&(x!==he||R>0&&R&64)?Qe(C,c,g,!1,!0):(x===he&&R&384||!b&&U&16)&&Qe(E,c,g),w&&ht(i)}(de&&(re=k&&k.onVnodeUnmounted)||te)&&Me(()=>{re&&ze(re,c,i),te&&Et(i,null,c,"unmounted")},g)},ht=i=>{const{type:c,el:g,anchor:w,transition:b}=i;if(c===he){At(g,w);return}if(c===is){F(i);return}const x=()=>{l(g),b&&!b.persisted&&b.afterLeave&&b.afterLeave()};if(i.shapeFlag&1&&b&&!b.persisted){const{leave:k,delayLeave:I}=b,E=()=>k(g,x);I?I(i.el,x,E):E()}else x()},At=(i,c)=>{let g;for(;i!==c;)g=S(i),l(i),i=g;l(c)},Dt=(i,c,g)=>{const{bum:w,scope:b,job:x,subTree:k,um:I,m:E,a:C}=i;sl(E),sl(C),w&&wn(w),b.stop(),x&&(x.flags|=8,ke(k,i,c,g)),I&&Me(I,c),Me(()=>{i.isUnmounted=!0},c)},Qe=(i,c,g,w=!1,b=!1,x=0)=>{for(let k=x;k{if(i.shapeFlag&6)return et(i.component.subTree);if(i.shapeFlag&128)return i.suspense.next();const c=S(i.anchor||i.el),g=c&&c[ji];return g?S(g):c};let Ct=!1;const kt=(i,c,g)=>{let w;i==null?c._vnode&&(ke(c._vnode,null,null,!0),w=c._vnode.component):m(c._vnode||null,i,c,null,null,null,g),c._vnode=i,Ct||(Ct=!0,zs(w),eo(),Ct=!1)},v={p:m,um:ke,m:ie,r:ht,mt:T,mc:z,pc:H,pbc:fe,n:et,o:e};return{render:kt,hydrate:void 0,createApp:rr(kt)}}function os({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Tt({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Sr(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function xo(e,t,n=!1){const s=e.children,l=t.children;if(V(s)&&V(l))for(let o=0;o>1,e[n[a]]0&&(t[s]=n[o-1]),n[o]=s)}}for(o=n.length,r=n[o-1];o-- >0;)n[o]=r,r=t[r];return n}function So(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:So(t)}function sl(e){if(e)for(let t=0;te.__isSuspense;function kr(e,t){t&&t.pendingBranch?V(e)?t.effects.push(...e):t.effects.push(e):Ai(e)}const he=Symbol.for("v-fgt"),Gn=Symbol.for("v-txt"),wt=Symbol.for("v-cmt"),is=Symbol.for("v-stc"),ln=[];let Le=null;function P(e=!1){ln.push(Le=e?null:[])}function Er(){ln.pop(),Le=ln[ln.length-1]||null}let cn=1;function Pn(e,t=!1){cn+=e,e<0&&Le&&t&&(Le.hasOnce=!0)}function Eo(e){return e.dynamicChildren=cn>0?Le||Ft:null,Er(),cn>0&&Le&&Le.push(e),e}function O(e,t,n,s,l,o){return Eo(d(e,t,n,s,l,o,!0))}function On(e,t,n,s,l){return Eo(be(e,t,n,s,l,!0))}function An(e){return e?e.__v_isVNode===!0:!1}function qt(e,t){return e.type===t.type&&e.key===t.key}const To=({key:e})=>e??null,xn=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?_e(e)||ge(e)||Q(e)?{i:Fe,r:e,k:t,f:!!n}:e:null);function d(e,t=null,n=null,s=0,l=null,o=e===he?0:1,r=!1,a=!1){const p={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&To(t),ref:t&&xn(t),scopeId:no,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:s,dynamicProps:l,dynamicChildren:null,appContext:null,ctx:Fe};return a?(Fs(p,n),o&128&&e.normalize(p)):n&&(p.shapeFlag|=_e(n)?8:16),cn>0&&!r&&Le&&(p.patchFlag>0||o&6)&&p.patchFlag!==32&&Le.push(p),p}const be=Tr;function Tr(e,t=null,n=null,s=0,l=null,o=!1){if((!e||e===Qi)&&(e=wt),An(e)){const a=Bt(e,t,!0);return n&&Fs(a,n),cn>0&&!o&&Le&&(a.shapeFlag&6?Le[Le.indexOf(e)]=a:Le.push(a)),a.patchFlag=-2,a}if(Nr(e)&&(e=e.__vccOpts),t){t=Ir(t);let{class:a,style:p}=t;a&&!_e(a)&&(t.class=Oe(a)),ue(p)&&(Vn(p)&&!V(p)&&(p=Ce({},p)),t.style=Es(p))}const r=_e(e)?1:ko(e)?128:Wi(e)?64:ue(e)?4:Q(e)?2:0;return d(e,t,n,s,l,r,o,!0)}function Ir(e){return e?Vn(e)||yo(e)?Ce({},e):e:null}function Bt(e,t,n=!1,s=!1){const{props:l,ref:o,patchFlag:r,children:a,transition:p}=e,h=t?$r(l||{},t):l,f={__v_isVNode:!0,__v_skip:!0,type:e.type,props:h,key:h&&To(h),ref:t&&t.ref?n&&o?V(o)?o.concat(xn(t)):[o,xn(t)]:xn(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:a,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==he?r===-1?16:r|16:r,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:p,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Bt(e.ssContent),ssFallback:e.ssFallback&&Bt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return p&&s&&Rs(f,p.clone(f)),f}function J(e=" ",t=0){return be(Gn,null,e,t)}function ye(e="",t=!1){return t?(P(),On(wt,null,e)):be(wt,null,e)}function Ye(e){return e==null||typeof e=="boolean"?be(wt):V(e)?be(he,null,e.slice()):An(e)?st(e):be(Gn,null,String(e))}function st(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Bt(e)}function Fs(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(V(t))n=16;else if(typeof t=="object")if(s&65){const l=t.default;l&&(l._c&&(l._d=!1),Fs(e,l()),l._c&&(l._d=!0));return}else{n=32;const l=t._;!l&&!yo(t)?t._ctx=Fe:l===3&&Fe&&(Fe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else Q(t)?(t={default:t,_ctx:Fe},n=32):(t=String(t),s&64?(n=16,t=[J(t)]):n=8);e.children=t,e.shapeFlag|=n}function $r(...e){const t={};for(let n=0;nPe||Fe;let Dn,bs;{const e=Un(),t=(n,s)=>{let l;return(l=e[n])||(l=e[n]=[]),l.push(s),o=>{l.length>1?l.forEach(r=>r(o)):l[0](o)}};Dn=t("__VUE_INSTANCE_SETTERS__",n=>Pe=n),bs=t("__VUE_SSR_SETTERS__",n=>fn=n)}const gn=e=>{const t=Pe;return Dn(e),e.scope.on(),()=>{e.scope.off(),Dn(t)}},ll=()=>{Pe&&Pe.scope.off(),Dn(null)};function $o(e){return e.vnode.shapeFlag&4}let fn=!1;function Dr(e,t=!1,n=!1){t&&bs(t);const{props:s,children:l}=e.vnode,o=$o(e);vr(e,s,o,t),br(e,l,n||t);const r=o?Mr(e,t):void 0;return t&&bs(!1),r}function Mr(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,er);const{setup:s}=n;if(s){ut();const l=e.setupContext=s.length>1?Lr(e):null,o=gn(e),r=vn(s,e,0,[e.props,l]),a=El(r);if(at(),o(),(a||e.sp)&&!nn(e)&&oo(e),a){if(r.then(ll,ll),t)return r.then(p=>{ol(e,p)}).catch(p=>{zn(p,e,0)});e.asyncDep=r}else ol(e,r)}else Po(e)}function ol(e,t,n){Q(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:ue(t)&&(e.setupState=Xl(t)),Po(e)}function Po(e,t,n){const s=e.type;e.render||(e.render=s.render||Ge);{const l=gn(e);ut();try{tr(e)}finally{at(),l()}}}const Rr={get(e,t){return Te(e,"get",""),e[t]}};function Lr(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Rr),slots:e.slots,emit:e.emit,expose:t}}function Xn(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Xl(Ds(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in sn)return sn[n](e)},has(t,n){return n in t||n in sn}})):e.proxy}function Nr(e){return Q(e)&&"__vccOpts"in e}const xe=(e,t)=>Ti(e,t,fn);function me(e,t,n){try{Pn(-1);const s=arguments.length;return s===2?ue(t)&&!V(t)?An(t)?be(e,null,[t]):be(e,t):be(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&An(n)&&(n=[n]),be(e,t,n))}finally{Pn(1)}}const Fr="3.5.29";/** +* @vue/runtime-dom v3.5.29 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let _s;const il=typeof window<"u"&&window.trustedTypes;if(il)try{_s=il.createPolicy("vue",{createHTML:e=>e})}catch{}const Oo=_s?e=>_s.createHTML(e):e=>e,jr="http://www.w3.org/2000/svg",Wr="http://www.w3.org/1998/Math/MathML",nt=typeof document<"u"?document:null,rl=nt&&nt.createElement("template"),Hr={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const l=t==="svg"?nt.createElementNS(jr,e):t==="mathml"?nt.createElementNS(Wr,e):n?nt.createElement(e,{is:n}):nt.createElement(e);return e==="select"&&s&&s.multiple!=null&&l.setAttribute("multiple",s.multiple),l},createText:e=>nt.createTextNode(e),createComment:e=>nt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>nt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,l,o){const r=n?n.previousSibling:t.lastChild;if(l&&(l===o||l.nextSibling))for(;t.insertBefore(l.cloneNode(!0),n),!(l===o||!(l=l.nextSibling)););else{rl.innerHTML=Oo(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const a=rl.content;if(s==="svg"||s==="mathml"){const p=a.firstChild;for(;p.firstChild;)a.appendChild(p.firstChild);a.removeChild(p)}t.insertBefore(a,n)}return[r?r.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Kr=Symbol("_vtc");function Ur(e,t,n){const s=e[Kr];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Mn=Symbol("_vod"),Ao=Symbol("_vsh"),ul={name:"show",beforeMount(e,{value:t},{transition:n}){e[Mn]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):Yt(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:s}){!t!=!n&&(s?t?(s.beforeEnter(e),Yt(e,!0),s.enter(e)):s.leave(e,()=>{Yt(e,!1)}):Yt(e,t))},beforeUnmount(e,{value:t}){Yt(e,t)}};function Yt(e,t){e.style.display=t?e[Mn]:"none",e[Ao]=!t}const Br=Symbol(""),Vr=/(?:^|;)\s*display\s*:/;function zr(e,t,n){const s=e.style,l=_e(n);let o=!1;if(n&&!l){if(t)if(_e(t))for(const r of t.split(";")){const a=r.slice(0,r.indexOf(":")).trim();n[a]==null&&Sn(s,a,"")}else for(const r in t)n[r]==null&&Sn(s,r,"");for(const r in n)r==="display"&&(o=!0),Sn(s,r,n[r])}else if(l){if(t!==n){const r=s[Br];r&&(n+=";"+r),s.cssText=n,o=Vr.test(n)}}else t&&e.removeAttribute("style");Mn in e&&(e[Mn]=o?s.display:"",e[Ao]&&(s.display="none"))}const al=/\s*!important$/;function Sn(e,t,n){if(V(n))n.forEach(s=>Sn(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=Jr(e,t);al.test(n)?e.setProperty(xt(s),n.replace(al,""),"important"):e[s]=n}}const cl=["Webkit","Moz","ms"],rs={};function Jr(e,t){const n=rs[t];if(n)return n;let s=_t(t);if(s!=="filter"&&s in e)return rs[t]=s;s=$l(s);for(let l=0;lus||(Xr.then(()=>us=0),us=Date.now());function Qr(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;Ze(eu(s,n.value),t,5,[s])};return n.value=e,n.attached=Zr(),n}function eu(e,t){if(V(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(s=>l=>!l._stopped&&s&&s(l))}else return t}const yl=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,tu=(e,t,n,s,l,o)=>{const r=l==="svg";t==="class"?Ur(e,s,r):t==="style"?zr(e,n,s):Fn(t)?Cs(t)||Yr(e,t,n,s,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):nu(e,t,s,r))?(pl(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&dl(e,t,s,r,o,t!=="value")):e._isVueCE&&(/[A-Z]/.test(t)||!_e(s))?pl(e,_t(t),s,o,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),dl(e,t,s,r))};function nu(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&yl(t)&&Q(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const l=e.tagName;if(l==="IMG"||l==="VIDEO"||l==="CANVAS"||l==="SOURCE")return!1}return yl(t)&&_e(n)?!1:t in e}const Rn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return V(t)?n=>wn(t,n):t};function su(e){e.target.composing=!0}function gl(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Ht=Symbol("_assign");function ml(e,t,n){return t&&(e=e.trim()),n&&(e=Kn(e)),e}const Do={created(e,{modifiers:{lazy:t,trim:n,number:s}},l){e[Ht]=Rn(l);const o=s||l.props&&l.props.type==="number";$t(e,t?"change":"input",r=>{r.target.composing||e[Ht](ml(e.value,n,o))}),(n||o)&&$t(e,"change",()=>{e.value=ml(e.value,n,o)}),t||($t(e,"compositionstart",su),$t(e,"compositionend",gl),$t(e,"change",gl))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:l,number:o}},r){if(e[Ht]=Rn(r),e.composing)return;const a=(o||e.type==="number")&&!/^0\d/.test(e.value)?Kn(e.value):e.value,p=t??"";a!==p&&(document.activeElement===e&&e.type!=="range"&&(s&&t===n||l&&e.value.trim()===p)||(e.value=p))}},Mo={deep:!0,created(e,{value:t,modifiers:{number:n}},s){const l=jn(t);$t(e,"change",()=>{const o=Array.prototype.filter.call(e.options,r=>r.selected).map(r=>n?Kn(Ln(r)):Ln(r));e[Ht](e.multiple?l?new Set(o):o:o[0]),e._assigning=!0,yn(()=>{e._assigning=!1})}),e[Ht]=Rn(s)},mounted(e,{value:t}){bl(e,t)},beforeUpdate(e,t,n){e[Ht]=Rn(n)},updated(e,{value:t}){e._assigning||bl(e,t)}};function bl(e,t){const n=e.multiple,s=V(t);if(!(n&&!s&&!jn(t))){for(let l=0,o=e.options.length;lString(h)===String(a)):r.selected=Zo(t,a)>-1}else r.selected=t.has(a);else if(pn(Ln(r),t)){e.selectedIndex!==l&&(e.selectedIndex=l);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Ln(e){return"_value"in e?e._value:e.value}const lu=["ctrl","shift","alt","meta"],ou={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>lu.some(n=>e[`${n}Key`]&&!t.includes(n))},qe=(e,t)=>{if(!e)return e;const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=(l,...o)=>{for(let r=0;r{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=l=>{if(!("key"in l))return;const o=xt(l.key);if(t.some(r=>r===o||iu[r]===o))return e(l)})},ru=Ce({patchProp:tu},Hr);let _l;function uu(){return _l||(_l=wr(ru))}const au=(...e)=>{const t=uu().createApp(...e),{mount:n}=t;return t.mount=s=>{const l=fu(s);if(!l)return;const o=t._component;!Q(o)&&!o.render&&!o.template&&(o.template=l.innerHTML),l.nodeType===1&&(l.textContent="");const r=n(l,!1,cu(l));return l instanceof Element&&(l.removeAttribute("v-cloak"),l.setAttribute("data-v-app","")),r},t};function cu(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function fu(e){return _e(e)?document.querySelector(e):e}/*! + * pinia v2.3.1 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let Ro;const Zn=e=>Ro=e,Lo=Symbol();function ws(e){return e&&typeof e=="object"&&Object.prototype.toString.call(e)==="[object Object]"&&typeof e.toJSON!="function"}var on;(function(e){e.direct="direct",e.patchObject="patch object",e.patchFunction="patch function"})(on||(on={}));function du(){const e=Rl(!0),t=e.run(()=>Z({}));let n=[],s=[];const l=Ds({install(o){Zn(l),l._a=o,o.provide(Lo,l),o.config.globalProperties.$pinia=l,s.forEach(r=>n.push(r)),s=[]},use(o){return this._a?n.push(o):s.push(o),this},_p:n,_a:null,_e:e,_s:new Map,state:t});return l}const No=()=>{};function wl(e,t,n,s=No){e.push(t);const l=()=>{const o=e.indexOf(t);o>-1&&(e.splice(o,1),s())};return!n&&Ll()&&Qo(l),l}function Rt(e,...t){e.slice().forEach(n=>{n(...t)})}const pu=e=>e(),xl=Symbol(),as=Symbol();function xs(e,t){e instanceof Map&&t instanceof Map?t.forEach((n,s)=>e.set(s,n)):e instanceof Set&&t instanceof Set&&t.forEach(e.add,e);for(const n in t){if(!t.hasOwnProperty(n))continue;const s=t[n],l=e[n];ws(l)&&ws(s)&&e.hasOwnProperty(n)&&!ge(s)&&!it(s)?e[n]=xs(l,s):e[n]=s}return e}const hu=Symbol();function vu(e){return!ws(e)||!e.hasOwnProperty(hu)}const{assign:vt}=Object;function yu(e){return!!(ge(e)&&e.effect)}function gu(e,t,n,s){const{state:l,actions:o,getters:r}=t,a=n.state.value[e];let p;function h(){a||(n.state.value[e]=l?l():{});const f=Si(n.state.value[e]);return vt(f,o,Object.keys(r||{}).reduce((y,S)=>(y[S]=Ds(xe(()=>{Zn(n);const $=n._s.get(e);return r[S].call($,$)})),y),{}))}return p=Fo(e,h,t,n,s,!0),p}function Fo(e,t,n={},s,l,o){let r;const a=vt({actions:{}},n),p={deep:!0};let h,f,y=[],S=[],$;const D=s.state.value[e];!o&&!D&&(s.state.value[e]={});let m;function q(z){let ee;h=f=!1,typeof z=="function"?(z(s.state.value[e]),ee={type:on.patchFunction,storeId:e,events:$}):(xs(s.state.value[e],z),ee={type:on.patchObject,payload:z,storeId:e,events:$});const fe=m=Symbol();yn().then(()=>{m===fe&&(h=!0)}),f=!0,Rt(y,ee,s.state.value[e])}const j=o?function(){const{state:ee}=n,fe=ee?ee():{};this.$patch(we=>{vt(we,fe)})}:No;function N(){r.stop(),y=[],S=[],s._s.delete(e)}const B=(z,ee="")=>{if(xl in z)return z[as]=ee,z;const fe=function(){Zn(s);const we=Array.from(arguments),Se=[],_=[];function T(M){Se.push(M)}function Y(M){_.push(M)}Rt(S,{args:we,name:fe[as],store:K,after:T,onError:Y});let L;try{L=z.apply(this&&this.$id===e?this:K,we)}catch(M){throw Rt(_,M),M}return L instanceof Promise?L.then(M=>(Rt(Se,M),M)).catch(M=>(Rt(_,M),Promise.reject(M))):(Rt(Se,L),L)};return fe[xl]=!0,fe[as]=ee,fe},F={_p:s,$id:e,$onAction:wl.bind(null,S),$patch:q,$reset:j,$subscribe(z,ee={}){const fe=wl(y,z,ee.detached,()=>we()),we=r.run(()=>bt(()=>s.state.value[e],Se=>{(ee.flush==="sync"?f:h)&&z({storeId:e,type:on.direct,events:$},Se)},vt({},p,ee)));return fe},$dispose:N},K=hn(F);s._s.set(e,K);const le=(s._a&&s._a.runWithContext||pu)(()=>s._e.run(()=>(r=Rl()).run(()=>t({action:B}))));for(const z in le){const ee=le[z];if(ge(ee)&&!yu(ee)||it(ee))o||(D&&vu(ee)&&(ge(ee)?ee.value=D[z]:xs(ee,D[z])),s.state.value[e][z]=ee);else if(typeof ee=="function"){const fe=B(ee,z);le[z]=fe,a.actions[z]=ee}}return vt(K,le),vt(se(K),le),Object.defineProperty(K,"$state",{get:()=>s.state.value[e],set:z=>{q(ee=>{vt(ee,z)})}}),s._p.forEach(z=>{vt(K,r.run(()=>z({store:K,app:s._a,pinia:s,options:a})))}),D&&o&&n.hydrate&&n.hydrate(K.$state,D),h=!0,f=!0,K}/*! #__NO_SIDE_EFFECTS__ */function mu(e,t,n){let s,l;const o=typeof t=="function";s=e,l=o?n:t;function r(a,p){const h=Ri();return a=a||(h?en(Lo,null):null),a&&Zn(a),a=Ro,a._s.has(s)||(o?Fo(s,t,l,a):gu(s,l,a)),a._s.get(s)}return r.$id=s,r}const dt="/api";async function bu(){return(await fetch(`${dt}/worlds`)).json()}async function _u(e){const t=await fetch(`${dt}/worlds/${e}`);if(!t.ok)throw new Error(`World "${e}" not found`);return t.json()}async function wu(e,t){const n=await fetch(`${dt}/worlds/${e}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok)throw new Error("Failed to save world");return n.json()}async function xu(){return(await fetch(`${dt}/config`)).json()}async function Su(e=""){const t=e?`?dir=${encodeURIComponent(e)}`:"";return(await fetch(`${dt}/assets/browse${t}`)).json()}async function Cu(){return(await fetch(`${dt}/scenes`)).json()}async function ku(){return(await fetch(`${dt}/ui`)).json()}async function Eu(e){const t=await fetch(`${dt}/ui/${e}`);if(!t.ok)throw new Error(`UI layout "${e}" not found`);return t.json()}async function Tu(e,t){const n=await fetch(`${dt}/ui/${e}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(!n.ok)throw new Error("Failed to save UI layout");return n.json()}function Iu(e="Untitled"){const t=new Date().toISOString();return{version:"1.0",meta:{name:e,type:"2d_topdown",tileSize:32,created:t,modified:t},camera:{position:{x:0,y:0},zoom:1},layers:[{id:"bg",name:"Background",type:"tile",visible:!0,locked:!1,tiles:{}},{id:"entities",name:"Entities",type:"entity",visible:!0,locked:!1,entities:[]}],lights:[],tilesets:[]}}const St=mu("world",()=>{const e=Z(null),t=Z(null),n=Z(!1),s=Z(null),l=Z("select"),o=Z(null),r=Z(null),a=Z(null),p=Z([]),h=Z({tileSize:32,gridWidth:32,gridHeight:32}),f=Z([]),y=Z(-1),S=Z(""),$=Z([]),D=Z(!1),m=Z([]),q=xe(()=>{var v;return((v=e.value)==null?void 0:v.layers.find(u=>u.id===s.value))??null}),j=xe(()=>{var v;if(!e.value)return null;for(const u of e.value.layers){if(u.type!=="entity")continue;const i=(v=u.entities)==null?void 0:v.find(c=>c.id===a.value);if(i)return i}return null});async function N(){h.value=await xu()}async function B(){p.value=await bu()}async function F(v){var i,c;const u=await _u(v);e.value=u,t.value=v,s.value=((c=(i=u.layers)==null?void 0:i[0])==null?void 0:c.id)??null,n.value=!1,f.value=[JSON.stringify(u)],y.value=0}function K(v="Untitled"){const u=Iu(v);e.value=u,t.value=v.toLowerCase().replace(/\s+/g,"_"),s.value=u.layers[0].id,n.value=!0,f.value=[JSON.stringify(u)],y.value=0}async function ae(){!e.value||!t.value||(e.value.meta.modified=new Date().toISOString(),await wu(t.value,e.value),n.value=!1,await B())}function le(){if(!e.value)return;const v=JSON.stringify(e.value);f.value=f.value.slice(0,y.value+1),f.value.push(v),y.value=f.value.length-1,n.value=!0}function z(){y.value<=0||(y.value--,e.value=JSON.parse(f.value[y.value]),n.value=!0)}function ee(){y.value>=f.value.length-1||(y.value++,e.value=JSON.parse(f.value[y.value]),n.value=!0)}function fe(v){l.value=v,a.value=null}function we(v){s.value=v,a.value=null}function Se(v){var i;const u=(i=e.value)==null?void 0:i.layers.find(c=>c.id===v);u&&(u.visible=!u.visible,n.value=!0)}function _(v){var i;const u=(i=e.value)==null?void 0:i.layers.find(c=>c.id===v);u&&(u.locked=!u.locked,n.value=!0)}function T(v="tile"){if(!e.value)return;const u="layer_"+Date.now(),i=v==="tile"?{id:u,name:"New Layer",type:"tile",visible:!0,locked:!1,tiles:{}}:{id:u,name:"New Layer",type:"entity",visible:!0,locked:!1,entities:[]};e.value.layers.push(i),s.value=u,le()}function Y(v){var u;e.value&&(e.value.layers=e.value.layers.filter(i=>i.id!==v),s.value===v&&(s.value=((u=e.value.layers[0])==null?void 0:u.id)??null),le())}function L(v,u){var c;const i=(c=e.value)==null?void 0:c.layers.find(g=>g.id===v);i&&(i.name=u,n.value=!0)}function M(v){if(!e.value)return;const u=e.value.layers.findIndex(i=>i.id===v);if(ui.id===v);if(u>0){const i=e.value.layers[u];e.value.layers[u]=e.value.layers[u-1],e.value.layers[u-1]=i,le()}}function ve(v,u){const i=q.value;!i||i.type!=="tile"||i.locked||r.value&&(i.tiles||(i.tiles={}),i.tiles[`${v},${u}`]={...r.value},n.value=!0)}function G(v,u){const i=q.value;!i||i.type!=="tile"||i.locked||(i.tiles&&delete i.tiles[`${v},${u}`],n.value=!0)}function ie(v,u){const i=q.value;if(!i||i.type!=="entity"||i.locked||!o.value)return;const c=Date.now();i.entities||(i.entities=[]),i.entities.push({id:c,name:o.value,type:o.value,position:{x:v,y:u},rotation:0,scale:{x:1,y:1},properties:{}}),a.value=c,le()}function ke(v,u,i=16){const c=q.value;!c||c.type!=="entity"||c.locked||c.entities&&(c.entities=c.entities.filter(g=>{const w=g.position.x-v,b=g.position.y-u;return Math.sqrt(w*w+b*b)>i}),le())}function ht(v){j.value&&(Object.assign(j.value,v),n.value=!0)}function At(v,u,i=16){var c;if(e.value){for(const g of e.value.layers){if(g.type!=="entity"||!g.visible)continue;const w=(c=g.entities)==null?void 0:c.find(b=>{const x=b.position.x-v,k=b.position.y-u;return Math.sqrt(x*x+k*k)<=i});if(w){s.value=g.id,a.value=w.id;return}}a.value=null}}function Dt(v,u,i){var c;if(e.value)for(const g of e.value.layers){if(g.type!=="entity")continue;const w=(c=g.entities)==null?void 0:c.find(b=>b.id===v);if(w){w.position.x=u,w.position.y=i,n.value=!0;return}}}function Qe(){if(!j.value||!e.value)return;const v=a.value;for(const u of e.value.layers){if(u.type!=="entity"||!u.entities)continue;const i=u.entities.findIndex(c=>c.id===v);if(i!==-1){u.entities.splice(i,1),a.value=null,le();return}}}function et(){if(!j.value||!e.value)return;const v=j.value;for(const u of e.value.layers){if(u.type!=="entity"||!u.entities||!u.entities.find(c=>c.id===v.id))continue;const i=JSON.parse(JSON.stringify(v));i.id=Date.now(),i.name=v.name+" (copy)",i.position.x+=32,i.position.y+=32,u.entities.push(i),a.value=i.id,le();return}}async function Ct(v=""){D.value=!0;try{const u=await Su(v);S.value=u.path??"",$.value=u.entries??[]}finally{D.value=!1}}async function kt(){m.value=await Cu()}return{world:e,worldName:t,isDirty:n,activeLayerId:s,activeLayer:q,selectedTool:l,selectedEntityType:o,selectedTile:r,selectedEntityId:a,selectedEntity:j,worldList:p,config:h,assetPath:S,assetEntries:$,assetLoading:D,sceneList:m,fetchConfig:N,fetchWorldList:B,loadWorld:F,newWorld:K,saveCurrentWorld:ae,snapshot:le,undo:z,redo:ee,setActiveTool:fe,setActiveLayer:we,toggleLayerVisibility:Se,toggleLayerLock:_,addLayer:T,removeLayer:Y,renameLayer:L,moveLayerUp:M,moveLayerDown:H,placeTile:ve,eraseTile:G,placeEntity:ie,eraseEntityAt:ke,updateSelectedEntity:ht,selectEntityAt:At,moveEntityTo:Dt,deleteSelectedEntity:Qe,duplicateEntity:et,browseAssets:Ct,fetchScenes:kt}});let We=null,rt=null;const Nt={};function jo(e=8766){if(We&&We.readyState===WebSocket.OPEN)return;const t=`ws://${location.hostname}:${e}`;try{We=new WebSocket(t)}catch{Cl(e);return}We.onopen=()=>{rt&&(clearTimeout(rt),rt=null),cs("_connected",{})},We.onclose=()=>{We=null,cs("_disconnected",{}),Cl(e)},We.onerror=()=>{},We.onmessage=n=>{try{const s=JSON.parse(n.data);s.type&&cs(s.type,s.data||{})}catch{}}}function Sl(e,t){return Nt[e]||(Nt[e]=[]),Nt[e].push(t),()=>{Nt[e]=Nt[e].filter(n=>n!==t)}}function $u(){rt&&(clearTimeout(rt),rt=null),We&&(We.close(),We=null)}function cs(e,t){const n=Nt[e]||[];for(const s of n)try{s(t)}catch(l){console.error(`[WS] Handler error for "${e}":`,l)}}function Cl(e){rt||(rt=setTimeout(()=>{rt=null,jo(e)},3e3))}const pt=(e,t)=>{const n=e.__vccOpts||e;for(const[s,l]of t)n[s]=l;return n},Pu={class:"menu-bar"},Ou={class:"menu-group"},Au=["disabled"],Du={class:"menu-group tools"},Mu=["title","onClick"],Ru={class:"menu-group history"},Lu={key:0,class:"world-name"},Nu={class:"popup-box"},Fu={class:"new-world-row"},ju={key:0,class:"world-list"},Wu=["onClick"],Hu={key:1,class:"empty"},Ku={__name:"MenuBar",setup(e){const t=St(),n=Z(!1),s=Z(""),l=[{id:"select",icon:"↖",label:"Select"},{id:"place_tile",icon:"▣",label:"Place Tile"},{id:"place_entity",icon:"⊕",label:"Place Entity"},{id:"erase",icon:"✕",label:"Erase"}];Vt(()=>t.fetchWorldList());function o(){t.isDirty&&!confirm("Discard unsaved changes?")||t.newWorld("Untitled")}async function r(h){t.isDirty&&!confirm("Discard unsaved changes?")||(await t.loadWorld(h),n.value=!1)}async function a(){const h=s.value.trim()||"Untitled";t.isDirty&&!confirm("Discard unsaved changes?")||(t.newWorld(h),n.value=!1,s.value="")}async function p(){await t.saveCurrentWorld()}return(h,f)=>(P(),O("div",Pu,[f[7]||(f[7]=d("span",{class:"logo"},"VISU World Editor",-1)),d("div",Ou,[d("button",{onClick:o},"New"),d("button",{onClick:f[0]||(f[0]=y=>n.value=!n.value)},"Open"),d("button",{onClick:p,disabled:!A(t).world,class:Oe({dirty:A(t).isDirty})}," Save"+ne(A(t).isDirty?"*":""),11,Au)]),d("div",Du,[(P(),O(he,null,je(l,y=>d("button",{key:y.id,class:Oe({active:A(t).selectedTool===y.id}),title:y.label,onClick:S=>A(t).setActiveTool(y.id)},ne(y.icon),11,Mu)),64))]),d("div",Ru,[d("button",{onClick:f[1]||(f[1]=(...y)=>A(t).undo&&A(t).undo(...y)),title:"Undo (Ctrl+Z)"},"↩"),d("button",{onClick:f[2]||(f[2]=(...y)=>A(t).redo&&A(t).redo(...y)),title:"Redo (Ctrl+Y)"},"↪")]),A(t).world?(P(),O("div",Lu,ne(A(t).world.meta.name),1)):ye("",!0),n.value?(P(),O("div",{key:1,class:"popup",onClick:f[5]||(f[5]=qe(y=>n.value=!1,["self"]))},[d("div",Nu,[f[6]||(f[6]=d("h3",null,"Open World",-1)),d("div",Fu,[Ut(d("input",{"onUpdate:modelValue":f[3]||(f[3]=y=>s.value=y),placeholder:"New world name",onKeyup:Nn(a,["enter"])},null,544),[[Do,s.value]]),d("button",{onClick:a},"Create")]),A(t).worldList.length?(P(),O("div",ju,[(P(!0),O(he,null,je(A(t).worldList,y=>(P(),O("div",{key:y.name,class:"world-item",onClick:S=>r(y.name)},[d("span",null,ne(y.name),1),d("small",null,ne(new Date(y.modified).toLocaleDateString()),1)],8,Wu))),128))])):(P(),O("p",Hu,"No saved worlds found.")),d("button",{class:"close-btn",onClick:f[4]||(f[4]=y=>n.value=!1)},"✕")])])):ye("",!0)]))}},Uu=pt(Ku,[["__scopeId","data-v-182a0c4e"]]),Bu={class:"layer-panel"},Vu={class:"panel-header"},zu={class:"header-btns"},Ju={key:0,class:"empty"},qu={key:1,class:"layer-list"},Yu=["onClick"],Gu=["value","onBlur","onKeyup"],Xu=["onDblclick"],Zu={class:"layer-actions"},Qu=["onClick"],ea=["onClick"],ta=["title","onClick"],na=["title","onClick"],sa=["onClick"],la={__name:"LayerPanel",setup(e){const t=St(),n=Z(null),s=Z(null);function l(a){n.value=a,yn(()=>{const p=document.querySelector(".layer-name-input");p&&(p.focus(),p.select())})}function o(a,p){p.trim()&&t.renameLayer(a,p.trim()),n.value=null}function r(a){confirm("Delete this layer?")&&t.removeLayer(a)}return(a,p)=>(P(),O("div",Bu,[d("div",Vu,[p[4]||(p[4]=d("span",null,"Layers",-1)),d("div",zu,[d("button",{title:"Add tile layer",onClick:p[0]||(p[0]=h=>A(t).addLayer("tile"))},"+T"),d("button",{title:"Add entity layer",onClick:p[1]||(p[1]=h=>A(t).addLayer("entity"))},"+E")])]),A(t).world?(P(),O("div",qu,[(P(!0),O(he,null,je([...A(t).world.layers].reverse(),h=>(P(),O("div",{key:h.id,class:Oe(["layer-item",{active:A(t).activeLayerId===h.id,locked:h.locked}]),onClick:f=>A(t).setActiveLayer(h.id)},[d("span",{class:Oe(["layer-type",h.type])},ne(h.type==="tile"?"T":"E"),3),n.value===h.id?(P(),O("input",{key:0,class:"layer-name-input",value:h.name,onBlur:f=>o(h.id,f.target.value),onKeyup:[Nn(f=>o(h.id,f.target.value),["enter"]),p[2]||(p[2]=Nn(f=>n.value=null,["escape"]))],ref_for:!0,ref_key:"renameInput",ref:s,onClick:p[3]||(p[3]=qe(()=>{},["stop"]))},null,40,Gu)):(P(),O("span",{key:1,class:"layer-name",onDblclick:qe(f=>l(h.id),["stop"])},ne(h.name),41,Xu)),d("div",Zu,[d("button",{title:"Move up",onClick:qe(f=>A(t).moveLayerUp(h.id),["stop"])},"^",8,Qu),d("button",{title:"Move down",onClick:qe(f=>A(t).moveLayerDown(h.id),["stop"])},"v",8,ea),d("button",{title:h.visible?"Hide":"Show",class:Oe({dim:!h.visible}),onClick:qe(f=>A(t).toggleLayerVisibility(h.id),["stop"])},"E",10,ta),d("button",{title:h.locked?"Unlock":"Lock",class:Oe({dim:!h.locked}),onClick:qe(f=>A(t).toggleLayerLock(h.id),["stop"])},"L",10,na),d("button",{title:"Delete layer",class:"del",onClick:qe(f=>r(h.id),["stop"])},"x",8,sa)])],10,Yu))),128))])):(P(),O("div",Ju,"No world open"))]))}},oa=pt(la,[["__scopeId","data-v-3b97bf63"]]),ia={class:"entity-palette"},ra={class:"entity-list"},ua=["onClick"],aa={class:"icon"},ca={class:"label"},fa={__name:"EntityPalette",setup(e){const t=St(),n=[{id:"player_spawn",icon:"★",label:"Player Spawn"},{id:"enemy_spawn",icon:"☠",label:"Enemy Spawn"},{id:"item",icon:"◈",label:"Item"},{id:"trigger",icon:"⚡",label:"Trigger Zone"},{id:"light_point",icon:"☀",label:"Point Light"},{id:"camera_hint",icon:"⊡",label:"Camera Hint"},{id:"npc",icon:"☻",label:"NPC"},{id:"prop",icon:"◧",label:"Prop"}];function s(l){t.selectedEntityType=t.selectedEntityType===l?null:l,t.selectedEntityType&&t.setActiveTool("place_entity")}return(l,o)=>(P(),O("div",ia,[o[0]||(o[0]=d("div",{class:"panel-header"},"Entity Types",-1)),d("div",ra,[(P(),O(he,null,je(n,r=>d("div",{key:r.id,class:Oe(["entity-item",{selected:A(t).selectedEntityType===r.id}]),onClick:a=>s(r.id)},[d("span",aa,ne(r.icon),1),d("span",ca,ne(r.label),1)],10,ua)),64))])]))}},da=pt(fa,[["__scopeId","data-v-976066af"]]),pa={class:"tileset-panel"},ha={key:0,class:"empty"},va={key:0,class:"empty"},ya={key:1},ga=["value"],ma={key:0,class:"tileset-canvas-wrapper"},ba=["width","height"],_a={__name:"TilesetPanel",setup(e){const t=St(),n=Z(null),s=Z(null),l=xe(()=>{var D;return((D=t.world)==null?void 0:D.tilesets.find(m=>m.id===n.value))??null}),o=xe(()=>{var D;return((D=l.value)==null?void 0:D.tileWidth)??32}),r=xe(()=>{var D;return((D=l.value)==null?void 0:D.tileHeight)??32}),a=Z(null),p=Z(1),h=Z(1),f=xe(()=>p.value*o.value),y=xe(()=>h.value*r.value);bt(l,async D=>{if(!D)return;await yn();const m=new Image;m.onload=()=>{a.value=m,p.value=Math.floor(m.width/o.value),h.value=Math.floor(m.height/r.value),S()},m.src="/"+D.path}),bt(t,()=>{var D;!n.value&&((D=t.world)!=null&&D.tilesets.length)&&(n.value=t.world.tilesets[0].id)});function S(){var q;const D=s.value;if(!D||!a.value)return;const m=D.getContext("2d");m.drawImage(a.value,0,0),m.strokeStyle="rgba(0,0,0,0.4)",m.lineWidth=.5;for(let j=0;j<=p.value;j++)m.beginPath(),m.moveTo(j*o.value,0),m.lineTo(j*o.value,y.value),m.stroke();for(let j=0;j<=h.value;j++)m.beginPath(),m.moveTo(0,j*r.value),m.lineTo(f.value,j*r.value),m.stroke();if(((q=t.selectedTile)==null?void 0:q.tilesetId)===n.value){const{tx:j,ty:N}=t.selectedTile;m.strokeStyle="#4a4aff",m.lineWidth=2,m.strokeRect(j*o.value+1,N*r.value+1,o.value-2,r.value-2)}}function $(D){const m=s.value.getBoundingClientRect(),q=f.value/m.width,j=y.value/m.height,N=Math.floor((D.clientX-m.left)*q/o.value),B=Math.floor((D.clientY-m.top)*j/r.value);t.selectedTile={tilesetId:n.value,tx:N,ty:B},t.setActiveTool("place_tile"),S()}return(D,m)=>(P(),O("div",pa,[m[1]||(m[1]=d("div",{class:"panel-header"},"Tilesets",-1)),A(t).world?(P(),O(he,{key:1},[A(t).world.tilesets.length?(P(),O("div",ya,[Ut(d("select",{"onUpdate:modelValue":m[0]||(m[0]=q=>n.value=q),class:"tileset-select"},[(P(!0),O(he,null,je(A(t).world.tilesets,q=>(P(),O("option",{key:q.id,value:q.id},ne(q.id),9,ga))),128))],512),[[Mo,n.value]]),l.value?(P(),O("div",ma,[d("canvas",{ref_key:"canvasRef",ref:s,width:f.value,height:y.value,onClick:$},null,8,ba)])):ye("",!0)])):(P(),O("div",va," No tilesets — add one to the world JSON. "))],64)):(P(),O("div",ha,"No world open"))]))}},wa=pt(_a,[["__scopeId","data-v-c873b828"]]),xa={key:0,class:"coords"},Sa={key:1,class:"placeholder"},Ca={__name:"EditorCanvas",setup(e){const t=St(),n=Z(null),s=Z(null),l=hn({x:0,y:0,zoom:1});let o=!1,r=null,a=!1,p=!1,h=null,f={x:0,y:0},y=null;const S=Z(null),$=Z({x:0,y:0}),D={};let m=null;Vt(()=>{m=new ResizeObserver(q),m.observe(n.value),q(),window.addEventListener("keydown",Se)}),qn(()=>{m==null||m.disconnect(),window.removeEventListener("keydown",Se)});function q(){const _=s.value,T=n.value;!_||!T||(_.width=T.clientWidth,_.height=T.clientHeight,B())}function j(_,T){const Y=s.value;return{x:(_-Y.width/2)/l.zoom+l.x,y:(T-Y.height/2)/l.zoom+l.y}}function N(_,T){var L;const Y=((L=t.world)==null?void 0:L.meta.tileSize)??32;return{x:Math.floor(_/Y),y:Math.floor(T/Y)}}bt(()=>[t.world,t.activeLayerId,t.selectedEntityId,t.selectedTool],B,{deep:!0});function B(){const _=s.value;if(!_)return;const T=_.getContext("2d"),Y=_.width,L=_.height;if(T.clearRect(0,0,Y,L),!t.world)return;T.save(),T.translate(Y/2,L/2),T.scale(l.zoom,l.zoom),T.translate(-l.x,-l.y);const M=t.world.meta.tileSize??32,H=t.config.gridWidth??32,ve=t.config.gridHeight??32;T.strokeStyle="rgba(255,255,255,0.06)",T.lineWidth=.5/l.zoom;for(let G=0;G<=H;G++)T.beginPath(),T.moveTo(G*M,0),T.lineTo(G*M,ve*M),T.stroke();for(let G=0;G<=ve;G++)T.beginPath(),T.moveTo(0,G*M),T.lineTo(H*M,G*M),T.stroke();T.strokeStyle="rgba(120,120,255,0.3)",T.lineWidth=1/l.zoom,T.strokeRect(0,0,H*M,ve*M);for(const G of t.world.layers)G.visible&&(G.type==="tile"?F(T,G,M):G.type==="entity"&&K(T,G,M));if(S.value&&!p){const G=S.value,ie=t.activeLayer;if((ie==null?void 0:ie.type)==="tile"&&t.selectedTool==="place_tile"){const ke=$.value.x,ht=$.value.y;T.fillStyle="rgba(120,120,255,0.25)",T.fillRect(ke*M,ht*M,M,M)}else(ie==null?void 0:ie.type)==="entity"&&t.selectedTool==="place_entity"&&(T.strokeStyle="#4a4aff",T.lineWidth=1.5/l.zoom,T.beginPath(),T.arc(G.x,G.y,M/2-2,0,Math.PI*2),T.stroke())}T.restore()}function F(_,T,Y){if(T.tiles)for(const[L,M]of Object.entries(T.tiles)){const[H,ve]=L.split(",").map(Number),G=t.world.tilesets.find(ie=>ie.id===M.tilesetId);if(G){let ie=D[G.path];if(!ie){ie=new Image,ie.src="/"+G.path,ie.onload=()=>{D[G.path]=ie,B()},D[G.path]=ie;continue}if(!ie.complete)continue;_.drawImage(ie,M.tx*(G.tileWidth??Y),M.ty*(G.tileHeight??Y),G.tileWidth??Y,G.tileHeight??Y,H*Y,ve*Y,Y,Y)}else _.fillStyle="#445566",_.fillRect(H*Y+1,ve*Y+1,Y-2,Y-2)}}function K(_,T,Y){if(!T.entities)return;const L=Y*.4;for(const M of T.entities){const{x:H,y:ve}=M.position,G=M.id===t.selectedEntityId;_.save(),_.translate(H,ve),_.rotate((M.rotation??0)*Math.PI/180),_.beginPath(),_.arc(0,0,L,0,Math.PI*2),_.fillStyle=G?"#4a4aff":"#336",_.fill(),_.strokeStyle=G?"#aaf":"#88f",_.lineWidth=1.5/l.zoom,_.stroke(),_.beginPath(),_.moveTo(0,0),_.lineTo(L,0),_.strokeStyle=G?"#fff":"#aaa",_.lineWidth=1/l.zoom,_.stroke(),G&&(_.strokeStyle="#7b8ff5",_.lineWidth=1/l.zoom,_.setLineDash([3/l.zoom,3/l.zoom]),_.strokeRect(-L-4/l.zoom,-L-4/l.zoom,(L+4/l.zoom)*2,(L+4/l.zoom)*2),_.setLineDash([])),_.rotate(-(M.rotation??0)*Math.PI/180),_.fillStyle="#eee",_.font=`${11/l.zoom}px system-ui`,_.textAlign="center",_.fillText(M.name||M.type,0,L+12/l.zoom),_.restore()}}function ae(_,T=16){if(!t.world)return null;for(let Y=t.world.layers.length-1;Y>=0;Y--){const L=t.world.layers[Y];if(!(L.type!=="entity"||!L.visible||!L.entities))for(let M=L.entities.length-1;M>=0;M--){const H=L.entities[M],ve=H.position.x-_.x,G=H.position.y-_.y;if(Math.sqrt(ve*ve+G*G)<=T)return H}}return null}function le(_){const T=j(_.offsetX,_.offsetY),Y=N(T.x,T.y);if(_.button===1||_.button===0&&_.altKey){o=!0,r={mx:_.clientX,my:_.clientY,cx:l.x,cy:l.y};return}if(_.button===0&&t.selectedTool==="select"){const L=ae(T);if(L){t.selectEntityAt(T.x,T.y),p=!0,h=L.id,f={x:L.position.x-T.x,y:L.position.y-T.y},y={x:L.position.x,y:L.position.y};return}t.selectEntityAt(T.x,T.y),B();return}_.button===0&&(a=!0,we(T,Y))}function z(_){const T=j(_.offsetX,_.offsetY);if(S.value=T,$.value=N(T.x,T.y),o&&r){const Y=(_.clientX-r.mx)/l.zoom,L=(_.clientY-r.my)/l.zoom;l.x=r.cx-Y,l.y=r.cy-L,B();return}if(p&&h!=null){t.moveEntityTo(h,T.x+f.x,T.y+f.y),B();return}a&&we(T,$.value),B()}function ee(_){var T;if(o){o=!1,r=null;return}if(p){y&&(ae({x:0,y:0},1/0),(T=t.world)!=null&&T.layers.some(L=>{var M;return(M=L.entities)==null?void 0:M.some(H=>H.id===h&&(H.position.x!==y.x||H.position.y!==y.y))})&&t.snapshot()),p=!1,h=null,y=null;return}a&&(a=!1,["place_tile","erase"].includes(t.selectedTool)&&t.snapshot())}function fe(_){_.preventDefault();const T=_.deltaY<0?1.1:.9;l.zoom=Math.min(8,Math.max(.1,l.zoom*T)),B()}function we(_,T){const Y=t.selectedTool;if(Y==="place_tile")t.placeTile(T.x,T.y),B();else if(Y==="erase"){const L=t.activeLayer;(L==null?void 0:L.type)==="tile"?t.eraseTile(T.x,T.y):(L==null?void 0:L.type)==="entity"&&t.eraseEntityAt(_.x,_.y),B()}else Y==="place_entity"&&(t.placeEntity(_.x,_.y),B())}function Se(_){if((_.ctrlKey||_.metaKey)&&_.key==="z"&&!_.shiftKey&&(_.preventDefault(),t.undo()),(_.ctrlKey||_.metaKey)&&(_.key==="y"||_.shiftKey&&_.key==="z")&&(_.preventDefault(),t.redo()),(_.ctrlKey||_.metaKey)&&_.key==="s"&&(_.preventDefault(),t.saveCurrentWorld()),(_.ctrlKey||_.metaKey)&&_.key==="d"&&(_.preventDefault(),t.duplicateEntity()),_.key==="Delete"||_.key==="Backspace"){if(_.target.tagName==="INPUT"||_.target.tagName==="TEXTAREA"||_.target.tagName==="SELECT")return;t.selectedEntityId!=null&&(_.preventDefault(),t.deleteSelectedEntity(),B())}!_.ctrlKey&&!_.metaKey&&!_.altKey&&((_.key==="v"||_.key==="V")&&t.setActiveTool("select"),(_.key==="b"||_.key==="B")&&t.setActiveTool("place_tile"),(_.key==="e"||_.key==="E")&&t.setActiveTool("place_entity"),(_.key==="x"||_.key==="X")&&t.setActiveTool("erase"))}return(_,T)=>(P(),O("div",{class:"canvas-wrapper",ref_key:"wrapperRef",ref:n},[d("canvas",{ref_key:"canvasRef",ref:s,onMousedown:le,onMousemove:z,onMouseup:ee,onWheel:fe,onContextmenu:T[0]||(T[0]=qe(()=>{},["prevent"]))},null,544),S.value?(P(),O("div",xa,ne(Math.round(S.value.x))+", "+ne(Math.round(S.value.y))+"  |  grid "+ne($.value.x)+", "+ne($.value.y),1)):ye("",!0),A(t).world?ye("",!0):(P(),O("div",Sa," Open or create a world to start editing "))],512))}},ka=pt(Ca,[["__scopeId","data-v-efb5caef"]]),Ea={class:"inspector-panel"},Ta={key:0,class:"empty"},Ia={class:"section"},$a=["value"],Pa=["value"],Oa={class:"entity-id"},Aa={class:"section"},Da={class:"row2"},Ma=["value"],Ra=["value"],La={class:"section"},Na=["value"],Fa={class:"row2"},ja=["value"],Wa=["value"],Ha={class:"section"},Ka={class:"prop-key"},Ua=["value","onInput"],Ba=["onClick"],Va={class:"section actions"},za={class:"section"},Ja=["value"],qa=["value"],Ya=["value"],Ga={class:"section"},Xa={class:"row2"},Za=["value"],Qa=["value"],ec=["value"],tc={key:3,class:"empty"},nc={__name:"InspectorPanel",setup(e){const t=St();function n(p,h){const f={...t.selectedEntity.position,[p]:+h.target.value};t.updateSelectedEntity({position:f})}function s(p,h){const f={...t.selectedEntity.scale,[p]:+h.target.value};t.updateSelectedEntity({scale:f})}function l(p,h){const f={...t.selectedEntity.properties,[p]:h};t.updateSelectedEntity({properties:f})}function o(){const p=prompt("Property name:");if(!p)return;const h={...t.selectedEntity.properties,[p]:""};t.updateSelectedEntity({properties:h})}function r(p){const h={...t.selectedEntity.properties};delete h[p],t.updateSelectedEntity({properties:h})}function a(){confirm("Delete this entity?")&&t.deleteSelectedEntity()}return(p,h)=>(P(),O("div",Ea,[h[34]||(h[34]=d("div",{class:"panel-header"},"Inspector",-1)),A(t).world?A(t).selectedEntity?(P(),O(he,{key:1},[d("div",Ia,[h[16]||(h[16]=d("div",{class:"section-title"},"Entity",-1)),d("label",null,[h[14]||(h[14]=J("Name ",-1)),d("input",{value:A(t).selectedEntity.name,onInput:h[0]||(h[0]=f=>A(t).updateSelectedEntity({name:f.target.value}))},null,40,$a)]),d("label",null,[h[15]||(h[15]=J("Type ",-1)),d("input",{value:A(t).selectedEntity.type,onInput:h[1]||(h[1]=f=>A(t).updateSelectedEntity({type:f.target.value}))},null,40,Pa)]),d("div",Oa,"ID: "+ne(A(t).selectedEntity.id),1)]),d("div",Aa,[h[19]||(h[19]=d("div",{class:"section-title"},"Position",-1)),d("div",Da,[d("label",null,[h[17]||(h[17]=J("X ",-1)),d("input",{type:"number",step:"1",value:A(t).selectedEntity.position.x,onInput:h[2]||(h[2]=f=>n("x",f))},null,40,Ma)]),d("label",null,[h[18]||(h[18]=J("Y ",-1)),d("input",{type:"number",step:"1",value:A(t).selectedEntity.position.y,onInput:h[3]||(h[3]=f=>n("y",f))},null,40,Ra)])])]),d("div",La,[h[23]||(h[23]=d("div",{class:"section-title"},"Transform",-1)),d("label",null,[h[20]||(h[20]=J("Rotation (deg) ",-1)),d("input",{type:"number",step:"1",value:A(t).selectedEntity.rotation,onInput:h[4]||(h[4]=f=>A(t).updateSelectedEntity({rotation:+f.target.value}))},null,40,Na)]),d("div",Fa,[d("label",null,[h[21]||(h[21]=J("Scale X ",-1)),d("input",{type:"number",step:"0.1",min:"0.01",value:A(t).selectedEntity.scale.x,onInput:h[5]||(h[5]=f=>s("x",f))},null,40,ja)]),d("label",null,[h[22]||(h[22]=J("Scale Y ",-1)),d("input",{type:"number",step:"0.1",min:"0.01",value:A(t).selectedEntity.scale.y,onInput:h[6]||(h[6]=f=>s("y",f))},null,40,Wa)])])]),d("div",Ha,[h[24]||(h[24]=d("div",{class:"section-title"},"Properties",-1)),(P(!0),O(he,null,je(A(t).selectedEntity.properties,(f,y)=>(P(),O("div",{key:y,class:"prop-row"},[d("span",Ka,ne(y),1),d("input",{class:"prop-val",value:f,onInput:S=>l(y,S.target.value)},null,40,Ua),d("button",{class:"prop-del",onClick:S=>r(y),title:"Remove property"},"x",8,Ba)]))),128)),d("button",{class:"add-prop",onClick:o},"+ Add property")]),d("div",Va,[d("button",{class:"action-btn duplicate",onClick:h[7]||(h[7]=(...f)=>A(t).duplicateEntity&&A(t).duplicateEntity(...f))},"Duplicate (Ctrl+D)"),d("button",{class:"action-btn delete",onClick:a},"Delete (Del)")])],64)):A(t).world?(P(),O(he,{key:2},[d("div",za,[h[29]||(h[29]=d("div",{class:"section-title"},"World",-1)),d("label",null,[h[25]||(h[25]=J("Name ",-1)),d("input",{value:A(t).world.meta.name,onInput:h[8]||(h[8]=f=>{A(t).world.meta.name=f.target.value,A(t).isDirty=!0})},null,40,Ja)]),d("label",null,[h[27]||(h[27]=J("Type ",-1)),d("select",{value:A(t).world.meta.type,onChange:h[9]||(h[9]=f=>{A(t).world.meta.type=f.target.value,A(t).isDirty=!0})},[...h[26]||(h[26]=[d("option",{value:"2d_topdown"},"2D Top-down",-1),d("option",{value:"2d_platformer"},"2D Platformer",-1),d("option",{value:"3d"},"3D",-1)])],40,qa)]),d("label",null,[h[28]||(h[28]=J("Tile Size ",-1)),d("input",{type:"number",min:"1",value:A(t).world.meta.tileSize,onInput:h[10]||(h[10]=f=>{A(t).world.meta.tileSize=+f.target.value,A(t).isDirty=!0})},null,40,Ya)])]),d("div",Ga,[h[33]||(h[33]=d("div",{class:"section-title"},"Camera",-1)),d("div",Xa,[d("label",null,[h[30]||(h[30]=J("X ",-1)),d("input",{type:"number",value:A(t).world.camera.position.x,onInput:h[11]||(h[11]=f=>{A(t).world.camera.position.x=+f.target.value,A(t).isDirty=!0})},null,40,Za)]),d("label",null,[h[31]||(h[31]=J("Y ",-1)),d("input",{type:"number",value:A(t).world.camera.position.y,onInput:h[12]||(h[12]=f=>{A(t).world.camera.position.y=+f.target.value,A(t).isDirty=!0})},null,40,Qa)])]),d("label",null,[h[32]||(h[32]=J("Zoom ",-1)),d("input",{type:"number",step:"0.1",min:"0.1",value:A(t).world.camera.zoom,onInput:h[13]||(h[13]=f=>{A(t).world.camera.zoom=+f.target.value,A(t).isDirty=!0})},null,40,ec)])])],64)):(P(),O("div",tc,"Select an entity to inspect")):(P(),O("div",Ta,"No world open"))]))}},sc=pt(nc,[["__scopeId","data-v-b8740aea"]]),lc={class:"asset-browser"},oc={class:"breadcrumb"},ic=["onClick"],rc={key:0,class:"loading"},uc={key:1,class:"file-list"},ac=["onClick","onDblclick"],cc={class:"file-icon"},fc={class:"file-name"},dc={key:0,class:"file-size"},pc={key:1,class:"empty"},hc={key:2,class:"preview-bar"},vc={class:"preview-path"},yc={__name:"AssetBrowser",setup(e){const t=St(),n=Z(null),s=xe(()=>t.assetPath?t.assetPath.split("/").filter(Boolean):[]);Vt(()=>t.browseAssets(""));function l(f){n.value=null,t.browseAssets(f)}function o(){const f=t.assetPath.split("/").filter(Boolean);f.pop(),l(f.join("/"))}function r(f){f.type==="directory"?l(f.path):n.value=f.path}function a(f){var y;f.type!=="directory"&&((y=navigator.clipboard)==null||y.writeText(f.path))}function p(f){switch(f){case"directory":return"📁";case"image":return"🖼";case"shader":return"✨";case"model":return"📦";case"audio":return"🔊";case"font":return"Aa";case"json":return"{}";default:return"📄"}}function h(f){return f<1024?f+" B":f<1024*1024?(f/1024).toFixed(1)+" KB":(f/(1024*1024)).toFixed(1)+" MB"}return(f,y)=>(P(),O("div",lc,[y[3]||(y[3]=d("div",{class:"panel-header"},"Assets",-1)),d("div",oc,[d("span",{class:"crumb",onClick:y[0]||(y[0]=S=>l(""))},"resources"),(P(!0),O(he,null,je(s.value,(S,$)=>(P(),O(he,{key:$},[y[1]||(y[1]=d("span",{class:"sep"},"/",-1)),d("span",{class:"crumb",onClick:D=>l(s.value.slice(0,$+1).join("/"))},ne(S),9,ic)],64))),128))]),A(t).assetLoading?(P(),O("div",rc,"Loading...")):(P(),O("div",uc,[A(t).assetPath?(P(),O("div",{key:0,class:"file-item dir",onClick:o},[...y[2]||(y[2]=[d("span",{class:"file-icon"},"←",-1),d("span",{class:"file-name"},"..",-1)])])):ye("",!0),(P(!0),O(he,null,je(A(t).assetEntries,S=>(P(),O("div",{key:S.path,class:Oe(["file-item",{dir:S.type==="directory",selected:n.value===S.path}]),onClick:$=>r(S),onDblclick:$=>a(S)},[d("span",cc,ne(p(S.type)),1),d("span",fc,ne(S.name),1),S.size?(P(),O("span",dc,ne(h(S.size)),1)):ye("",!0)],42,ac))),128)),!A(t).assetEntries.length&&!A(t).assetLoading?(P(),O("div",pc," Empty directory ")):ye("",!0)])),n.value?(P(),O("div",hc,[d("span",vc,ne(n.value),1)])):ye("",!0)]))}},gc=pt(yc,[["__scopeId","data-v-246e2e97"]]),mc={class:"ui-editor"},bc={class:"toolbar"},_c={class:"toolbar-group"},wc=["disabled"],xc={key:0,class:"layout-name"},Sc={class:"toolbar-group right"},Cc=["disabled"],kc=["disabled"],Ec={class:"editor-body"},Tc={class:"panel-left"},Ic={class:"panel-header"},$c={key:0,class:"add-root-group"},Pc=["value"],Oc={key:0,class:"tree-container"},Ac={key:1,class:"empty-hint"},Dc={key:2,class:"tree-actions"},Mc=["disabled"],Rc=["disabled"],Lc={class:"panel-center"},Nc={class:"preview-scroll"},Fc={class:"preview-canvas"},jc={class:"panel-right"},Wc={key:0,class:"props-container"},Hc={class:"section"},Kc={class:"type-badge"},Uc={class:"section"},Bc=["value"],Vc=["value"],zc={class:"section"},Jc=["value"],qc={class:"section"},Yc={class:"row2"},Gc=["value"],Xc=["value"],Zc={class:"row2"},Qc=["value"],ef=["value"],tf={class:"section"},nf=["value"],sf=["value"],lf={class:"section"},of={class:"color-row"},rf=["value"],uf=["value"],af={key:1,class:"section"},cf=["value"],ff=["value"],df={class:"color-row"},pf=["value"],hf=["value"],vf={class:"checkbox-label"},yf=["checked"],gf={key:2,class:"section"},mf=["value"],bf=["value"],_f=["value"],wf=["value"],xf={class:"checkbox-label"},Sf=["checked"],Cf=["value"],kf={key:3,class:"section"},Ef=["value"],Tf={class:"color-row"},If=["value"],$f=["value"],Pf=["value"],Of={key:4,class:"section"},Af=["value"],Df=["value"],Mf=["value"],Rf=["value"],Lf={key:5,class:"section"},Nf=["value"],Ff=["value"],jf=["value"],Wf=["value","onInput"],Hf=["onClick"],Kf={key:6,class:"section"},Uf={class:"row2"},Bf=["value"],Vf=["value"],zf={class:"color-row"},Jf=["value"],qf=["value"],Yf={key:7,class:"section"},Gf={class:"row2"},Xf=["value"],Zf=["value"],Qf={class:"section"},ed={class:"json-preview"},td={key:1,class:"empty-hint"},nd={class:"popup-box"},sd={class:"new-row"},ld={key:0,class:"layout-list"},od=["onClick"],id={key:0},rd={key:1,class:"empty-msg"},ud={__name:"UILayoutEditor",setup(e){const t=["panel","label","button","progressbar","checkbox","select","image","space"];function n(v){switch(v){case"panel":return{type:"panel",layout:"column",children:[]};case"label":return{type:"label",text:"Label"};case"button":return{type:"button",label:"Button",event:"ui.click"};case"progressbar":return{type:"progressbar",value:"0.5",color:"#0088ff"};case"checkbox":return{type:"checkbox",text:"Checkbox"};case"select":return{type:"select",name:"my_select",options:["Option A","Option B"]};case"image":return{type:"image",width:64,height:64};case"space":return{type:"space",height:10};default:return{type:v}}}const s=Z(null),l=Z(null),o=Z(!1),r=Z([]),a=Z(!1),p=Z(""),h=Z([]),f=Z("panel"),y=Z([]),S=Z(-1);function $(v){if(!s.value)return null;let u=s.value;for(const i of v){if(!u.children||i>=u.children.length)return null;u=u.children[i]}return u}function D(v){if(!s.value||v.length===0)return{parent:null,index:-1};const u=v.slice(0,-1);return{parent:$(u),index:v[v.length-1]}}const m=xe(()=>$(r.value)),q=xe(()=>r.value.length===0?!1:r.value[r.value.length-1]>0),j=xe(()=>{if(r.value.length===0)return!1;const{parent:v,index:u}=D(r.value);return!v||!v.children?!1:u=y.value.length-1||(S.value++,s.value=JSON.parse(y.value[S.value]),o.value=!0)}function K(v,u){const i=m.value;i&&(i[v]=u,N())}function ae(v,u){const i=m.value;i&&(u===""||u===null||u===void 0?delete i[v]:i[v]=u,N())}function le(v,u){const i=m.value;i&&(i[v]=+u,N())}function z(v,u){const i=m.value;i&&(u===""||u===null||u===void 0?delete i[v]:i[v]=+u,N())}function ee(v,u){const i=m.value;if(i)try{i[v]=JSON.parse(u),N()}catch{}}function fe(v,u){const i=m.value;!i||!i.options||(i.options[v]=u,N())}function we(v){const u=m.value;!u||!u.options||(u.options.splice(v,1),N())}function Se(){const v=m.value;v&&(v.options||(v.options=[]),v.options.push("New Option"),N())}function _(v){r.value=[...v]}function T(){const v=m.value,u=n(f.value);if(v&&(v.type==="panel"||v.children))v.children||(v.children=[]),v.children.push(u),r.value=[...r.value,v.children.length-1];else if(r.value.length===0&&s.value)s.value.children||(s.value.children=[]),s.value.children.push(u),r.value=[s.value.children.length-1];else if(r.value.length>0){const{parent:i,index:c}=D(r.value);if(i&&i.children){i.children.splice(c+1,0,u);const g=[...r.value];g[g.length-1]=c+1,r.value=g}}N()}function Y(){if(r.value.length===0)return;const{parent:v,index:u}=D(r.value);!v||!v.children||(v.children.splice(u,1),r.value=r.value.slice(0,-1),N())}function L(){if(!q.value)return;const{parent:v,index:u}=D(r.value);if(!v||!v.children)return;const i=v.children[u];v.children[u]=v.children[u-1],v.children[u-1]=i;const c=[...r.value];c[c.length-1]=u-1,r.value=c,N()}function M(){if(!j.value)return;const{parent:v,index:u}=D(r.value);if(!v||!v.children)return;const i=v.children[u];v.children[u]=v.children[u+1],v.children[u+1]=i;const c=[...r.value];c[c.length-1]=u+1,r.value=c,N()}const H=Z(null);function ve(v){H.value=v}function G(v){if(!H.value||!s.value)return;const u=H.value.join(",");if(v.join(",").startsWith(u))return;const{parent:c,index:g}=D(H.value);if(!c||!c.children)return;const w=c.children[g];c.children.splice(g,1);const b=$(v);if(b&&(b.type==="panel"||b.children))b.children||(b.children=[]),b.children.push(w),r.value=[...v,b.children.length-1];else{const{parent:x,index:k}=D(v);x&&x.children&&x.children.splice(k+1,0,w)}H.value=null,N()}function ie(){o.value&&!confirm("Discard unsaved changes?")||(s.value=n("panel"),s.value.padding=10,l.value="untitled",o.value=!0,r.value=[],y.value=[JSON.stringify(s.value)],S.value=0)}async function ke(){try{h.value=await ku()}catch{h.value=[]}}async function ht(v){if(!(o.value&&!confirm("Discard unsaved changes?")))try{const u=await Eu(v);s.value=u,l.value=v,o.value=!1,r.value=[],y.value=[JSON.stringify(u)],S.value=0,a.value=!1}catch(u){alert("Failed to load layout: "+u.message)}}function At(){const v=p.value.trim().toLowerCase().replace(/\s+/g,"_")||"untitled";o.value&&!confirm("Discard unsaved changes?")||(s.value=n("panel"),s.value.padding=10,l.value=v,o.value=!0,r.value=[],y.value=[JSON.stringify(s.value)],S.value=0,a.value=!1,p.value="")}async function Dt(){if(!(!s.value||!l.value))try{await Tu(l.value,s.value),o.value=!1}catch(v){alert("Failed to save: "+v.message)}}bt(a,v=>{v&&ke()});function Qe(v){(v.ctrlKey||v.metaKey)&&(v.key==="z"&&(v.preventDefault(),B()),v.key==="y"&&(v.preventDefault(),F()),v.key==="s"&&(v.preventDefault(),Dt())),v.key==="Delete"&&r.value.length>0&&Y()}Vt(()=>{window.addEventListener("keydown",Qe)});const et=Js({name:"TreeNode",props:{node:{type:Object,required:!0},path:{type:Array,required:!0},selectedPath:{type:Array,required:!0},depth:{type:Number,default:0}},emits:["select","dragstart","drop"],setup(v,{emit:u}){const i=Z(!1),c=xe(()=>v.selectedPath.length===v.path.length&&v.selectedPath.every((b,x)=>b===v.path[x])),g=xe(()=>v.node.children&&v.node.children.length>0),w=xe(()=>{const b=v.node;switch(b.type){case"label":return`label: "${(b.text||"").substring(0,20)}"`;case"button":return`button: "${(b.label||"").substring(0,20)}"`;case"panel":return`panel (${b.layout||"column"})`;case"progressbar":return"progressbar";case"checkbox":return`checkbox: "${(b.text||"").substring(0,20)}"`;case"select":return`select: ${b.name||"?"}`;case"image":return`image ${b.width||64}x${b.height||64}`;case"space":return"space";default:return b.type}});return()=>{const b=v.depth*16,x=[];return x.push(me("div",{class:["tree-row",{selected:c.value}],style:{paddingLeft:b+"px"},draggable:v.path.length>0,onClick:k=>{k.stopPropagation(),u("select",v.path)},onDragstart:k=>{k.stopPropagation(),u("dragstart",v.path)},onDragover:k=>{k.preventDefault()},onDrop:k=>{k.preventDefault(),k.stopPropagation(),u("drop",v.path)}},[g.value?me("span",{class:["tree-arrow",{open:!i.value}],onClick:k=>{k.stopPropagation(),i.value=!i.value}},"▶"):me("span",{class:"tree-arrow-placeholder"}),me("span",{class:"tree-icon"},Ct(v.node.type)),me("span",{class:"tree-label"},w.value)])),g.value&&!i.value&&v.node.children.forEach((k,I)=>{x.push(me(et,{key:I,node:k,path:[...v.path,I],selectedPath:v.selectedPath,depth:v.depth+1,onSelect:E=>u("select",E),onDragstart:E=>u("dragstart",E),onDrop:E=>u("drop",E)}))}),me("div",{class:"tree-node"},x)}}});function Ct(v){switch(v){case"panel":return"□";case"label":return"T";case"button":return"▣";case"progressbar":return"━";case"checkbox":return"☑";case"select":return"▾";case"image":return"▨";case"space":return"┈";default:return"?"}}const kt=Js({name:"PreviewNode",props:{node:{type:Object,required:!0},selectedPath:{type:Array,required:!0},currentPath:{type:Array,required:!0}},emits:["select"],setup(v,{emit:u}){const i=xe(()=>v.selectedPath.length===v.currentPath.length&&v.selectedPath.every((c,g)=>c===v.currentPath[g]));return()=>{const c=v.node,g=i.value,w=b=>{b.stopPropagation(),u("select",v.currentPath)};switch(c.type){case"panel":{const x={display:"flex",flexDirection:c.layout==="row"?"row":"column",padding:(c.padding||0)+"px",gap:(c.spacing||0)+"px",backgroundColor:c.backgroundColor||"transparent",border:g?"2px solid #7b8ff5":"1px dashed #444",borderRadius:"3px",minHeight:"24px",minWidth:"24px"};c.width&&(x.width=c.width+"px"),c.height&&(x.height=c.height+"px"),!c.width&&(c.horizontalSizing||"fill")==="fill"&&(x.width="100%");const k=(c.children||[]).map((I,E)=>me(kt,{key:E,node:I,selectedPath:v.selectedPath,currentPath:[...v.currentPath,E],onSelect:C=>u("select",C)}));return me("div",{class:"pv-panel",style:x,onClick:w},k)}case"label":{const b={fontSize:(c.fontSize||14)+"px",color:c.color||"#eee",fontWeight:c.bold?"bold":"normal",border:g?"1px solid #7b8ff5":"1px solid transparent",padding:"2px 4px",cursor:"pointer"};return me("div",{class:"pv-label",style:b,onClick:w},c.text||"Label")}case"button":{const b={background:c.style==="secondary"?"#333":"#4a4aff",color:"#fff",border:g?"2px solid #7b8ff5":"1px solid #555",borderRadius:"4px",padding:"6px 14px",cursor:"pointer",fontSize:"12px",textAlign:"center",width:c.fullWidth?"100%":"auto"};return me("div",{class:"pv-button",style:b,onClick:w},c.label||"Button")}case"progressbar":{const b=parseFloat(c.value)||0,x=Math.min(100,Math.max(0,b*100)),k={height:(c.height||18)+"px",background:"#222",borderRadius:"3px",overflow:"hidden",border:g?"2px solid #7b8ff5":"1px solid #444",width:"100%",cursor:"pointer"},I={width:x+"%",height:"100%",background:c.color||"#0088ff",transition:"width 0.2s"};return me("div",{class:"pv-progressbar",style:k,onClick:w},[me("div",{style:I})])}case"checkbox":return me("div",{class:"pv-checkbox",style:{display:"flex",alignItems:"center",gap:"6px",fontSize:"12px",color:"#ddd",border:g?"1px solid #7b8ff5":"1px solid transparent",padding:"2px 4px",cursor:"pointer"},onClick:w},[me("span",{style:{display:"inline-block",width:"14px",height:"14px",border:"1px solid #888",borderRadius:"2px",background:"#2a2a3e"}}),me("span",{},c.text||"Checkbox")]);case"select":{const b={background:"#2a2a3e",border:g?"2px solid #7b8ff5":"1px solid #555",borderRadius:"3px",padding:"4px 8px",color:"#ddd",fontSize:"12px",cursor:"pointer",minWidth:"80px"},x=c.selected||c.options&&c.options[0]||c.name||"Select";return me("div",{class:"pv-select",style:b,onClick:w},x+" ▾")}case"image":{const b={width:(c.width||64)+"px",height:(c.height||64)+"px",background:c.color||"#555",borderRadius:"4px",border:g?"2px solid #7b8ff5":"1px solid #444",display:"flex",alignItems:"center",justifyContent:"center",color:"#999",fontSize:"10px",cursor:"pointer"};return me("div",{class:"pv-image",style:b,onClick:w},"IMG")}case"space":{const b={width:(c.width||0)+"px",height:(c.height||0)+"px",minWidth:"4px",minHeight:"4px",border:g?"1px solid #7b8ff5":"1px dashed #333",cursor:"pointer"};return me("div",{class:"pv-space",style:b,onClick:w})}default:return me("div",{onClick:w},"[unknown: "+c.type+"]")}}}});return(v,u)=>(P(),O("div",mc,[d("div",bc,[u[43]||(u[43]=d("span",{class:"toolbar-title"},"UI Layout Editor",-1)),d("div",_c,[d("button",{onClick:ie},"New"),d("button",{onClick:u[0]||(u[0]=i=>a.value=!0)},"Open"),d("button",{onClick:Dt,disabled:!s.value,class:Oe({dirty:o.value})}," Save"+ne(o.value?"*":""),11,wc)]),l.value?(P(),O("span",xc,ne(l.value),1)):ye("",!0),d("div",Sc,[d("button",{onClick:B,disabled:S.value<=0,title:"Undo"},"↩",8,Cc),d("button",{onClick:F,disabled:S.value>=y.value.length-1,title:"Redo"},"↪",8,kc)])]),d("div",Ec,[d("div",Tc,[d("div",Ic,[u[44]||(u[44]=d("span",null,"Hierarchy",-1)),s.value?(P(),O("div",$c,[Ut(d("select",{"onUpdate:modelValue":u[1]||(u[1]=i=>f.value=i),class:"add-select"},[(P(),O(he,null,je(t,i=>d("option",{key:i,value:i},ne(i),9,Pc)),64))],512),[[Mo,f.value]]),d("button",{class:"add-btn",onClick:T,title:"Add child node"},"+")])):ye("",!0)]),s.value?(P(),O("div",Oc,[be(A(et),{node:s.value,path:[],selectedPath:r.value,depth:0,onSelect:_,onDragstart:ve,onDrop:G},null,8,["node","selectedPath"])])):(P(),O("div",Ac,"No layout loaded")),m.value&&r.value.length>0?(P(),O("div",Dc,[d("button",{class:"action-btn delete",onClick:Y},"Remove Node"),d("button",{class:"action-btn",onClick:L,disabled:!q.value},"Move Up",8,Mc),d("button",{class:"action-btn",onClick:M,disabled:!j.value},"Move Down",8,Rc)])):ye("",!0)]),d("div",Lc,[u[45]||(u[45]=d("div",{class:"panel-header"},"Preview",-1)),d("div",Nc,[d("div",Fc,[s.value?(P(),On(A(kt),{key:0,node:s.value,selectedPath:r.value,currentPath:[],onSelect:_},null,8,["node","selectedPath"])):ye("",!0)])])]),d("div",jc,[u[97]||(u[97]=d("div",{class:"panel-header"},"Properties",-1)),m.value?(P(),O("div",Wc,[d("div",Hc,[u[46]||(u[46]=d("div",{class:"section-title"},"Node Type",-1)),d("div",Kc,ne(m.value.type),1)]),d("div",Uc,[u[47]||(u[47]=d("div",{class:"section-title"},"Type",-1)),d("select",{value:m.value.type,onChange:u[2]||(u[2]=i=>K("type",i.target.value))},[(P(),O(he,null,je(t,i=>d("option",{key:i,value:i},ne(i),9,Vc)),64))],40,Bc)]),m.value.type==="panel"?(P(),O(he,{key:0},[d("div",zc,[u[49]||(u[49]=d("div",{class:"section-title"},"Layout",-1)),d("select",{value:m.value.layout||"column",onChange:u[3]||(u[3]=i=>K("layout",i.target.value))},[...u[48]||(u[48]=[d("option",{value:"column"},"Column",-1),d("option",{value:"row"},"Row",-1)])],40,Jc)]),d("div",qc,[u[56]||(u[56]=d("div",{class:"section-title"},"Sizing",-1)),d("div",Yc,[d("label",null,[u[50]||(u[50]=J("Width ",-1)),d("input",{type:"number",value:m.value.width||"",placeholder:"auto",onInput:u[4]||(u[4]=i=>z("width",i.target.value))},null,40,Gc)]),d("label",null,[u[51]||(u[51]=J("Height ",-1)),d("input",{type:"number",value:m.value.height||"",placeholder:"auto",onInput:u[5]||(u[5]=i=>z("height",i.target.value))},null,40,Xc)])]),d("div",Zc,[d("label",null,[u[53]||(u[53]=J("H Sizing ",-1)),d("select",{value:m.value.horizontalSizing||"fill",onChange:u[6]||(u[6]=i=>K("horizontalSizing",i.target.value))},[...u[52]||(u[52]=[d("option",{value:"fill"},"Fill",-1),d("option",{value:"fit"},"Fit",-1)])],40,Qc)]),d("label",null,[u[55]||(u[55]=J("V Sizing ",-1)),d("select",{value:m.value.verticalSizing||"fit",onChange:u[7]||(u[7]=i=>K("verticalSizing",i.target.value))},[...u[54]||(u[54]=[d("option",{value:"fill"},"Fill",-1),d("option",{value:"fit"},"Fit",-1)])],40,ef)])])]),d("div",tf,[u[59]||(u[59]=d("div",{class:"section-title"},"Spacing & Padding",-1)),d("label",null,[u[57]||(u[57]=J("Padding ",-1)),d("input",{type:"number",value:m.value.padding??"",placeholder:"0",onInput:u[8]||(u[8]=i=>z("padding",i.target.value))},null,40,nf)]),d("label",null,[u[58]||(u[58]=J("Spacing ",-1)),d("input",{type:"number",value:m.value.spacing??"",placeholder:"0",onInput:u[9]||(u[9]=i=>z("spacing",i.target.value))},null,40,sf)])]),d("div",lf,[u[61]||(u[61]=d("div",{class:"section-title"},"Background",-1)),d("label",null,[u[60]||(u[60]=J("Color ",-1)),d("div",of,[d("input",{type:"text",value:m.value.backgroundColor||"",placeholder:"#RRGGBB",onInput:u[10]||(u[10]=i=>ae("backgroundColor",i.target.value))},null,40,rf),d("input",{type:"color",value:m.value.backgroundColor||"#333333",class:"color-picker",onInput:u[11]||(u[11]=i=>K("backgroundColor",i.target.value))},null,40,uf)])])])],64)):ye("",!0),m.value.type==="label"?(P(),O("div",af,[u[66]||(u[66]=d("div",{class:"section-title"},"Text",-1)),d("label",null,[u[62]||(u[62]=J("Text ",-1)),d("input",{type:"text",value:m.value.text||"",placeholder:"Label text or {binding}",onInput:u[12]||(u[12]=i=>K("text",i.target.value))},null,40,cf)]),d("label",null,[u[63]||(u[63]=J("Font Size ",-1)),d("input",{type:"number",value:m.value.fontSize??"",placeholder:"14",onInput:u[13]||(u[13]=i=>z("fontSize",i.target.value))},null,40,ff)]),d("label",null,[u[64]||(u[64]=J("Color ",-1)),d("div",df,[d("input",{type:"text",value:m.value.color||"",placeholder:"#RRGGBB",onInput:u[14]||(u[14]=i=>ae("color",i.target.value))},null,40,pf),d("input",{type:"color",value:m.value.color||"#eeeeee",class:"color-picker",onInput:u[15]||(u[15]=i=>K("color",i.target.value))},null,40,hf)])]),d("label",vf,[d("input",{type:"checkbox",checked:!!m.value.bold,onChange:u[16]||(u[16]=i=>K("bold",i.target.checked))},null,40,yf),u[65]||(u[65]=J(" Bold ",-1))])])):ye("",!0),m.value.type==="button"?(P(),O("div",gf,[u[74]||(u[74]=d("div",{class:"section-title"},"Button",-1)),d("label",null,[u[67]||(u[67]=J("Label ",-1)),d("input",{type:"text",value:m.value.label||"",placeholder:"Button",onInput:u[17]||(u[17]=i=>K("label",i.target.value))},null,40,mf)]),d("label",null,[u[68]||(u[68]=J("Event ",-1)),d("input",{type:"text",value:m.value.event||"",placeholder:"ui.action",onInput:u[18]||(u[18]=i=>ae("event",i.target.value))},null,40,bf)]),d("label",null,[u[69]||(u[69]=J("Event Data (JSON) ",-1)),d("input",{type:"text",value:JSON.stringify(m.value.eventData||{}),placeholder:"{}",onInput:u[19]||(u[19]=i=>ee("eventData",i.target.value))},null,40,_f)]),d("label",null,[u[70]||(u[70]=J("ID ",-1)),d("input",{type:"text",value:m.value.id||"",placeholder:"optional",onInput:u[20]||(u[20]=i=>ae("id",i.target.value))},null,40,wf)]),d("label",xf,[d("input",{type:"checkbox",checked:!!m.value.fullWidth,onChange:u[21]||(u[21]=i=>K("fullWidth",i.target.checked))},null,40,Sf),u[71]||(u[71]=J(" Full Width ",-1))]),d("label",null,[u[73]||(u[73]=J("Style ",-1)),d("select",{value:m.value.style||"primary",onChange:u[22]||(u[22]=i=>ae("style",i.target.value==="primary"?"":i.target.value))},[...u[72]||(u[72]=[d("option",{value:"primary"},"Primary",-1),d("option",{value:"secondary"},"Secondary",-1)])],40,Cf)])])):ye("",!0),m.value.type==="progressbar"?(P(),O("div",kf,[u[78]||(u[78]=d("div",{class:"section-title"},"Progress Bar",-1)),d("label",null,[u[75]||(u[75]=J("Value ",-1)),d("input",{type:"text",value:m.value.value??"0",placeholder:"0.5 or {binding}",onInput:u[23]||(u[23]=i=>K("value",i.target.value))},null,40,Ef)]),d("label",null,[u[76]||(u[76]=J("Color ",-1)),d("div",Tf,[d("input",{type:"text",value:m.value.color||"",placeholder:"#0088ff",onInput:u[24]||(u[24]=i=>ae("color",i.target.value))},null,40,If),d("input",{type:"color",value:m.value.color||"#0088ff",class:"color-picker",onInput:u[25]||(u[25]=i=>K("color",i.target.value))},null,40,$f)])]),d("label",null,[u[77]||(u[77]=J("Height ",-1)),d("input",{type:"number",value:m.value.height??"",placeholder:"auto",onInput:u[26]||(u[26]=i=>z("height",i.target.value))},null,40,Pf)])])):ye("",!0),m.value.type==="checkbox"?(P(),O("div",Of,[u[83]||(u[83]=d("div",{class:"section-title"},"Checkbox",-1)),d("label",null,[u[79]||(u[79]=J("Text ",-1)),d("input",{type:"text",value:m.value.text||"",placeholder:"Checkbox label",onInput:u[27]||(u[27]=i=>K("text",i.target.value))},null,40,Af)]),d("label",null,[u[80]||(u[80]=J("ID ",-1)),d("input",{type:"text",value:m.value.id||"",placeholder:"optional",onInput:u[28]||(u[28]=i=>ae("id",i.target.value))},null,40,Df)]),d("label",null,[u[81]||(u[81]=J("Event ",-1)),d("input",{type:"text",value:m.value.event||"",placeholder:"ui.checkbox",onInput:u[29]||(u[29]=i=>ae("event",i.target.value))},null,40,Mf)]),d("label",null,[u[82]||(u[82]=J("Checked Binding ",-1)),d("input",{type:"text",value:m.value.checked??"",placeholder:"false or {binding}",onInput:u[30]||(u[30]=i=>ae("checked",i.target.value))},null,40,Rf)])])):ye("",!0),m.value.type==="select"?(P(),O("div",Lf,[u[87]||(u[87]=d("div",{class:"section-title"},"Select",-1)),d("label",null,[u[84]||(u[84]=J("Name ",-1)),d("input",{type:"text",value:m.value.name||"",placeholder:"select_name",onInput:u[31]||(u[31]=i=>K("name",i.target.value))},null,40,Nf)]),d("label",null,[u[85]||(u[85]=J("Event ",-1)),d("input",{type:"text",value:m.value.event||"",placeholder:"ui.select",onInput:u[32]||(u[32]=i=>ae("event",i.target.value))},null,40,Ff)]),d("label",null,[u[86]||(u[86]=J("Selected ",-1)),d("input",{type:"text",value:m.value.selected||"",placeholder:"default value",onInput:u[33]||(u[33]=i=>ae("selected",i.target.value))},null,40,jf)]),u[88]||(u[88]=d("div",{class:"section-title",style:{"margin-top":"8px"}},"Options",-1)),(P(!0),O(he,null,je(m.value.options||[],(i,c)=>(P(),O("div",{key:c,class:"option-row"},[d("input",{type:"text",value:i,onInput:g=>fe(c,g.target.value)},null,40,Wf),d("button",{class:"prop-del",onClick:g=>we(c)},"x",8,Hf)]))),128)),d("button",{class:"add-prop",onClick:Se},"+ Add option")])):ye("",!0),m.value.type==="image"?(P(),O("div",Kf,[u[92]||(u[92]=d("div",{class:"section-title"},"Image",-1)),d("div",Uf,[d("label",null,[u[89]||(u[89]=J("Width ",-1)),d("input",{type:"number",value:m.value.width??64,onInput:u[34]||(u[34]=i=>le("width",i.target.value))},null,40,Bf)]),d("label",null,[u[90]||(u[90]=J("Height ",-1)),d("input",{type:"number",value:m.value.height??64,onInput:u[35]||(u[35]=i=>le("height",i.target.value))},null,40,Vf)])]),d("label",null,[u[91]||(u[91]=J("Color ",-1)),d("div",zf,[d("input",{type:"text",value:m.value.color||"",placeholder:"#808080",onInput:u[36]||(u[36]=i=>ae("color",i.target.value))},null,40,Jf),d("input",{type:"color",value:m.value.color||"#808080",class:"color-picker",onInput:u[37]||(u[37]=i=>K("color",i.target.value))},null,40,qf)])])])):ye("",!0),m.value.type==="space"?(P(),O("div",Yf,[u[95]||(u[95]=d("div",{class:"section-title"},"Space",-1)),d("div",Gf,[d("label",null,[u[93]||(u[93]=J("Width ",-1)),d("input",{type:"number",value:m.value.width??"",placeholder:"0",onInput:u[38]||(u[38]=i=>z("width",i.target.value))},null,40,Xf)]),d("label",null,[u[94]||(u[94]=J("Height ",-1)),d("input",{type:"number",value:m.value.height??"",placeholder:"0",onInput:u[39]||(u[39]=i=>z("height",i.target.value))},null,40,Zf)])])])):ye("",!0),d("div",Qf,[u[96]||(u[96]=d("div",{class:"section-title"},"JSON (read-only)",-1)),d("pre",ed,ne(JSON.stringify(m.value,null,2)),1)])])):(P(),O("div",td,"Select a node to inspect"))])]),a.value?(P(),O("div",{key:0,class:"popup",onClick:u[42]||(u[42]=qe(i=>a.value=!1,["self"]))},[d("div",nd,[u[98]||(u[98]=d("h3",null,"Open UI Layout",-1)),d("div",sd,[Ut(d("input",{"onUpdate:modelValue":u[40]||(u[40]=i=>p.value=i),placeholder:"New layout name",onKeyup:Nn(At,["enter"])},null,544),[[Do,p.value]]),d("button",{onClick:At},"Create")]),h.value.length?(P(),O("div",ld,[(P(!0),O(he,null,je(h.value,i=>(P(),O("div",{key:i.name,class:"layout-item",onClick:c=>ht(i.name)},[d("span",null,ne(i.name),1),i.modified?(P(),O("small",id,ne(new Date(i.modified).toLocaleDateString()),1)):ye("",!0)],8,od))),128))])):(P(),O("p",rd,"No UI layouts found.")),d("button",{class:"close-btn",onClick:u[41]||(u[41]=i=>a.value=!1)},"x")])])):ye("",!0)]))}},ad=pt(ud,[["__scopeId","data-v-ec2587ba"]]),cd={class:"app"},fd={class:"tab-bar"},dd=["title"],pd={class:"main"},hd={class:"sidebar-left"},vd={class:"panel layers-panel"},yd={class:"panel bottom-palette"},gd={class:"sidebar-right"},md={class:"panel inspector-section"},bd={class:"panel asset-section"},_d={class:"main"},wd={class:"status-bar"},xd={key:0},Sd={key:0,class:"dirty"},Cd={key:1,class:"saved"},kd={key:1,class:"no-world"},Ed={key:2,class:"no-world"},Td={__name:"App",setup(e){const t=St(),n=Z("world"),s=Z("disconnected"),l=xe(()=>{var o;return((o=t.activeLayer)==null?void 0:o.type)==="entity"});return Vt(()=>{t.fetchConfig(),jo(),Sl("_connected",()=>{s.value="connected"}),Sl("_disconnected",()=>{s.value="disconnected"})}),qn(()=>{$u()}),(o,r)=>(P(),O("div",cd,[be(Uu),d("div",fd,[d("button",{class:Oe(["tab",{active:n.value==="world"}]),onClick:r[0]||(r[0]=a=>n.value="world")},"World Editor",2),d("button",{class:Oe(["tab",{active:n.value==="ui"}]),onClick:r[1]||(r[1]=a=>n.value="ui")},"UI Layout Editor",2),d("span",{class:Oe(["ws-indicator",s.value]),title:"WebSocket: "+s.value},ne(s.value==="connected"?"WS":s.value==="connecting"?"...":"--"),11,dd)]),Ut(d("div",pd,[d("div",hd,[d("div",vd,[be(oa)]),d("div",yd,[l.value?(P(),On(da,{key:0})):(P(),On(wa,{key:1}))])]),be(ka),d("div",gd,[d("div",md,[be(sc)]),d("div",bd,[be(gc)])])],512),[[ul,n.value==="world"]]),Ut(d("div",_d,[be(ad)],512),[[ul,n.value==="ui"]]),d("div",wd,[n.value==="world"&&A(t).world?(P(),O("span",xd,[J(ne(A(t).world.meta.name)+"  ·  "+ne(A(t).world.layers.length)+" layers  ·  Tile: "+ne(A(t).world.meta.tileSize)+"px  ·  ",1),A(t).isDirty?(P(),O("span",Sd,"Unsaved changes")):(P(),O("span",Cd,"Saved"))])):n.value==="world"?(P(),O("span",kd,"No world open")):(P(),O("span",Ed,"UI Layout Editor")),r[2]||(r[2]=d("span",{class:"shortcuts"},"V Select · B Tile · E Entity · X Erase · Del Delete · Ctrl+D Duplicate",-1))])]))}},Id=pt(Td,[["__scopeId","data-v-e8589b1b"]]),Wo=au(Id);Wo.use(du());Wo.mount("#app"); diff --git a/resources/editor/dist/index.html b/resources/editor/dist/index.html new file mode 100644 index 0000000..c309b0c --- /dev/null +++ b/resources/editor/dist/index.html @@ -0,0 +1,18 @@ + + + + + + VISU World Editor + + + + + +
+ + diff --git a/resources/lib/minimp3/build.bat b/resources/lib/minimp3/build.bat new file mode 100644 index 0000000..0f9bc77 --- /dev/null +++ b/resources/lib/minimp3/build.bat @@ -0,0 +1,27 @@ +@echo off +REM Build minimp3 shared library for PHP FFI (Windows) +REM Run from a Visual Studio Developer Command Prompt, or with MinGW/gcc in PATH. + +cd /d "%~dp0" + +where cl >nul 2>nul +if %errorlevel% equ 0 ( + echo Building with MSVC... + cl /O2 /LD /Fe:minimp3.dll minimp3_wrapper.c + del minimp3_wrapper.obj minimp3.lib minimp3.exp 2>nul + echo Built minimp3.dll + goto :done +) + +where gcc >nul 2>nul +if %errorlevel% equ 0 ( + echo Building with GCC... + gcc -shared -O2 -o minimp3.dll minimp3_wrapper.c + echo Built minimp3.dll + goto :done +) + +echo Error: No C compiler found. Install Visual Studio Build Tools or MinGW. +exit /b 1 + +:done diff --git a/resources/lib/minimp3/build.sh b/resources/lib/minimp3/build.sh new file mode 100755 index 0000000..cf71743 --- /dev/null +++ b/resources/lib/minimp3/build.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Build minimp3 shared library for all platforms. +# Requires: zig (https://ziglang.org) for cross-compilation, +# or falls back to native cc for current platform only. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Cross-compile all targets with zig +if command -v zig &>/dev/null; then + echo "Using zig for cross-compilation..." + + mkdir -p darwin-arm64 darwin-x86_64 linux-x86_64 windows-x86_64 + + zig cc -shared -O2 -o darwin-arm64/libminimp3.dylib minimp3_wrapper.c -target aarch64-macos + echo " darwin-arm64/libminimp3.dylib" + + zig cc -shared -O2 -o darwin-x86_64/libminimp3.dylib minimp3_wrapper.c -target x86_64-macos + echo " darwin-x86_64/libminimp3.dylib" + + zig cc -shared -O2 -o linux-x86_64/libminimp3.so minimp3_wrapper.c -target x86_64-linux-gnu -lc + echo " linux-x86_64/libminimp3.so" + + zig cc -shared -O2 -o windows-x86_64/minimp3.dll minimp3_wrapper.c -target x86_64-windows-gnu -lc + echo " windows-x86_64/minimp3.dll" + + echo "All platforms built successfully." + exit 0 +fi + +# Fallback: native compiler for current platform only +echo "zig not found, building for current platform only..." + +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Darwin) + DIR="darwin-${ARCH}" + mkdir -p "$DIR" + cc -shared -O2 -fPIC -o "$DIR/libminimp3.dylib" minimp3_wrapper.c + echo "Built $DIR/libminimp3.dylib" + ;; + Linux) + DIR="linux-${ARCH}" + mkdir -p "$DIR" + cc -shared -O2 -fPIC -o "$DIR/libminimp3.so" minimp3_wrapper.c -lm + echo "Built $DIR/libminimp3.so" + ;; + MINGW*|MSYS*|CYGWIN*) + DIR="windows-${ARCH}" + mkdir -p "$DIR" + cc -shared -O2 -o "$DIR/minimp3.dll" minimp3_wrapper.c + echo "Built $DIR/minimp3.dll" + ;; + *) + echo "Unsupported OS: $OS" >&2 + exit 1 + ;; +esac diff --git a/resources/lib/minimp3/darwin-arm64/libminimp3.dylib b/resources/lib/minimp3/darwin-arm64/libminimp3.dylib new file mode 100755 index 0000000..b0427ad Binary files /dev/null and b/resources/lib/minimp3/darwin-arm64/libminimp3.dylib differ diff --git a/resources/lib/minimp3/darwin-x86_64/libminimp3.dylib b/resources/lib/minimp3/darwin-x86_64/libminimp3.dylib new file mode 100755 index 0000000..3910f67 Binary files /dev/null and b/resources/lib/minimp3/darwin-x86_64/libminimp3.dylib differ diff --git a/resources/lib/minimp3/linux-x86_64/libminimp3.so b/resources/lib/minimp3/linux-x86_64/libminimp3.so new file mode 100755 index 0000000..92e1dd8 Binary files /dev/null and b/resources/lib/minimp3/linux-x86_64/libminimp3.so differ diff --git a/resources/lib/minimp3/minimp3.h b/resources/lib/minimp3/minimp3.h new file mode 100644 index 0000000..3220ae1 --- /dev/null +++ b/resources/lib/minimp3/minimp3.h @@ -0,0 +1,1865 @@ +#ifndef MINIMP3_H +#define MINIMP3_H +/* + https://github.com/lieff/minimp3 + To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. + This software is distributed without any warranty. + See . +*/ +#include + +#define MINIMP3_MAX_SAMPLES_PER_FRAME (1152*2) + +typedef struct +{ + int frame_bytes, frame_offset, channels, hz, layer, bitrate_kbps; +} mp3dec_frame_info_t; + +typedef struct +{ + float mdct_overlap[2][9*32], qmf_state[15*2*32]; + int reserv, free_format_bytes; + unsigned char header[4], reserv_buf[511]; +} mp3dec_t; + +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ + +void mp3dec_init(mp3dec_t *dec); +#ifndef MINIMP3_FLOAT_OUTPUT +typedef int16_t mp3d_sample_t; +#else /* MINIMP3_FLOAT_OUTPUT */ +typedef float mp3d_sample_t; +void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples); +#endif /* MINIMP3_FLOAT_OUTPUT */ +int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info); + +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#endif /* MINIMP3_H */ +#if defined(MINIMP3_IMPLEMENTATION) && !defined(_MINIMP3_IMPLEMENTATION_GUARD) +#define _MINIMP3_IMPLEMENTATION_GUARD + +#include +#include + +#define MAX_FREE_FORMAT_FRAME_SIZE 2304 /* more than ISO spec's */ +#ifndef MAX_FRAME_SYNC_MATCHES +#define MAX_FRAME_SYNC_MATCHES 10 +#endif /* MAX_FRAME_SYNC_MATCHES */ + +#define MAX_L3_FRAME_PAYLOAD_BYTES MAX_FREE_FORMAT_FRAME_SIZE /* MUST be >= 320000/8/32000*1152 = 1440 */ + +#define MAX_BITRESERVOIR_BYTES 511 +#define SHORT_BLOCK_TYPE 2 +#define STOP_BLOCK_TYPE 3 +#define MODE_MONO 3 +#define MODE_JOINT_STEREO 1 +#define HDR_SIZE 4 +#define HDR_IS_MONO(h) (((h[3]) & 0xC0) == 0xC0) +#define HDR_IS_MS_STEREO(h) (((h[3]) & 0xE0) == 0x60) +#define HDR_IS_FREE_FORMAT(h) (((h[2]) & 0xF0) == 0) +#define HDR_IS_CRC(h) (!((h[1]) & 1)) +#define HDR_TEST_PADDING(h) ((h[2]) & 0x2) +#define HDR_TEST_MPEG1(h) ((h[1]) & 0x8) +#define HDR_TEST_NOT_MPEG25(h) ((h[1]) & 0x10) +#define HDR_TEST_I_STEREO(h) ((h[3]) & 0x10) +#define HDR_TEST_MS_STEREO(h) ((h[3]) & 0x20) +#define HDR_GET_STEREO_MODE(h) (((h[3]) >> 6) & 3) +#define HDR_GET_STEREO_MODE_EXT(h) (((h[3]) >> 4) & 3) +#define HDR_GET_LAYER(h) (((h[1]) >> 1) & 3) +#define HDR_GET_BITRATE(h) ((h[2]) >> 4) +#define HDR_GET_SAMPLE_RATE(h) (((h[2]) >> 2) & 3) +#define HDR_GET_MY_SAMPLE_RATE(h) (HDR_GET_SAMPLE_RATE(h) + (((h[1] >> 3) & 1) + ((h[1] >> 4) & 1))*3) +#define HDR_IS_FRAME_576(h) ((h[1] & 14) == 2) +#define HDR_IS_LAYER_1(h) ((h[1] & 6) == 6) + +#define BITS_DEQUANTIZER_OUT -1 +#define MAX_SCF (255 + BITS_DEQUANTIZER_OUT*4 - 210) +#define MAX_SCFI ((MAX_SCF + 3) & ~3) + +#define MINIMP3_MIN(a, b) ((a) > (b) ? (b) : (a)) +#define MINIMP3_MAX(a, b) ((a) < (b) ? (b) : (a)) + +#if !defined(MINIMP3_NO_SIMD) + +#if !defined(MINIMP3_ONLY_SIMD) && (defined(_M_X64) || defined(__x86_64__) || defined(__aarch64__) || defined(_M_ARM64)) +/* x64 always have SSE2, arm64 always have neon, no need for generic code */ +#define MINIMP3_ONLY_SIMD +#endif /* SIMD checks... */ + +#if (defined(_MSC_VER) && (defined(_M_IX86) || defined(_M_X64))) || ((defined(__i386__) || defined(__x86_64__)) && defined(__SSE2__)) +#if defined(_MSC_VER) +#include +#endif /* defined(_MSC_VER) */ +#include +#define HAVE_SSE 1 +#define HAVE_SIMD 1 +#define VSTORE _mm_storeu_ps +#define VLD _mm_loadu_ps +#define VSET _mm_set1_ps +#define VADD _mm_add_ps +#define VSUB _mm_sub_ps +#define VMUL _mm_mul_ps +#define VMAC(a, x, y) _mm_add_ps(a, _mm_mul_ps(x, y)) +#define VMSB(a, x, y) _mm_sub_ps(a, _mm_mul_ps(x, y)) +#define VMUL_S(x, s) _mm_mul_ps(x, _mm_set1_ps(s)) +#define VREV(x) _mm_shuffle_ps(x, x, _MM_SHUFFLE(0, 1, 2, 3)) +typedef __m128 f4; +#if defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) +#define minimp3_cpuid __cpuid +#else /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */ +static __inline__ __attribute__((always_inline)) void minimp3_cpuid(int CPUInfo[], const int InfoType) +{ +#if defined(__PIC__) + __asm__ __volatile__( +#if defined(__x86_64__) + "push %%rbx\n" + "cpuid\n" + "xchgl %%ebx, %1\n" + "pop %%rbx\n" +#else /* defined(__x86_64__) */ + "xchgl %%ebx, %1\n" + "cpuid\n" + "xchgl %%ebx, %1\n" +#endif /* defined(__x86_64__) */ + : "=a" (CPUInfo[0]), "=r" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3]) + : "a" (InfoType)); +#else /* defined(__PIC__) */ + __asm__ __volatile__( + "cpuid" + : "=a" (CPUInfo[0]), "=b" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3]) + : "a" (InfoType)); +#endif /* defined(__PIC__)*/ +} +#endif /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */ +static int have_simd(void) +{ +#ifdef MINIMP3_ONLY_SIMD + return 1; +#else /* MINIMP3_ONLY_SIMD */ + static int g_have_simd; + int CPUInfo[4]; +#ifdef MINIMP3_TEST + static int g_counter; + if (g_counter++ > 100) + return 0; +#endif /* MINIMP3_TEST */ + if (g_have_simd) + goto end; + minimp3_cpuid(CPUInfo, 0); + g_have_simd = 1; + if (CPUInfo[0] > 0) + { + minimp3_cpuid(CPUInfo, 1); + g_have_simd = (CPUInfo[3] & (1 << 26)) + 1; /* SSE2 */ + } +end: + return g_have_simd - 1; +#endif /* MINIMP3_ONLY_SIMD */ +} +#elif defined(__ARM_NEON) || defined(__aarch64__) || defined(_M_ARM64) +#include +#define HAVE_SSE 0 +#define HAVE_SIMD 1 +#define VSTORE vst1q_f32 +#define VLD vld1q_f32 +#define VSET vmovq_n_f32 +#define VADD vaddq_f32 +#define VSUB vsubq_f32 +#define VMUL vmulq_f32 +#define VMAC(a, x, y) vmlaq_f32(a, x, y) +#define VMSB(a, x, y) vmlsq_f32(a, x, y) +#define VMUL_S(x, s) vmulq_f32(x, vmovq_n_f32(s)) +#define VREV(x) vcombine_f32(vget_high_f32(vrev64q_f32(x)), vget_low_f32(vrev64q_f32(x))) +typedef float32x4_t f4; +static int have_simd() +{ /* TODO: detect neon for !MINIMP3_ONLY_SIMD */ + return 1; +} +#else /* SIMD checks... */ +#define HAVE_SSE 0 +#define HAVE_SIMD 0 +#ifdef MINIMP3_ONLY_SIMD +#error MINIMP3_ONLY_SIMD used, but SSE/NEON not enabled +#endif /* MINIMP3_ONLY_SIMD */ +#endif /* SIMD checks... */ +#else /* !defined(MINIMP3_NO_SIMD) */ +#define HAVE_SIMD 0 +#endif /* !defined(MINIMP3_NO_SIMD) */ + +#if defined(__ARM_ARCH) && (__ARM_ARCH >= 6) && !defined(__aarch64__) && !defined(_M_ARM64) +#define HAVE_ARMV6 1 +static __inline__ __attribute__((always_inline)) int32_t minimp3_clip_int16_arm(int32_t a) +{ + int32_t x = 0; + __asm__ ("ssat %0, #16, %1" : "=r"(x) : "r"(a)); + return x; +} +#else +#define HAVE_ARMV6 0 +#endif + +typedef struct +{ + const uint8_t *buf; + int pos, limit; +} bs_t; + +typedef struct +{ + float scf[3*64]; + uint8_t total_bands, stereo_bands, bitalloc[64], scfcod[64]; +} L12_scale_info; + +typedef struct +{ + uint8_t tab_offset, code_tab_width, band_count; +} L12_subband_alloc_t; + +typedef struct +{ + const uint8_t *sfbtab; + uint16_t part_23_length, big_values, scalefac_compress; + uint8_t global_gain, block_type, mixed_block_flag, n_long_sfb, n_short_sfb; + uint8_t table_select[3], region_count[3], subblock_gain[3]; + uint8_t preflag, scalefac_scale, count1_table, scfsi; +} L3_gr_info_t; + +typedef struct +{ + bs_t bs; + uint8_t maindata[MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES]; + L3_gr_info_t gr_info[4]; + float grbuf[2][576], scf[40], syn[18 + 15][2*32]; + uint8_t ist_pos[2][39]; +} mp3dec_scratch_t; + +static void bs_init(bs_t *bs, const uint8_t *data, int bytes) +{ + bs->buf = data; + bs->pos = 0; + bs->limit = bytes*8; +} + +static uint32_t get_bits(bs_t *bs, int n) +{ + uint32_t next, cache = 0, s = bs->pos & 7; + int shl = n + s; + const uint8_t *p = bs->buf + (bs->pos >> 3); + if ((bs->pos += n) > bs->limit) + return 0; + next = *p++ & (255 >> s); + while ((shl -= 8) > 0) + { + cache |= next << shl; + next = *p++; + } + return cache | (next >> -shl); +} + +static int hdr_valid(const uint8_t *h) +{ + return h[0] == 0xff && + ((h[1] & 0xF0) == 0xf0 || (h[1] & 0xFE) == 0xe2) && + (HDR_GET_LAYER(h) != 0) && + (HDR_GET_BITRATE(h) != 15) && + (HDR_GET_SAMPLE_RATE(h) != 3); +} + +static int hdr_compare(const uint8_t *h1, const uint8_t *h2) +{ + return hdr_valid(h2) && + ((h1[1] ^ h2[1]) & 0xFE) == 0 && + ((h1[2] ^ h2[2]) & 0x0C) == 0 && + !(HDR_IS_FREE_FORMAT(h1) ^ HDR_IS_FREE_FORMAT(h2)); +} + +static unsigned hdr_bitrate_kbps(const uint8_t *h) +{ + static const uint8_t halfrate[2][3][15] = { + { { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,16,24,28,32,40,48,56,64,72,80,88,96,112,128 } }, + { { 0,16,20,24,28,32,40,48,56,64,80,96,112,128,160 }, { 0,16,24,28,32,40,48,56,64,80,96,112,128,160,192 }, { 0,16,32,48,64,80,96,112,128,144,160,176,192,208,224 } }, + }; + return 2*halfrate[!!HDR_TEST_MPEG1(h)][HDR_GET_LAYER(h) - 1][HDR_GET_BITRATE(h)]; +} + +static unsigned hdr_sample_rate_hz(const uint8_t *h) +{ + static const unsigned g_hz[3] = { 44100, 48000, 32000 }; + return g_hz[HDR_GET_SAMPLE_RATE(h)] >> (int)!HDR_TEST_MPEG1(h) >> (int)!HDR_TEST_NOT_MPEG25(h); +} + +static unsigned hdr_frame_samples(const uint8_t *h) +{ + return HDR_IS_LAYER_1(h) ? 384 : (1152 >> (int)HDR_IS_FRAME_576(h)); +} + +static int hdr_frame_bytes(const uint8_t *h, int free_format_size) +{ + int frame_bytes = hdr_frame_samples(h)*hdr_bitrate_kbps(h)*125/hdr_sample_rate_hz(h); + if (HDR_IS_LAYER_1(h)) + { + frame_bytes &= ~3; /* slot align */ + } + return frame_bytes ? frame_bytes : free_format_size; +} + +static int hdr_padding(const uint8_t *h) +{ + return HDR_TEST_PADDING(h) ? (HDR_IS_LAYER_1(h) ? 4 : 1) : 0; +} + +#ifndef MINIMP3_ONLY_MP3 +static const L12_subband_alloc_t *L12_subband_alloc_table(const uint8_t *hdr, L12_scale_info *sci) +{ + const L12_subband_alloc_t *alloc; + int mode = HDR_GET_STEREO_MODE(hdr); + int nbands, stereo_bands = (mode == MODE_MONO) ? 0 : (mode == MODE_JOINT_STEREO) ? (HDR_GET_STEREO_MODE_EXT(hdr) << 2) + 4 : 32; + + if (HDR_IS_LAYER_1(hdr)) + { + static const L12_subband_alloc_t g_alloc_L1[] = { { 76, 4, 32 } }; + alloc = g_alloc_L1; + nbands = 32; + } else if (!HDR_TEST_MPEG1(hdr)) + { + static const L12_subband_alloc_t g_alloc_L2M2[] = { { 60, 4, 4 }, { 44, 3, 7 }, { 44, 2, 19 } }; + alloc = g_alloc_L2M2; + nbands = 30; + } else + { + static const L12_subband_alloc_t g_alloc_L2M1[] = { { 0, 4, 3 }, { 16, 4, 8 }, { 32, 3, 12 }, { 40, 2, 7 } }; + int sample_rate_idx = HDR_GET_SAMPLE_RATE(hdr); + unsigned kbps = hdr_bitrate_kbps(hdr) >> (int)(mode != MODE_MONO); + if (!kbps) /* free-format */ + { + kbps = 192; + } + + alloc = g_alloc_L2M1; + nbands = 27; + if (kbps < 56) + { + static const L12_subband_alloc_t g_alloc_L2M1_lowrate[] = { { 44, 4, 2 }, { 44, 3, 10 } }; + alloc = g_alloc_L2M1_lowrate; + nbands = sample_rate_idx == 2 ? 12 : 8; + } else if (kbps >= 96 && sample_rate_idx != 1) + { + nbands = 30; + } + } + + sci->total_bands = (uint8_t)nbands; + sci->stereo_bands = (uint8_t)MINIMP3_MIN(stereo_bands, nbands); + + return alloc; +} + +static void L12_read_scalefactors(bs_t *bs, uint8_t *pba, uint8_t *scfcod, int bands, float *scf) +{ + static const float g_deq_L12[18*3] = { +#define DQ(x) 9.53674316e-07f/x, 7.56931807e-07f/x, 6.00777173e-07f/x + DQ(3),DQ(7),DQ(15),DQ(31),DQ(63),DQ(127),DQ(255),DQ(511),DQ(1023),DQ(2047),DQ(4095),DQ(8191),DQ(16383),DQ(32767),DQ(65535),DQ(3),DQ(5),DQ(9) + }; + int i, m; + for (i = 0; i < bands; i++) + { + float s = 0; + int ba = *pba++; + int mask = ba ? 4 + ((19 >> scfcod[i]) & 3) : 0; + for (m = 4; m; m >>= 1) + { + if (mask & m) + { + int b = get_bits(bs, 6); + s = g_deq_L12[ba*3 - 6 + b % 3]*(1 << 21 >> b/3); + } + *scf++ = s; + } + } +} + +static void L12_read_scale_info(const uint8_t *hdr, bs_t *bs, L12_scale_info *sci) +{ + static const uint8_t g_bitalloc_code_tab[] = { + 0,17, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,16, + 0,17,18, 3,19,4,5,16, + 0,17,18,16, + 0,17,18,19, 4,5,6, 7,8, 9,10,11,12,13,14,15, + 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,14, + 0, 2, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16 + }; + const L12_subband_alloc_t *subband_alloc = L12_subband_alloc_table(hdr, sci); + + int i, k = 0, ba_bits = 0; + const uint8_t *ba_code_tab = g_bitalloc_code_tab; + + for (i = 0; i < sci->total_bands; i++) + { + uint8_t ba; + if (i == k) + { + k += subband_alloc->band_count; + ba_bits = subband_alloc->code_tab_width; + ba_code_tab = g_bitalloc_code_tab + subband_alloc->tab_offset; + subband_alloc++; + } + ba = ba_code_tab[get_bits(bs, ba_bits)]; + sci->bitalloc[2*i] = ba; + if (i < sci->stereo_bands) + { + ba = ba_code_tab[get_bits(bs, ba_bits)]; + } + sci->bitalloc[2*i + 1] = sci->stereo_bands ? ba : 0; + } + + for (i = 0; i < 2*sci->total_bands; i++) + { + sci->scfcod[i] = sci->bitalloc[i] ? HDR_IS_LAYER_1(hdr) ? 2 : get_bits(bs, 2) : 6; + } + + L12_read_scalefactors(bs, sci->bitalloc, sci->scfcod, sci->total_bands*2, sci->scf); + + for (i = sci->stereo_bands; i < sci->total_bands; i++) + { + sci->bitalloc[2*i + 1] = 0; + } +} + +static int L12_dequantize_granule(float *grbuf, bs_t *bs, L12_scale_info *sci, int group_size) +{ + int i, j, k, choff = 576; + for (j = 0; j < 4; j++) + { + float *dst = grbuf + group_size*j; + for (i = 0; i < 2*sci->total_bands; i++) + { + int ba = sci->bitalloc[i]; + if (ba != 0) + { + if (ba < 17) + { + int half = (1 << (ba - 1)) - 1; + for (k = 0; k < group_size; k++) + { + dst[k] = (float)((int)get_bits(bs, ba) - half); + } + } else + { + unsigned mod = (2 << (ba - 17)) + 1; /* 3, 5, 9 */ + unsigned code = get_bits(bs, mod + 2 - (mod >> 3)); /* 5, 7, 10 */ + for (k = 0; k < group_size; k++, code /= mod) + { + dst[k] = (float)((int)(code % mod - mod/2)); + } + } + } + dst += choff; + choff = 18 - choff; + } + } + return group_size*4; +} + +static void L12_apply_scf_384(L12_scale_info *sci, const float *scf, float *dst) +{ + int i, k; + memcpy(dst + 576 + sci->stereo_bands*18, dst + sci->stereo_bands*18, (sci->total_bands - sci->stereo_bands)*18*sizeof(float)); + for (i = 0; i < sci->total_bands; i++, dst += 18, scf += 6) + { + for (k = 0; k < 12; k++) + { + dst[k + 0] *= scf[0]; + dst[k + 576] *= scf[3]; + } + } +} +#endif /* MINIMP3_ONLY_MP3 */ + +static int L3_read_side_info(bs_t *bs, L3_gr_info_t *gr, const uint8_t *hdr) +{ + static const uint8_t g_scf_long[8][23] = { + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 12,12,12,12,12,12,16,20,24,28,32,40,48,56,64,76,90,2,2,2,2,2,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,18,22,26,32,38,46,54,62,70,76,36,0 }, + { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 }, + { 4,4,4,4,4,4,6,6,8,8,10,12,16,20,24,28,34,42,50,54,76,158,0 }, + { 4,4,4,4,4,4,6,6,6,8,10,12,16,18,22,28,34,40,46,54,54,192,0 }, + { 4,4,4,4,4,4,6,6,8,10,12,16,20,24,30,38,46,56,68,84,102,26,0 } + }; + static const uint8_t g_scf_short[8][40] = { + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 8,8,8,8,8,8,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 }, + { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 }, + { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 } + }; + static const uint8_t g_scf_mixed[8][40] = { + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 12,12,12,4,4,4,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 }, + { 6,6,6,6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 }, + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 }, + { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 }, + { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 } + }; + + unsigned tables, scfsi = 0; + int main_data_begin, part_23_sum = 0; + int sr_idx = HDR_GET_MY_SAMPLE_RATE(hdr); sr_idx -= (sr_idx != 0); + int gr_count = HDR_IS_MONO(hdr) ? 1 : 2; + + if (HDR_TEST_MPEG1(hdr)) + { + gr_count *= 2; + main_data_begin = get_bits(bs, 9); + scfsi = get_bits(bs, 7 + gr_count); + } else + { + main_data_begin = get_bits(bs, 8 + gr_count) >> gr_count; + } + + do + { + if (HDR_IS_MONO(hdr)) + { + scfsi <<= 4; + } + gr->part_23_length = (uint16_t)get_bits(bs, 12); + part_23_sum += gr->part_23_length; + gr->big_values = (uint16_t)get_bits(bs, 9); + if (gr->big_values > 288) + { + return -1; + } + gr->global_gain = (uint8_t)get_bits(bs, 8); + gr->scalefac_compress = (uint16_t)get_bits(bs, HDR_TEST_MPEG1(hdr) ? 4 : 9); + gr->sfbtab = g_scf_long[sr_idx]; + gr->n_long_sfb = 22; + gr->n_short_sfb = 0; + if (get_bits(bs, 1)) + { + gr->block_type = (uint8_t)get_bits(bs, 2); + if (!gr->block_type) + { + return -1; + } + gr->mixed_block_flag = (uint8_t)get_bits(bs, 1); + gr->region_count[0] = 7; + gr->region_count[1] = 255; + if (gr->block_type == SHORT_BLOCK_TYPE) + { + scfsi &= 0x0F0F; + if (!gr->mixed_block_flag) + { + gr->region_count[0] = 8; + gr->sfbtab = g_scf_short[sr_idx]; + gr->n_long_sfb = 0; + gr->n_short_sfb = 39; + } else + { + gr->sfbtab = g_scf_mixed[sr_idx]; + gr->n_long_sfb = HDR_TEST_MPEG1(hdr) ? 8 : 6; + gr->n_short_sfb = 30; + } + } + tables = get_bits(bs, 10); + tables <<= 5; + gr->subblock_gain[0] = (uint8_t)get_bits(bs, 3); + gr->subblock_gain[1] = (uint8_t)get_bits(bs, 3); + gr->subblock_gain[2] = (uint8_t)get_bits(bs, 3); + } else + { + gr->block_type = 0; + gr->mixed_block_flag = 0; + tables = get_bits(bs, 15); + gr->region_count[0] = (uint8_t)get_bits(bs, 4); + gr->region_count[1] = (uint8_t)get_bits(bs, 3); + gr->region_count[2] = 255; + } + gr->table_select[0] = (uint8_t)(tables >> 10); + gr->table_select[1] = (uint8_t)((tables >> 5) & 31); + gr->table_select[2] = (uint8_t)((tables) & 31); + gr->preflag = HDR_TEST_MPEG1(hdr) ? get_bits(bs, 1) : (gr->scalefac_compress >= 500); + gr->scalefac_scale = (uint8_t)get_bits(bs, 1); + gr->count1_table = (uint8_t)get_bits(bs, 1); + gr->scfsi = (uint8_t)((scfsi >> 12) & 15); + scfsi <<= 4; + gr++; + } while(--gr_count); + + if (part_23_sum + bs->pos > bs->limit + main_data_begin*8) + { + return -1; + } + + return main_data_begin; +} + +static void L3_read_scalefactors(uint8_t *scf, uint8_t *ist_pos, const uint8_t *scf_size, const uint8_t *scf_count, bs_t *bitbuf, int scfsi) +{ + int i, k; + for (i = 0; i < 4 && scf_count[i]; i++, scfsi *= 2) + { + int cnt = scf_count[i]; + if (scfsi & 8) + { + memcpy(scf, ist_pos, cnt); + } else + { + int bits = scf_size[i]; + if (!bits) + { + memset(scf, 0, cnt); + memset(ist_pos, 0, cnt); + } else + { + int max_scf = (scfsi < 0) ? (1 << bits) - 1 : -1; + for (k = 0; k < cnt; k++) + { + int s = get_bits(bitbuf, bits); + ist_pos[k] = (s == max_scf ? -1 : s); + scf[k] = s; + } + } + } + ist_pos += cnt; + scf += cnt; + } + scf[0] = scf[1] = scf[2] = 0; +} + +static float L3_ldexp_q2(float y, int exp_q2) +{ + static const float g_expfrac[4] = { 9.31322575e-10f,7.83145814e-10f,6.58544508e-10f,5.53767716e-10f }; + int e; + do + { + e = MINIMP3_MIN(30*4, exp_q2); + y *= g_expfrac[e & 3]*(1 << 30 >> (e >> 2)); + } while ((exp_q2 -= e) > 0); + return y; +} + +static void L3_decode_scalefactors(const uint8_t *hdr, uint8_t *ist_pos, bs_t *bs, const L3_gr_info_t *gr, float *scf, int ch) +{ + static const uint8_t g_scf_partitions[3][28] = { + { 6,5,5, 5,6,5,5,5,6,5, 7,3,11,10,0,0, 7, 7, 7,0, 6, 6,6,3, 8, 8,5,0 }, + { 8,9,6,12,6,9,9,9,6,9,12,6,15,18,0,0, 6,15,12,0, 6,12,9,6, 6,18,9,0 }, + { 9,9,6,12,9,9,9,9,9,9,12,6,18,18,0,0,12,12,12,0,12, 9,9,6,15,12,9,0 } + }; + const uint8_t *scf_partition = g_scf_partitions[!!gr->n_short_sfb + !gr->n_long_sfb]; + uint8_t scf_size[4], iscf[40]; + int i, scf_shift = gr->scalefac_scale + 1, gain_exp, scfsi = gr->scfsi; + float gain; + + if (HDR_TEST_MPEG1(hdr)) + { + static const uint8_t g_scfc_decode[16] = { 0,1,2,3, 12,5,6,7, 9,10,11,13, 14,15,18,19 }; + int part = g_scfc_decode[gr->scalefac_compress]; + scf_size[1] = scf_size[0] = (uint8_t)(part >> 2); + scf_size[3] = scf_size[2] = (uint8_t)(part & 3); + } else + { + static const uint8_t g_mod[6*4] = { 5,5,4,4,5,5,4,1,4,3,1,1,5,6,6,1,4,4,4,1,4,3,1,1 }; + int k, modprod, sfc, ist = HDR_TEST_I_STEREO(hdr) && ch; + sfc = gr->scalefac_compress >> ist; + for (k = ist*3*4; sfc >= 0; sfc -= modprod, k += 4) + { + for (modprod = 1, i = 3; i >= 0; i--) + { + scf_size[i] = (uint8_t)(sfc / modprod % g_mod[k + i]); + modprod *= g_mod[k + i]; + } + } + scf_partition += k; + scfsi = -16; + } + L3_read_scalefactors(iscf, ist_pos, scf_size, scf_partition, bs, scfsi); + + if (gr->n_short_sfb) + { + int sh = 3 - scf_shift; + for (i = 0; i < gr->n_short_sfb; i += 3) + { + iscf[gr->n_long_sfb + i + 0] += gr->subblock_gain[0] << sh; + iscf[gr->n_long_sfb + i + 1] += gr->subblock_gain[1] << sh; + iscf[gr->n_long_sfb + i + 2] += gr->subblock_gain[2] << sh; + } + } else if (gr->preflag) + { + static const uint8_t g_preamp[10] = { 1,1,1,1,2,2,3,3,3,2 }; + for (i = 0; i < 10; i++) + { + iscf[11 + i] += g_preamp[i]; + } + } + + gain_exp = gr->global_gain + BITS_DEQUANTIZER_OUT*4 - 210 - (HDR_IS_MS_STEREO(hdr) ? 2 : 0); + gain = L3_ldexp_q2(1 << (MAX_SCFI/4), MAX_SCFI - gain_exp); + for (i = 0; i < (int)(gr->n_long_sfb + gr->n_short_sfb); i++) + { + scf[i] = L3_ldexp_q2(gain, iscf[i] << scf_shift); + } +} + +static const float g_pow43[129 + 16] = { + 0,-1,-2.519842f,-4.326749f,-6.349604f,-8.549880f,-10.902724f,-13.390518f,-16.000000f,-18.720754f,-21.544347f,-24.463781f,-27.473142f,-30.567351f,-33.741992f,-36.993181f, + 0,1,2.519842f,4.326749f,6.349604f,8.549880f,10.902724f,13.390518f,16.000000f,18.720754f,21.544347f,24.463781f,27.473142f,30.567351f,33.741992f,36.993181f,40.317474f,43.711787f,47.173345f,50.699631f,54.288352f,57.937408f,61.644865f,65.408941f,69.227979f,73.100443f,77.024898f,81.000000f,85.024491f,89.097188f,93.216975f,97.382800f,101.593667f,105.848633f,110.146801f,114.487321f,118.869381f,123.292209f,127.755065f,132.257246f,136.798076f,141.376907f,145.993119f,150.646117f,155.335327f,160.060199f,164.820202f,169.614826f,174.443577f,179.305980f,184.201575f,189.129918f,194.090580f,199.083145f,204.107210f,209.162385f,214.248292f,219.364564f,224.510845f,229.686789f,234.892058f,240.126328f,245.389280f,250.680604f,256.000000f,261.347174f,266.721841f,272.123723f,277.552547f,283.008049f,288.489971f,293.998060f,299.532071f,305.091761f,310.676898f,316.287249f,321.922592f,327.582707f,333.267377f,338.976394f,344.709550f,350.466646f,356.247482f,362.051866f,367.879608f,373.730522f,379.604427f,385.501143f,391.420496f,397.362314f,403.326427f,409.312672f,415.320884f,421.350905f,427.402579f,433.475750f,439.570269f,445.685987f,451.822757f,457.980436f,464.158883f,470.357960f,476.577530f,482.817459f,489.077615f,495.357868f,501.658090f,507.978156f,514.317941f,520.677324f,527.056184f,533.454404f,539.871867f,546.308458f,552.764065f,559.238575f,565.731879f,572.243870f,578.774440f,585.323483f,591.890898f,598.476581f,605.080431f,611.702349f,618.342238f,625.000000f,631.675540f,638.368763f,645.079578f +}; + +static float L3_pow_43(int x) +{ + float frac; + int sign, mult = 256; + + if (x < 129) + { + return g_pow43[16 + x]; + } + + if (x < 1024) + { + mult = 16; + x <<= 3; + } + + sign = 2*x & 64; + frac = (float)((x & 63) - sign) / ((x & ~63) + sign); + return g_pow43[16 + ((x + sign) >> 6)]*(1.f + frac*((4.f/3) + frac*(2.f/9)))*mult; +} + +static void L3_huffman(float *dst, bs_t *bs, const L3_gr_info_t *gr_info, const float *scf, int layer3gr_limit) +{ + static const int16_t tabs[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 785,785,785,785,784,784,784,784,513,513,513,513,513,513,513,513,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256, + -255,1313,1298,1282,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,290,288, + -255,1313,1298,1282,769,769,769,769,529,529,529,529,529,529,529,529,528,528,528,528,528,528,528,528,512,512,512,512,512,512,512,512,290,288, + -253,-318,-351,-367,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,819,818,547,547,275,275,275,275,561,560,515,546,289,274,288,258, + -254,-287,1329,1299,1314,1312,1057,1057,1042,1042,1026,1026,784,784,784,784,529,529,529,529,529,529,529,529,769,769,769,769,768,768,768,768,563,560,306,306,291,259, + -252,-413,-477,-542,1298,-575,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-383,-399,1107,1092,1106,1061,849,849,789,789,1104,1091,773,773,1076,1075,341,340,325,309,834,804,577,577,532,532,516,516,832,818,803,816,561,561,531,531,515,546,289,289,288,258, + -252,-429,-493,-559,1057,1057,1042,1042,529,529,529,529,529,529,529,529,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,-382,1077,-415,1106,1061,1104,849,849,789,789,1091,1076,1029,1075,834,834,597,581,340,340,339,324,804,833,532,532,832,772,818,803,817,787,816,771,290,290,290,290,288,258, + -253,-349,-414,-447,-463,1329,1299,-479,1314,1312,1057,1057,1042,1042,1026,1026,785,785,785,785,784,784,784,784,769,769,769,769,768,768,768,768,-319,851,821,-335,836,850,805,849,341,340,325,336,533,533,579,579,564,564,773,832,578,548,563,516,321,276,306,291,304,259, + -251,-572,-733,-830,-863,-879,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,1396,1351,1381,1366,1395,1335,1380,-559,1334,1138,1138,1063,1063,1350,1392,1031,1031,1062,1062,1364,1363,1120,1120,1333,1348,881,881,881,881,375,374,359,373,343,358,341,325,791,791,1123,1122,-703,1105,1045,-719,865,865,790,790,774,774,1104,1029,338,293,323,308,-799,-815,833,788,772,818,803,816,322,292,307,320,561,531,515,546,289,274,288,258, + -251,-525,-605,-685,-765,-831,-846,1298,1057,1057,1312,1282,785,785,785,785,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,1399,1398,1383,1367,1382,1396,1351,-511,1381,1366,1139,1139,1079,1079,1124,1124,1364,1349,1363,1333,882,882,882,882,807,807,807,807,1094,1094,1136,1136,373,341,535,535,881,775,867,822,774,-591,324,338,-671,849,550,550,866,864,609,609,293,336,534,534,789,835,773,-751,834,804,308,307,833,788,832,772,562,562,547,547,305,275,560,515,290,290, + -252,-397,-477,-557,-622,-653,-719,-735,-750,1329,1299,1314,1057,1057,1042,1042,1312,1282,1024,1024,785,785,785,785,784,784,784,784,769,769,769,769,-383,1127,1141,1111,1126,1140,1095,1110,869,869,883,883,1079,1109,882,882,375,374,807,868,838,881,791,-463,867,822,368,263,852,837,836,-543,610,610,550,550,352,336,534,534,865,774,851,821,850,805,593,533,579,564,773,832,578,578,548,548,577,577,307,276,306,291,516,560,259,259, + -250,-2107,-2507,-2764,-2909,-2974,-3007,-3023,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-767,-1052,-1213,-1277,-1358,-1405,-1469,-1535,-1550,-1582,-1614,-1647,-1662,-1694,-1726,-1759,-1774,-1807,-1822,-1854,-1886,1565,-1919,-1935,-1951,-1967,1731,1730,1580,1717,-1983,1729,1564,-1999,1548,-2015,-2031,1715,1595,-2047,1714,-2063,1610,-2079,1609,-2095,1323,1323,1457,1457,1307,1307,1712,1547,1641,1700,1699,1594,1685,1625,1442,1442,1322,1322,-780,-973,-910,1279,1278,1277,1262,1276,1261,1275,1215,1260,1229,-959,974,974,989,989,-943,735,478,478,495,463,506,414,-1039,1003,958,1017,927,942,987,957,431,476,1272,1167,1228,-1183,1256,-1199,895,895,941,941,1242,1227,1212,1135,1014,1014,490,489,503,487,910,1013,985,925,863,894,970,955,1012,847,-1343,831,755,755,984,909,428,366,754,559,-1391,752,486,457,924,997,698,698,983,893,740,740,908,877,739,739,667,667,953,938,497,287,271,271,683,606,590,712,726,574,302,302,738,736,481,286,526,725,605,711,636,724,696,651,589,681,666,710,364,467,573,695,466,466,301,465,379,379,709,604,665,679,316,316,634,633,436,436,464,269,424,394,452,332,438,363,347,408,393,448,331,422,362,407,392,421,346,406,391,376,375,359,1441,1306,-2367,1290,-2383,1337,-2399,-2415,1426,1321,-2431,1411,1336,-2447,-2463,-2479,1169,1169,1049,1049,1424,1289,1412,1352,1319,-2495,1154,1154,1064,1064,1153,1153,416,390,360,404,403,389,344,374,373,343,358,372,327,357,342,311,356,326,1395,1394,1137,1137,1047,1047,1365,1392,1287,1379,1334,1364,1349,1378,1318,1363,792,792,792,792,1152,1152,1032,1032,1121,1121,1046,1046,1120,1120,1030,1030,-2895,1106,1061,1104,849,849,789,789,1091,1076,1029,1090,1060,1075,833,833,309,324,532,532,832,772,818,803,561,561,531,560,515,546,289,274,288,258, + -250,-1179,-1579,-1836,-1996,-2124,-2253,-2333,-2413,-2477,-2542,-2574,-2607,-2622,-2655,1314,1313,1298,1312,1282,785,785,785,785,1040,1040,1025,1025,768,768,768,768,-766,-798,-830,-862,-895,-911,-927,-943,-959,-975,-991,-1007,-1023,-1039,-1055,-1070,1724,1647,-1103,-1119,1631,1767,1662,1738,1708,1723,-1135,1780,1615,1779,1599,1677,1646,1778,1583,-1151,1777,1567,1737,1692,1765,1722,1707,1630,1751,1661,1764,1614,1736,1676,1763,1750,1645,1598,1721,1691,1762,1706,1582,1761,1566,-1167,1749,1629,767,766,751,765,494,494,735,764,719,749,734,763,447,447,748,718,477,506,431,491,446,476,461,505,415,430,475,445,504,399,460,489,414,503,383,474,429,459,502,502,746,752,488,398,501,473,413,472,486,271,480,270,-1439,-1455,1357,-1471,-1487,-1503,1341,1325,-1519,1489,1463,1403,1309,-1535,1372,1448,1418,1476,1356,1462,1387,-1551,1475,1340,1447,1402,1386,-1567,1068,1068,1474,1461,455,380,468,440,395,425,410,454,364,467,466,464,453,269,409,448,268,432,1371,1473,1432,1417,1308,1460,1355,1446,1459,1431,1083,1083,1401,1416,1458,1445,1067,1067,1370,1457,1051,1051,1291,1430,1385,1444,1354,1415,1400,1443,1082,1082,1173,1113,1186,1066,1185,1050,-1967,1158,1128,1172,1097,1171,1081,-1983,1157,1112,416,266,375,400,1170,1142,1127,1065,793,793,1169,1033,1156,1096,1141,1111,1155,1080,1126,1140,898,898,808,808,897,897,792,792,1095,1152,1032,1125,1110,1139,1079,1124,882,807,838,881,853,791,-2319,867,368,263,822,852,837,866,806,865,-2399,851,352,262,534,534,821,836,594,594,549,549,593,593,533,533,848,773,579,579,564,578,548,563,276,276,577,576,306,291,516,560,305,305,275,259, + -251,-892,-2058,-2620,-2828,-2957,-3023,-3039,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,-559,1530,-575,-591,1528,1527,1407,1526,1391,1023,1023,1023,1023,1525,1375,1268,1268,1103,1103,1087,1087,1039,1039,1523,-604,815,815,815,815,510,495,509,479,508,463,507,447,431,505,415,399,-734,-782,1262,-815,1259,1244,-831,1258,1228,-847,-863,1196,-879,1253,987,987,748,-767,493,493,462,477,414,414,686,669,478,446,461,445,474,429,487,458,412,471,1266,1264,1009,1009,799,799,-1019,-1276,-1452,-1581,-1677,-1757,-1821,-1886,-1933,-1997,1257,1257,1483,1468,1512,1422,1497,1406,1467,1496,1421,1510,1134,1134,1225,1225,1466,1451,1374,1405,1252,1252,1358,1480,1164,1164,1251,1251,1238,1238,1389,1465,-1407,1054,1101,-1423,1207,-1439,830,830,1248,1038,1237,1117,1223,1148,1236,1208,411,426,395,410,379,269,1193,1222,1132,1235,1221,1116,976,976,1192,1162,1177,1220,1131,1191,963,963,-1647,961,780,-1663,558,558,994,993,437,408,393,407,829,978,813,797,947,-1743,721,721,377,392,844,950,828,890,706,706,812,859,796,960,948,843,934,874,571,571,-1919,690,555,689,421,346,539,539,944,779,918,873,932,842,903,888,570,570,931,917,674,674,-2575,1562,-2591,1609,-2607,1654,1322,1322,1441,1441,1696,1546,1683,1593,1669,1624,1426,1426,1321,1321,1639,1680,1425,1425,1305,1305,1545,1668,1608,1623,1667,1592,1638,1666,1320,1320,1652,1607,1409,1409,1304,1304,1288,1288,1664,1637,1395,1395,1335,1335,1622,1636,1394,1394,1319,1319,1606,1621,1392,1392,1137,1137,1137,1137,345,390,360,375,404,373,1047,-2751,-2767,-2783,1062,1121,1046,-2799,1077,-2815,1106,1061,789,789,1105,1104,263,355,310,340,325,354,352,262,339,324,1091,1076,1029,1090,1060,1075,833,833,788,788,1088,1028,818,818,803,803,561,561,531,531,816,771,546,546,289,274,288,258, + -253,-317,-381,-446,-478,-509,1279,1279,-811,-1179,-1451,-1756,-1900,-2028,-2189,-2253,-2333,-2414,-2445,-2511,-2526,1313,1298,-2559,1041,1041,1040,1040,1025,1025,1024,1024,1022,1007,1021,991,1020,975,1019,959,687,687,1018,1017,671,671,655,655,1016,1015,639,639,758,758,623,623,757,607,756,591,755,575,754,559,543,543,1009,783,-575,-621,-685,-749,496,-590,750,749,734,748,974,989,1003,958,988,973,1002,942,987,957,972,1001,926,986,941,971,956,1000,910,985,925,999,894,970,-1071,-1087,-1102,1390,-1135,1436,1509,1451,1374,-1151,1405,1358,1480,1420,-1167,1507,1494,1389,1342,1465,1435,1450,1326,1505,1310,1493,1373,1479,1404,1492,1464,1419,428,443,472,397,736,526,464,464,486,457,442,471,484,482,1357,1449,1434,1478,1388,1491,1341,1490,1325,1489,1463,1403,1309,1477,1372,1448,1418,1433,1476,1356,1462,1387,-1439,1475,1340,1447,1402,1474,1324,1461,1371,1473,269,448,1432,1417,1308,1460,-1711,1459,-1727,1441,1099,1099,1446,1386,1431,1401,-1743,1289,1083,1083,1160,1160,1458,1445,1067,1067,1370,1457,1307,1430,1129,1129,1098,1098,268,432,267,416,266,400,-1887,1144,1187,1082,1173,1113,1186,1066,1050,1158,1128,1143,1172,1097,1171,1081,420,391,1157,1112,1170,1142,1127,1065,1169,1049,1156,1096,1141,1111,1155,1080,1126,1154,1064,1153,1140,1095,1048,-2159,1125,1110,1137,-2175,823,823,1139,1138,807,807,384,264,368,263,868,838,853,791,867,822,852,837,866,806,865,790,-2319,851,821,836,352,262,850,805,849,-2399,533,533,835,820,336,261,578,548,563,577,532,532,832,772,562,562,547,547,305,275,560,515,290,290,288,258 }; + static const uint8_t tab32[] = { 130,162,193,209,44,28,76,140,9,9,9,9,9,9,9,9,190,254,222,238,126,94,157,157,109,61,173,205 }; + static const uint8_t tab33[] = { 252,236,220,204,188,172,156,140,124,108,92,76,60,44,28,12 }; + static const int16_t tabindex[2*16] = { 0,32,64,98,0,132,180,218,292,364,426,538,648,746,0,1126,1460,1460,1460,1460,1460,1460,1460,1460,1842,1842,1842,1842,1842,1842,1842,1842 }; + static const uint8_t g_linbits[] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3,4,6,8,10,13,4,5,6,7,8,9,11,13 }; + +#define PEEK_BITS(n) (bs_cache >> (32 - n)) +#define FLUSH_BITS(n) { bs_cache <<= (n); bs_sh += (n); } +#define CHECK_BITS while (bs_sh >= 0) { bs_cache |= (uint32_t)*bs_next_ptr++ << bs_sh; bs_sh -= 8; } +#define BSPOS ((bs_next_ptr - bs->buf)*8 - 24 + bs_sh) + + float one = 0.0f; + int ireg = 0, big_val_cnt = gr_info->big_values; + const uint8_t *sfb = gr_info->sfbtab; + const uint8_t *bs_next_ptr = bs->buf + bs->pos/8; + uint32_t bs_cache = (((bs_next_ptr[0]*256u + bs_next_ptr[1])*256u + bs_next_ptr[2])*256u + bs_next_ptr[3]) << (bs->pos & 7); + int pairs_to_decode, np, bs_sh = (bs->pos & 7) - 8; + bs_next_ptr += 4; + + while (big_val_cnt > 0) + { + int tab_num = gr_info->table_select[ireg]; + int sfb_cnt = gr_info->region_count[ireg++]; + const int16_t *codebook = tabs + tabindex[tab_num]; + int linbits = g_linbits[tab_num]; + if (linbits) + { + do + { + np = *sfb++ / 2; + pairs_to_decode = MINIMP3_MIN(big_val_cnt, np); + one = *scf++; + do + { + int j, w = 5; + int leaf = codebook[PEEK_BITS(w)]; + while (leaf < 0) + { + FLUSH_BITS(w); + w = leaf & 7; + leaf = codebook[PEEK_BITS(w) - (leaf >> 3)]; + } + FLUSH_BITS(leaf >> 8); + + for (j = 0; j < 2; j++, dst++, leaf >>= 4) + { + int lsb = leaf & 0x0F; + if (lsb == 15) + { + lsb += PEEK_BITS(linbits); + FLUSH_BITS(linbits); + CHECK_BITS; + *dst = one*L3_pow_43(lsb)*((int32_t)bs_cache < 0 ? -1: 1); + } else + { + *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one; + } + FLUSH_BITS(lsb ? 1 : 0); + } + CHECK_BITS; + } while (--pairs_to_decode); + } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0); + } else + { + do + { + np = *sfb++ / 2; + pairs_to_decode = MINIMP3_MIN(big_val_cnt, np); + one = *scf++; + do + { + int j, w = 5; + int leaf = codebook[PEEK_BITS(w)]; + while (leaf < 0) + { + FLUSH_BITS(w); + w = leaf & 7; + leaf = codebook[PEEK_BITS(w) - (leaf >> 3)]; + } + FLUSH_BITS(leaf >> 8); + + for (j = 0; j < 2; j++, dst++, leaf >>= 4) + { + int lsb = leaf & 0x0F; + *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one; + FLUSH_BITS(lsb ? 1 : 0); + } + CHECK_BITS; + } while (--pairs_to_decode); + } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0); + } + } + + for (np = 1 - big_val_cnt;; dst += 4) + { + const uint8_t *codebook_count1 = (gr_info->count1_table) ? tab33 : tab32; + int leaf = codebook_count1[PEEK_BITS(4)]; + if (!(leaf & 8)) + { + leaf = codebook_count1[(leaf >> 3) + (bs_cache << 4 >> (32 - (leaf & 3)))]; + } + FLUSH_BITS(leaf & 7); + if (BSPOS > layer3gr_limit) + { + break; + } +#define RELOAD_SCALEFACTOR if (!--np) { np = *sfb++/2; if (!np) break; one = *scf++; } +#define DEQ_COUNT1(s) if (leaf & (128 >> s)) { dst[s] = ((int32_t)bs_cache < 0) ? -one : one; FLUSH_BITS(1) } + RELOAD_SCALEFACTOR; + DEQ_COUNT1(0); + DEQ_COUNT1(1); + RELOAD_SCALEFACTOR; + DEQ_COUNT1(2); + DEQ_COUNT1(3); + CHECK_BITS; + } + + bs->pos = layer3gr_limit; +} + +static void L3_midside_stereo(float *left, int n) +{ + int i = 0; + float *right = left + 576; +#if HAVE_SIMD + if (have_simd()) + { + for (; i < n - 3; i += 4) + { + f4 vl = VLD(left + i); + f4 vr = VLD(right + i); + VSTORE(left + i, VADD(vl, vr)); + VSTORE(right + i, VSUB(vl, vr)); + } +#ifdef __GNUC__ + /* Workaround for spurious -Waggressive-loop-optimizations warning from gcc. + * For more info see: https://github.com/lieff/minimp3/issues/88 + */ + if (__builtin_constant_p(n % 4 == 0) && n % 4 == 0) + return; +#endif + } +#endif /* HAVE_SIMD */ + for (; i < n; i++) + { + float a = left[i]; + float b = right[i]; + left[i] = a + b; + right[i] = a - b; + } +} + +static void L3_intensity_stereo_band(float *left, int n, float kl, float kr) +{ + int i; + for (i = 0; i < n; i++) + { + left[i + 576] = left[i]*kr; + left[i] = left[i]*kl; + } +} + +static void L3_stereo_top_band(const float *right, const uint8_t *sfb, int nbands, int max_band[3]) +{ + int i, k; + + max_band[0] = max_band[1] = max_band[2] = -1; + + for (i = 0; i < nbands; i++) + { + for (k = 0; k < sfb[i]; k += 2) + { + if (right[k] != 0 || right[k + 1] != 0) + { + max_band[i % 3] = i; + break; + } + } + right += sfb[i]; + } +} + +static void L3_stereo_process(float *left, const uint8_t *ist_pos, const uint8_t *sfb, const uint8_t *hdr, int max_band[3], int mpeg2_sh) +{ + static const float g_pan[7*2] = { 0,1,0.21132487f,0.78867513f,0.36602540f,0.63397460f,0.5f,0.5f,0.63397460f,0.36602540f,0.78867513f,0.21132487f,1,0 }; + unsigned i, max_pos = HDR_TEST_MPEG1(hdr) ? 7 : 64; + + for (i = 0; sfb[i]; i++) + { + unsigned ipos = ist_pos[i]; + if ((int)i > max_band[i % 3] && ipos < max_pos) + { + float kl, kr, s = HDR_TEST_MS_STEREO(hdr) ? 1.41421356f : 1; + if (HDR_TEST_MPEG1(hdr)) + { + kl = g_pan[2*ipos]; + kr = g_pan[2*ipos + 1]; + } else + { + kl = 1; + kr = L3_ldexp_q2(1, (ipos + 1) >> 1 << mpeg2_sh); + if (ipos & 1) + { + kl = kr; + kr = 1; + } + } + L3_intensity_stereo_band(left, sfb[i], kl*s, kr*s); + } else if (HDR_TEST_MS_STEREO(hdr)) + { + L3_midside_stereo(left, sfb[i]); + } + left += sfb[i]; + } +} + +static void L3_intensity_stereo(float *left, uint8_t *ist_pos, const L3_gr_info_t *gr, const uint8_t *hdr) +{ + int max_band[3], n_sfb = gr->n_long_sfb + gr->n_short_sfb; + int i, max_blocks = gr->n_short_sfb ? 3 : 1; + + L3_stereo_top_band(left + 576, gr->sfbtab, n_sfb, max_band); + if (gr->n_long_sfb) + { + max_band[0] = max_band[1] = max_band[2] = MINIMP3_MAX(MINIMP3_MAX(max_band[0], max_band[1]), max_band[2]); + } + for (i = 0; i < max_blocks; i++) + { + int default_pos = HDR_TEST_MPEG1(hdr) ? 3 : 0; + int itop = n_sfb - max_blocks + i; + int prev = itop - max_blocks; + ist_pos[itop] = max_band[i] >= prev ? default_pos : ist_pos[prev]; + } + L3_stereo_process(left, ist_pos, gr->sfbtab, hdr, max_band, gr[1].scalefac_compress & 1); +} + +static void L3_reorder(float *grbuf, float *scratch, const uint8_t *sfb) +{ + int i, len; + float *src = grbuf, *dst = scratch; + + for (;0 != (len = *sfb); sfb += 3, src += 2*len) + { + for (i = 0; i < len; i++, src++) + { + *dst++ = src[0*len]; + *dst++ = src[1*len]; + *dst++ = src[2*len]; + } + } + memcpy(grbuf, scratch, (dst - scratch)*sizeof(float)); +} + +static void L3_antialias(float *grbuf, int nbands) +{ + static const float g_aa[2][8] = { + {0.85749293f,0.88174200f,0.94962865f,0.98331459f,0.99551782f,0.99916056f,0.99989920f,0.99999316f}, + {0.51449576f,0.47173197f,0.31337745f,0.18191320f,0.09457419f,0.04096558f,0.01419856f,0.00369997f} + }; + + for (; nbands > 0; nbands--, grbuf += 18) + { + int i = 0; +#if HAVE_SIMD + if (have_simd()) for (; i < 8; i += 4) + { + f4 vu = VLD(grbuf + 18 + i); + f4 vd = VLD(grbuf + 14 - i); + f4 vc0 = VLD(g_aa[0] + i); + f4 vc1 = VLD(g_aa[1] + i); + vd = VREV(vd); + VSTORE(grbuf + 18 + i, VSUB(VMUL(vu, vc0), VMUL(vd, vc1))); + vd = VADD(VMUL(vu, vc1), VMUL(vd, vc0)); + VSTORE(grbuf + 14 - i, VREV(vd)); + } +#endif /* HAVE_SIMD */ +#ifndef MINIMP3_ONLY_SIMD + for(; i < 8; i++) + { + float u = grbuf[18 + i]; + float d = grbuf[17 - i]; + grbuf[18 + i] = u*g_aa[0][i] - d*g_aa[1][i]; + grbuf[17 - i] = u*g_aa[1][i] + d*g_aa[0][i]; + } +#endif /* MINIMP3_ONLY_SIMD */ + } +} + +static void L3_dct3_9(float *y) +{ + float s0, s1, s2, s3, s4, s5, s6, s7, s8, t0, t2, t4; + + s0 = y[0]; s2 = y[2]; s4 = y[4]; s6 = y[6]; s8 = y[8]; + t0 = s0 + s6*0.5f; + s0 -= s6; + t4 = (s4 + s2)*0.93969262f; + t2 = (s8 + s2)*0.76604444f; + s6 = (s4 - s8)*0.17364818f; + s4 += s8 - s2; + + s2 = s0 - s4*0.5f; + y[4] = s4 + s0; + s8 = t0 - t2 + s6; + s0 = t0 - t4 + t2; + s4 = t0 + t4 - s6; + + s1 = y[1]; s3 = y[3]; s5 = y[5]; s7 = y[7]; + + s3 *= 0.86602540f; + t0 = (s5 + s1)*0.98480775f; + t4 = (s5 - s7)*0.34202014f; + t2 = (s1 + s7)*0.64278761f; + s1 = (s1 - s5 - s7)*0.86602540f; + + s5 = t0 - s3 - t2; + s7 = t4 - s3 - t0; + s3 = t4 + s3 - t2; + + y[0] = s4 - s7; + y[1] = s2 + s1; + y[2] = s0 - s3; + y[3] = s8 + s5; + y[5] = s8 - s5; + y[6] = s0 + s3; + y[7] = s2 - s1; + y[8] = s4 + s7; +} + +static void L3_imdct36(float *grbuf, float *overlap, const float *window, int nbands) +{ + int i, j; + static const float g_twid9[18] = { + 0.73727734f,0.79335334f,0.84339145f,0.88701083f,0.92387953f,0.95371695f,0.97629601f,0.99144486f,0.99904822f,0.67559021f,0.60876143f,0.53729961f,0.46174861f,0.38268343f,0.30070580f,0.21643961f,0.13052619f,0.04361938f + }; + + for (j = 0; j < nbands; j++, grbuf += 18, overlap += 9) + { + float co[9], si[9]; + co[0] = -grbuf[0]; + si[0] = grbuf[17]; + for (i = 0; i < 4; i++) + { + si[8 - 2*i] = grbuf[4*i + 1] - grbuf[4*i + 2]; + co[1 + 2*i] = grbuf[4*i + 1] + grbuf[4*i + 2]; + si[7 - 2*i] = grbuf[4*i + 4] - grbuf[4*i + 3]; + co[2 + 2*i] = -(grbuf[4*i + 3] + grbuf[4*i + 4]); + } + L3_dct3_9(co); + L3_dct3_9(si); + + si[1] = -si[1]; + si[3] = -si[3]; + si[5] = -si[5]; + si[7] = -si[7]; + + i = 0; + +#if HAVE_SIMD + if (have_simd()) for (; i < 8; i += 4) + { + f4 vovl = VLD(overlap + i); + f4 vc = VLD(co + i); + f4 vs = VLD(si + i); + f4 vr0 = VLD(g_twid9 + i); + f4 vr1 = VLD(g_twid9 + 9 + i); + f4 vw0 = VLD(window + i); + f4 vw1 = VLD(window + 9 + i); + f4 vsum = VADD(VMUL(vc, vr1), VMUL(vs, vr0)); + VSTORE(overlap + i, VSUB(VMUL(vc, vr0), VMUL(vs, vr1))); + VSTORE(grbuf + i, VSUB(VMUL(vovl, vw0), VMUL(vsum, vw1))); + vsum = VADD(VMUL(vovl, vw1), VMUL(vsum, vw0)); + VSTORE(grbuf + 14 - i, VREV(vsum)); + } +#endif /* HAVE_SIMD */ + for (; i < 9; i++) + { + float ovl = overlap[i]; + float sum = co[i]*g_twid9[9 + i] + si[i]*g_twid9[0 + i]; + overlap[i] = co[i]*g_twid9[0 + i] - si[i]*g_twid9[9 + i]; + grbuf[i] = ovl*window[0 + i] - sum*window[9 + i]; + grbuf[17 - i] = ovl*window[9 + i] + sum*window[0 + i]; + } + } +} + +static void L3_idct3(float x0, float x1, float x2, float *dst) +{ + float m1 = x1*0.86602540f; + float a1 = x0 - x2*0.5f; + dst[1] = x0 + x2; + dst[0] = a1 + m1; + dst[2] = a1 - m1; +} + +static void L3_imdct12(float *x, float *dst, float *overlap) +{ + static const float g_twid3[6] = { 0.79335334f,0.92387953f,0.99144486f, 0.60876143f,0.38268343f,0.13052619f }; + float co[3], si[3]; + int i; + + L3_idct3(-x[0], x[6] + x[3], x[12] + x[9], co); + L3_idct3(x[15], x[12] - x[9], x[6] - x[3], si); + si[1] = -si[1]; + + for (i = 0; i < 3; i++) + { + float ovl = overlap[i]; + float sum = co[i]*g_twid3[3 + i] + si[i]*g_twid3[0 + i]; + overlap[i] = co[i]*g_twid3[0 + i] - si[i]*g_twid3[3 + i]; + dst[i] = ovl*g_twid3[2 - i] - sum*g_twid3[5 - i]; + dst[5 - i] = ovl*g_twid3[5 - i] + sum*g_twid3[2 - i]; + } +} + +static void L3_imdct_short(float *grbuf, float *overlap, int nbands) +{ + for (;nbands > 0; nbands--, overlap += 9, grbuf += 18) + { + float tmp[18]; + memcpy(tmp, grbuf, sizeof(tmp)); + memcpy(grbuf, overlap, 6*sizeof(float)); + L3_imdct12(tmp, grbuf + 6, overlap + 6); + L3_imdct12(tmp + 1, grbuf + 12, overlap + 6); + L3_imdct12(tmp + 2, overlap, overlap + 6); + } +} + +static void L3_change_sign(float *grbuf) +{ + int b, i; + for (b = 0, grbuf += 18; b < 32; b += 2, grbuf += 36) + for (i = 1; i < 18; i += 2) + grbuf[i] = -grbuf[i]; +} + +static void L3_imdct_gr(float *grbuf, float *overlap, unsigned block_type, unsigned n_long_bands) +{ + static const float g_mdct_window[2][18] = { + { 0.99904822f,0.99144486f,0.97629601f,0.95371695f,0.92387953f,0.88701083f,0.84339145f,0.79335334f,0.73727734f,0.04361938f,0.13052619f,0.21643961f,0.30070580f,0.38268343f,0.46174861f,0.53729961f,0.60876143f,0.67559021f }, + { 1,1,1,1,1,1,0.99144486f,0.92387953f,0.79335334f,0,0,0,0,0,0,0.13052619f,0.38268343f,0.60876143f } + }; + if (n_long_bands) + { + L3_imdct36(grbuf, overlap, g_mdct_window[0], n_long_bands); + grbuf += 18*n_long_bands; + overlap += 9*n_long_bands; + } + if (block_type == SHORT_BLOCK_TYPE) + L3_imdct_short(grbuf, overlap, 32 - n_long_bands); + else + L3_imdct36(grbuf, overlap, g_mdct_window[block_type == STOP_BLOCK_TYPE], 32 - n_long_bands); +} + +static void L3_save_reservoir(mp3dec_t *h, mp3dec_scratch_t *s) +{ + int pos = (s->bs.pos + 7)/8u; + int remains = s->bs.limit/8u - pos; + if (remains > MAX_BITRESERVOIR_BYTES) + { + pos += remains - MAX_BITRESERVOIR_BYTES; + remains = MAX_BITRESERVOIR_BYTES; + } + if (remains > 0) + { + memmove(h->reserv_buf, s->maindata + pos, remains); + } + h->reserv = remains; +} + +static int L3_restore_reservoir(mp3dec_t *h, bs_t *bs, mp3dec_scratch_t *s, int main_data_begin) +{ + int frame_bytes = (bs->limit - bs->pos)/8; + int bytes_have = MINIMP3_MIN(h->reserv, main_data_begin); + memcpy(s->maindata, h->reserv_buf + MINIMP3_MAX(0, h->reserv - main_data_begin), MINIMP3_MIN(h->reserv, main_data_begin)); + memcpy(s->maindata + bytes_have, bs->buf + bs->pos/8, frame_bytes); + bs_init(&s->bs, s->maindata, bytes_have + frame_bytes); + return h->reserv >= main_data_begin; +} + +static void L3_decode(mp3dec_t *h, mp3dec_scratch_t *s, L3_gr_info_t *gr_info, int nch) +{ + int ch; + + for (ch = 0; ch < nch; ch++) + { + int layer3gr_limit = s->bs.pos + gr_info[ch].part_23_length; + L3_decode_scalefactors(h->header, s->ist_pos[ch], &s->bs, gr_info + ch, s->scf, ch); + L3_huffman(s->grbuf[ch], &s->bs, gr_info + ch, s->scf, layer3gr_limit); + } + + if (HDR_TEST_I_STEREO(h->header)) + { + L3_intensity_stereo(s->grbuf[0], s->ist_pos[1], gr_info, h->header); + } else if (HDR_IS_MS_STEREO(h->header)) + { + L3_midside_stereo(s->grbuf[0], 576); + } + + for (ch = 0; ch < nch; ch++, gr_info++) + { + int aa_bands = 31; + int n_long_bands = (gr_info->mixed_block_flag ? 2 : 0) << (int)(HDR_GET_MY_SAMPLE_RATE(h->header) == 2); + + if (gr_info->n_short_sfb) + { + aa_bands = n_long_bands - 1; + L3_reorder(s->grbuf[ch] + n_long_bands*18, s->syn[0], gr_info->sfbtab + gr_info->n_long_sfb); + } + + L3_antialias(s->grbuf[ch], aa_bands); + L3_imdct_gr(s->grbuf[ch], h->mdct_overlap[ch], gr_info->block_type, n_long_bands); + L3_change_sign(s->grbuf[ch]); + } +} + +static void mp3d_DCT_II(float *grbuf, int n) +{ + static const float g_sec[24] = { + 10.19000816f,0.50060302f,0.50241929f,3.40760851f,0.50547093f,0.52249861f,2.05778098f,0.51544732f,0.56694406f,1.48416460f,0.53104258f,0.64682180f,1.16943991f,0.55310392f,0.78815460f,0.97256821f,0.58293498f,1.06067765f,0.83934963f,0.62250412f,1.72244716f,0.74453628f,0.67480832f,5.10114861f + }; + int i, k = 0; +#if HAVE_SIMD + if (have_simd()) for (; k < n; k += 4) + { + f4 t[4][8], *x; + float *y = grbuf + k; + + for (x = t[0], i = 0; i < 8; i++, x++) + { + f4 x0 = VLD(&y[i*18]); + f4 x1 = VLD(&y[(15 - i)*18]); + f4 x2 = VLD(&y[(16 + i)*18]); + f4 x3 = VLD(&y[(31 - i)*18]); + f4 t0 = VADD(x0, x3); + f4 t1 = VADD(x1, x2); + f4 t2 = VMUL_S(VSUB(x1, x2), g_sec[3*i + 0]); + f4 t3 = VMUL_S(VSUB(x0, x3), g_sec[3*i + 1]); + x[0] = VADD(t0, t1); + x[8] = VMUL_S(VSUB(t0, t1), g_sec[3*i + 2]); + x[16] = VADD(t3, t2); + x[24] = VMUL_S(VSUB(t3, t2), g_sec[3*i + 2]); + } + for (x = t[0], i = 0; i < 4; i++, x += 8) + { + f4 x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt; + xt = VSUB(x0, x7); x0 = VADD(x0, x7); + x7 = VSUB(x1, x6); x1 = VADD(x1, x6); + x6 = VSUB(x2, x5); x2 = VADD(x2, x5); + x5 = VSUB(x3, x4); x3 = VADD(x3, x4); + x4 = VSUB(x0, x3); x0 = VADD(x0, x3); + x3 = VSUB(x1, x2); x1 = VADD(x1, x2); + x[0] = VADD(x0, x1); + x[4] = VMUL_S(VSUB(x0, x1), 0.70710677f); + x5 = VADD(x5, x6); + x6 = VMUL_S(VADD(x6, x7), 0.70710677f); + x7 = VADD(x7, xt); + x3 = VMUL_S(VADD(x3, x4), 0.70710677f); + x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); /* rotate by PI/8 */ + x7 = VADD(x7, VMUL_S(x5, 0.382683432f)); + x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); + x0 = VSUB(xt, x6); xt = VADD(xt, x6); + x[1] = VMUL_S(VADD(xt, x7), 0.50979561f); + x[2] = VMUL_S(VADD(x4, x3), 0.54119611f); + x[3] = VMUL_S(VSUB(x0, x5), 0.60134488f); + x[5] = VMUL_S(VADD(x0, x5), 0.89997619f); + x[6] = VMUL_S(VSUB(x4, x3), 1.30656302f); + x[7] = VMUL_S(VSUB(xt, x7), 2.56291556f); + } + + if (k > n - 3) + { +#if HAVE_SSE +#define VSAVE2(i, v) _mm_storel_pi((__m64 *)(void*)&y[i*18], v) +#else /* HAVE_SSE */ +#define VSAVE2(i, v) vst1_f32((float32_t *)&y[i*18], vget_low_f32(v)) +#endif /* HAVE_SSE */ + for (i = 0; i < 7; i++, y += 4*18) + { + f4 s = VADD(t[3][i], t[3][i + 1]); + VSAVE2(0, t[0][i]); + VSAVE2(1, VADD(t[2][i], s)); + VSAVE2(2, VADD(t[1][i], t[1][i + 1])); + VSAVE2(3, VADD(t[2][1 + i], s)); + } + VSAVE2(0, t[0][7]); + VSAVE2(1, VADD(t[2][7], t[3][7])); + VSAVE2(2, t[1][7]); + VSAVE2(3, t[3][7]); + } else + { +#define VSAVE4(i, v) VSTORE(&y[i*18], v) + for (i = 0; i < 7; i++, y += 4*18) + { + f4 s = VADD(t[3][i], t[3][i + 1]); + VSAVE4(0, t[0][i]); + VSAVE4(1, VADD(t[2][i], s)); + VSAVE4(2, VADD(t[1][i], t[1][i + 1])); + VSAVE4(3, VADD(t[2][1 + i], s)); + } + VSAVE4(0, t[0][7]); + VSAVE4(1, VADD(t[2][7], t[3][7])); + VSAVE4(2, t[1][7]); + VSAVE4(3, t[3][7]); + } + } else +#endif /* HAVE_SIMD */ +#ifdef MINIMP3_ONLY_SIMD + {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */ +#else /* MINIMP3_ONLY_SIMD */ + for (; k < n; k++) + { + float t[4][8], *x, *y = grbuf + k; + + for (x = t[0], i = 0; i < 8; i++, x++) + { + float x0 = y[i*18]; + float x1 = y[(15 - i)*18]; + float x2 = y[(16 + i)*18]; + float x3 = y[(31 - i)*18]; + float t0 = x0 + x3; + float t1 = x1 + x2; + float t2 = (x1 - x2)*g_sec[3*i + 0]; + float t3 = (x0 - x3)*g_sec[3*i + 1]; + x[0] = t0 + t1; + x[8] = (t0 - t1)*g_sec[3*i + 2]; + x[16] = t3 + t2; + x[24] = (t3 - t2)*g_sec[3*i + 2]; + } + for (x = t[0], i = 0; i < 4; i++, x += 8) + { + float x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt; + xt = x0 - x7; x0 += x7; + x7 = x1 - x6; x1 += x6; + x6 = x2 - x5; x2 += x5; + x5 = x3 - x4; x3 += x4; + x4 = x0 - x3; x0 += x3; + x3 = x1 - x2; x1 += x2; + x[0] = x0 + x1; + x[4] = (x0 - x1)*0.70710677f; + x5 = x5 + x6; + x6 = (x6 + x7)*0.70710677f; + x7 = x7 + xt; + x3 = (x3 + x4)*0.70710677f; + x5 -= x7*0.198912367f; /* rotate by PI/8 */ + x7 += x5*0.382683432f; + x5 -= x7*0.198912367f; + x0 = xt - x6; xt += x6; + x[1] = (xt + x7)*0.50979561f; + x[2] = (x4 + x3)*0.54119611f; + x[3] = (x0 - x5)*0.60134488f; + x[5] = (x0 + x5)*0.89997619f; + x[6] = (x4 - x3)*1.30656302f; + x[7] = (xt - x7)*2.56291556f; + + } + for (i = 0; i < 7; i++, y += 4*18) + { + y[0*18] = t[0][i]; + y[1*18] = t[2][i] + t[3][i] + t[3][i + 1]; + y[2*18] = t[1][i] + t[1][i + 1]; + y[3*18] = t[2][i + 1] + t[3][i] + t[3][i + 1]; + } + y[0*18] = t[0][7]; + y[1*18] = t[2][7] + t[3][7]; + y[2*18] = t[1][7]; + y[3*18] = t[3][7]; + } +#endif /* MINIMP3_ONLY_SIMD */ +} + +#ifndef MINIMP3_FLOAT_OUTPUT +static int16_t mp3d_scale_pcm(float sample) +{ +#if HAVE_ARMV6 + int32_t s32 = (int32_t)(sample + .5f); + s32 -= (s32 < 0); + int16_t s = (int16_t)minimp3_clip_int16_arm(s32); +#else + if (sample >= 32766.5) return (int16_t) 32767; + if (sample <= -32767.5) return (int16_t)-32768; + int16_t s = (int16_t)(sample + .5f); + s -= (s < 0); /* away from zero, to be compliant */ +#endif + return s; +} +#else /* MINIMP3_FLOAT_OUTPUT */ +static float mp3d_scale_pcm(float sample) +{ + return sample*(1.f/32768.f); +} +#endif /* MINIMP3_FLOAT_OUTPUT */ + +static void mp3d_synth_pair(mp3d_sample_t *pcm, int nch, const float *z) +{ + float a; + a = (z[14*64] - z[ 0]) * 29; + a += (z[ 1*64] + z[13*64]) * 213; + a += (z[12*64] - z[ 2*64]) * 459; + a += (z[ 3*64] + z[11*64]) * 2037; + a += (z[10*64] - z[ 4*64]) * 5153; + a += (z[ 5*64] + z[ 9*64]) * 6574; + a += (z[ 8*64] - z[ 6*64]) * 37489; + a += z[ 7*64] * 75038; + pcm[0] = mp3d_scale_pcm(a); + + z += 2; + a = z[14*64] * 104; + a += z[12*64] * 1567; + a += z[10*64] * 9727; + a += z[ 8*64] * 64019; + a += z[ 6*64] * -9975; + a += z[ 4*64] * -45; + a += z[ 2*64] * 146; + a += z[ 0*64] * -5; + pcm[16*nch] = mp3d_scale_pcm(a); +} + +static void mp3d_synth(float *xl, mp3d_sample_t *dstl, int nch, float *lins) +{ + int i; + float *xr = xl + 576*(nch - 1); + mp3d_sample_t *dstr = dstl + (nch - 1); + + static const float g_win[] = { + -1,26,-31,208,218,401,-519,2063,2000,4788,-5517,7134,5959,35640,-39336,74992, + -1,24,-35,202,222,347,-581,2080,1952,4425,-5879,7640,5288,33791,-41176,74856, + -1,21,-38,196,225,294,-645,2087,1893,4063,-6237,8092,4561,31947,-43006,74630, + -1,19,-41,190,227,244,-711,2085,1822,3705,-6589,8492,3776,30112,-44821,74313, + -1,17,-45,183,228,197,-779,2075,1739,3351,-6935,8840,2935,28289,-46617,73908, + -1,16,-49,176,228,153,-848,2057,1644,3004,-7271,9139,2037,26482,-48390,73415, + -2,14,-53,169,227,111,-919,2032,1535,2663,-7597,9389,1082,24694,-50137,72835, + -2,13,-58,161,224,72,-991,2001,1414,2330,-7910,9592,70,22929,-51853,72169, + -2,11,-63,154,221,36,-1064,1962,1280,2006,-8209,9750,-998,21189,-53534,71420, + -2,10,-68,147,215,2,-1137,1919,1131,1692,-8491,9863,-2122,19478,-55178,70590, + -3,9,-73,139,208,-29,-1210,1870,970,1388,-8755,9935,-3300,17799,-56778,69679, + -3,8,-79,132,200,-57,-1283,1817,794,1095,-8998,9966,-4533,16155,-58333,68692, + -4,7,-85,125,189,-83,-1356,1759,605,814,-9219,9959,-5818,14548,-59838,67629, + -4,7,-91,117,177,-106,-1428,1698,402,545,-9416,9916,-7154,12980,-61289,66494, + -5,6,-97,111,163,-127,-1498,1634,185,288,-9585,9838,-8540,11455,-62684,65290 + }; + float *zlin = lins + 15*64; + const float *w = g_win; + + zlin[4*15] = xl[18*16]; + zlin[4*15 + 1] = xr[18*16]; + zlin[4*15 + 2] = xl[0]; + zlin[4*15 + 3] = xr[0]; + + zlin[4*31] = xl[1 + 18*16]; + zlin[4*31 + 1] = xr[1 + 18*16]; + zlin[4*31 + 2] = xl[1]; + zlin[4*31 + 3] = xr[1]; + + mp3d_synth_pair(dstr, nch, lins + 4*15 + 1); + mp3d_synth_pair(dstr + 32*nch, nch, lins + 4*15 + 64 + 1); + mp3d_synth_pair(dstl, nch, lins + 4*15); + mp3d_synth_pair(dstl + 32*nch, nch, lins + 4*15 + 64); + +#if HAVE_SIMD + if (have_simd()) for (i = 14; i >= 0; i--) + { +#define VLOAD(k) f4 w0 = VSET(*w++); f4 w1 = VSET(*w++); f4 vz = VLD(&zlin[4*i - 64*k]); f4 vy = VLD(&zlin[4*i - 64*(15 - k)]); +#define V0(k) { VLOAD(k) b = VADD(VMUL(vz, w1), VMUL(vy, w0)) ; a = VSUB(VMUL(vz, w0), VMUL(vy, w1)); } +#define V1(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vz, w0), VMUL(vy, w1))); } +#define V2(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vy, w1), VMUL(vz, w0))); } + f4 a, b; + zlin[4*i] = xl[18*(31 - i)]; + zlin[4*i + 1] = xr[18*(31 - i)]; + zlin[4*i + 2] = xl[1 + 18*(31 - i)]; + zlin[4*i + 3] = xr[1 + 18*(31 - i)]; + zlin[4*i + 64] = xl[1 + 18*(1 + i)]; + zlin[4*i + 64 + 1] = xr[1 + 18*(1 + i)]; + zlin[4*i - 64 + 2] = xl[18*(1 + i)]; + zlin[4*i - 64 + 3] = xr[18*(1 + i)]; + + V0(0) V2(1) V1(2) V2(3) V1(4) V2(5) V1(6) V2(7) + + { +#ifndef MINIMP3_FLOAT_OUTPUT +#if HAVE_SSE + static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f }; + static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f }; + __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)), + _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min))); + dstr[(15 - i)*nch] = _mm_extract_epi16(pcm8, 1); + dstr[(17 + i)*nch] = _mm_extract_epi16(pcm8, 5); + dstl[(15 - i)*nch] = _mm_extract_epi16(pcm8, 0); + dstl[(17 + i)*nch] = _mm_extract_epi16(pcm8, 4); + dstr[(47 - i)*nch] = _mm_extract_epi16(pcm8, 3); + dstr[(49 + i)*nch] = _mm_extract_epi16(pcm8, 7); + dstl[(47 - i)*nch] = _mm_extract_epi16(pcm8, 2); + dstl[(49 + i)*nch] = _mm_extract_epi16(pcm8, 6); +#else /* HAVE_SSE */ + int16x4_t pcma, pcmb; + a = VADD(a, VSET(0.5f)); + b = VADD(b, VSET(0.5f)); + pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0))))); + pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0))))); + vst1_lane_s16(dstr + (15 - i)*nch, pcma, 1); + vst1_lane_s16(dstr + (17 + i)*nch, pcmb, 1); + vst1_lane_s16(dstl + (15 - i)*nch, pcma, 0); + vst1_lane_s16(dstl + (17 + i)*nch, pcmb, 0); + vst1_lane_s16(dstr + (47 - i)*nch, pcma, 3); + vst1_lane_s16(dstr + (49 + i)*nch, pcmb, 3); + vst1_lane_s16(dstl + (47 - i)*nch, pcma, 2); + vst1_lane_s16(dstl + (49 + i)*nch, pcmb, 2); +#endif /* HAVE_SSE */ + +#else /* MINIMP3_FLOAT_OUTPUT */ + + static const f4 g_scale = { 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f }; + a = VMUL(a, g_scale); + b = VMUL(b, g_scale); +#if HAVE_SSE + _mm_store_ss(dstr + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(1, 1, 1, 1))); + _mm_store_ss(dstr + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(1, 1, 1, 1))); + _mm_store_ss(dstl + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(0, 0, 0, 0))); + _mm_store_ss(dstl + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(0, 0, 0, 0))); + _mm_store_ss(dstr + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(3, 3, 3, 3))); + _mm_store_ss(dstr + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(3, 3, 3, 3))); + _mm_store_ss(dstl + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(2, 2, 2, 2))); + _mm_store_ss(dstl + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(2, 2, 2, 2))); +#else /* HAVE_SSE */ + vst1q_lane_f32(dstr + (15 - i)*nch, a, 1); + vst1q_lane_f32(dstr + (17 + i)*nch, b, 1); + vst1q_lane_f32(dstl + (15 - i)*nch, a, 0); + vst1q_lane_f32(dstl + (17 + i)*nch, b, 0); + vst1q_lane_f32(dstr + (47 - i)*nch, a, 3); + vst1q_lane_f32(dstr + (49 + i)*nch, b, 3); + vst1q_lane_f32(dstl + (47 - i)*nch, a, 2); + vst1q_lane_f32(dstl + (49 + i)*nch, b, 2); +#endif /* HAVE_SSE */ +#endif /* MINIMP3_FLOAT_OUTPUT */ + } + } else +#endif /* HAVE_SIMD */ +#ifdef MINIMP3_ONLY_SIMD + {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */ +#else /* MINIMP3_ONLY_SIMD */ + for (i = 14; i >= 0; i--) + { +#define LOAD(k) float w0 = *w++; float w1 = *w++; float *vz = &zlin[4*i - k*64]; float *vy = &zlin[4*i - (15 - k)*64]; +#define S0(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] = vz[j]*w1 + vy[j]*w0, a[j] = vz[j]*w0 - vy[j]*w1; } +#define S1(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vz[j]*w0 - vy[j]*w1; } +#define S2(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vy[j]*w1 - vz[j]*w0; } + float a[4], b[4]; + + zlin[4*i] = xl[18*(31 - i)]; + zlin[4*i + 1] = xr[18*(31 - i)]; + zlin[4*i + 2] = xl[1 + 18*(31 - i)]; + zlin[4*i + 3] = xr[1 + 18*(31 - i)]; + zlin[4*(i + 16)] = xl[1 + 18*(1 + i)]; + zlin[4*(i + 16) + 1] = xr[1 + 18*(1 + i)]; + zlin[4*(i - 16) + 2] = xl[18*(1 + i)]; + zlin[4*(i - 16) + 3] = xr[18*(1 + i)]; + + S0(0) S2(1) S1(2) S2(3) S1(4) S2(5) S1(6) S2(7) + + dstr[(15 - i)*nch] = mp3d_scale_pcm(a[1]); + dstr[(17 + i)*nch] = mp3d_scale_pcm(b[1]); + dstl[(15 - i)*nch] = mp3d_scale_pcm(a[0]); + dstl[(17 + i)*nch] = mp3d_scale_pcm(b[0]); + dstr[(47 - i)*nch] = mp3d_scale_pcm(a[3]); + dstr[(49 + i)*nch] = mp3d_scale_pcm(b[3]); + dstl[(47 - i)*nch] = mp3d_scale_pcm(a[2]); + dstl[(49 + i)*nch] = mp3d_scale_pcm(b[2]); + } +#endif /* MINIMP3_ONLY_SIMD */ +} + +static void mp3d_synth_granule(float *qmf_state, float *grbuf, int nbands, int nch, mp3d_sample_t *pcm, float *lins) +{ + int i; + for (i = 0; i < nch; i++) + { + mp3d_DCT_II(grbuf + 576*i, nbands); + } + + memcpy(lins, qmf_state, sizeof(float)*15*64); + + for (i = 0; i < nbands; i += 2) + { + mp3d_synth(grbuf + i, pcm + 32*nch*i, nch, lins + i*64); + } +#ifndef MINIMP3_NONSTANDARD_BUT_LOGICAL + if (nch == 1) + { + for (i = 0; i < 15*64; i += 2) + { + qmf_state[i] = lins[nbands*64 + i]; + } + } else +#endif /* MINIMP3_NONSTANDARD_BUT_LOGICAL */ + { + memcpy(qmf_state, lins + nbands*64, sizeof(float)*15*64); + } +} + +static int mp3d_match_frame(const uint8_t *hdr, int mp3_bytes, int frame_bytes) +{ + int i, nmatch; + for (i = 0, nmatch = 0; nmatch < MAX_FRAME_SYNC_MATCHES; nmatch++) + { + i += hdr_frame_bytes(hdr + i, frame_bytes) + hdr_padding(hdr + i); + if (i + HDR_SIZE > mp3_bytes) + return nmatch > 0; + if (!hdr_compare(hdr, hdr + i)) + return 0; + } + return 1; +} + +static int mp3d_find_frame(const uint8_t *mp3, int mp3_bytes, int *free_format_bytes, int *ptr_frame_bytes) +{ + int i, k; + for (i = 0; i < mp3_bytes - HDR_SIZE; i++, mp3++) + { + if (hdr_valid(mp3)) + { + int frame_bytes = hdr_frame_bytes(mp3, *free_format_bytes); + int frame_and_padding = frame_bytes + hdr_padding(mp3); + + for (k = HDR_SIZE; !frame_bytes && k < MAX_FREE_FORMAT_FRAME_SIZE && i + 2*k < mp3_bytes - HDR_SIZE; k++) + { + if (hdr_compare(mp3, mp3 + k)) + { + int fb = k - hdr_padding(mp3); + int nextfb = fb + hdr_padding(mp3 + k); + if (i + k + nextfb + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + k + nextfb)) + continue; + frame_and_padding = k; + frame_bytes = fb; + *free_format_bytes = fb; + } + } + if ((frame_bytes && i + frame_and_padding <= mp3_bytes && + mp3d_match_frame(mp3, mp3_bytes - i, frame_bytes)) || + (!i && frame_and_padding == mp3_bytes)) + { + *ptr_frame_bytes = frame_and_padding; + return i; + } + *free_format_bytes = 0; + } + } + *ptr_frame_bytes = 0; + return mp3_bytes; +} + +void mp3dec_init(mp3dec_t *dec) +{ + dec->header[0] = 0; +} + +int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info) +{ + int i = 0, igr, frame_size = 0, success = 1; + const uint8_t *hdr; + bs_t bs_frame[1]; + mp3dec_scratch_t scratch; + + if (mp3_bytes > 4 && dec->header[0] == 0xff && hdr_compare(dec->header, mp3)) + { + frame_size = hdr_frame_bytes(mp3, dec->free_format_bytes) + hdr_padding(mp3); + if (frame_size != mp3_bytes && (frame_size + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + frame_size))) + { + frame_size = 0; + } + } + if (!frame_size) + { + memset(dec, 0, sizeof(mp3dec_t)); + i = mp3d_find_frame(mp3, mp3_bytes, &dec->free_format_bytes, &frame_size); + if (!frame_size || i + frame_size > mp3_bytes) + { + info->frame_bytes = i; + return 0; + } + } + + hdr = mp3 + i; + memcpy(dec->header, hdr, HDR_SIZE); + info->frame_bytes = i + frame_size; + info->frame_offset = i; + info->channels = HDR_IS_MONO(hdr) ? 1 : 2; + info->hz = hdr_sample_rate_hz(hdr); + info->layer = 4 - HDR_GET_LAYER(hdr); + info->bitrate_kbps = hdr_bitrate_kbps(hdr); + + if (!pcm) + { + return hdr_frame_samples(hdr); + } + + bs_init(bs_frame, hdr + HDR_SIZE, frame_size - HDR_SIZE); + if (HDR_IS_CRC(hdr)) + { + get_bits(bs_frame, 16); + } + + if (info->layer == 3) + { + int main_data_begin = L3_read_side_info(bs_frame, scratch.gr_info, hdr); + if (main_data_begin < 0 || bs_frame->pos > bs_frame->limit) + { + mp3dec_init(dec); + return 0; + } + success = L3_restore_reservoir(dec, bs_frame, &scratch, main_data_begin); + if (success) + { + for (igr = 0; igr < (HDR_TEST_MPEG1(hdr) ? 2 : 1); igr++, pcm += 576*info->channels) + { + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + L3_decode(dec, &scratch, scratch.gr_info + igr*info->channels, info->channels); + mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 18, info->channels, pcm, scratch.syn[0]); + } + } + L3_save_reservoir(dec, &scratch); + } else + { +#ifdef MINIMP3_ONLY_MP3 + return 0; +#else /* MINIMP3_ONLY_MP3 */ + L12_scale_info sci[1]; + L12_read_scale_info(hdr, bs_frame, sci); + + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + for (i = 0, igr = 0; igr < 3; igr++) + { + if (12 == (i += L12_dequantize_granule(scratch.grbuf[0] + i, bs_frame, sci, info->layer | 1))) + { + i = 0; + L12_apply_scf_384(sci, sci->scf + igr, scratch.grbuf[0]); + mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 12, info->channels, pcm, scratch.syn[0]); + memset(scratch.grbuf[0], 0, 576*2*sizeof(float)); + pcm += 384*info->channels; + } + if (bs_frame->pos > bs_frame->limit) + { + mp3dec_init(dec); + return 0; + } + } +#endif /* MINIMP3_ONLY_MP3 */ + } + return success*hdr_frame_samples(dec->header); +} + +#ifdef MINIMP3_FLOAT_OUTPUT +void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples) +{ + int i = 0; +#if HAVE_SIMD + int aligned_count = num_samples & ~7; + for(; i < aligned_count; i += 8) + { + static const f4 g_scale = { 32768.0f, 32768.0f, 32768.0f, 32768.0f }; + f4 a = VMUL(VLD(&in[i ]), g_scale); + f4 b = VMUL(VLD(&in[i+4]), g_scale); +#if HAVE_SSE + static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f }; + static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f }; + __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)), + _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min))); + out[i ] = _mm_extract_epi16(pcm8, 0); + out[i+1] = _mm_extract_epi16(pcm8, 1); + out[i+2] = _mm_extract_epi16(pcm8, 2); + out[i+3] = _mm_extract_epi16(pcm8, 3); + out[i+4] = _mm_extract_epi16(pcm8, 4); + out[i+5] = _mm_extract_epi16(pcm8, 5); + out[i+6] = _mm_extract_epi16(pcm8, 6); + out[i+7] = _mm_extract_epi16(pcm8, 7); +#else /* HAVE_SSE */ + int16x4_t pcma, pcmb; + a = VADD(a, VSET(0.5f)); + b = VADD(b, VSET(0.5f)); + pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0))))); + pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0))))); + vst1_lane_s16(out+i , pcma, 0); + vst1_lane_s16(out+i+1, pcma, 1); + vst1_lane_s16(out+i+2, pcma, 2); + vst1_lane_s16(out+i+3, pcma, 3); + vst1_lane_s16(out+i+4, pcmb, 0); + vst1_lane_s16(out+i+5, pcmb, 1); + vst1_lane_s16(out+i+6, pcmb, 2); + vst1_lane_s16(out+i+7, pcmb, 3); +#endif /* HAVE_SSE */ + } +#endif /* HAVE_SIMD */ + for(; i < num_samples; i++) + { + float sample = in[i] * 32768.0f; + if (sample >= 32766.5) + out[i] = (int16_t) 32767; + else if (sample <= -32767.5) + out[i] = (int16_t)-32768; + else + { + int16_t s = (int16_t)(sample + .5f); + s -= (s < 0); /* away from zero, to be compliant */ + out[i] = s; + } + } +} +#endif /* MINIMP3_FLOAT_OUTPUT */ +#endif /* MINIMP3_IMPLEMENTATION && !_MINIMP3_IMPLEMENTATION_GUARD */ diff --git a/resources/lib/minimp3/minimp3_ex.h b/resources/lib/minimp3/minimp3_ex.h new file mode 100644 index 0000000..2871705 --- /dev/null +++ b/resources/lib/minimp3/minimp3_ex.h @@ -0,0 +1,1397 @@ +#ifndef MINIMP3_EXT_H +#define MINIMP3_EXT_H +/* + https://github.com/lieff/minimp3 + To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. + This software is distributed without any warranty. + See . +*/ +#include +#include "minimp3.h" + +/* flags for mp3dec_ex_open_* functions */ +#define MP3D_SEEK_TO_BYTE 0 /* mp3dec_ex_seek seeks to byte in stream */ +#define MP3D_SEEK_TO_SAMPLE 1 /* mp3dec_ex_seek precisely seeks to sample using index (created during duration calculation scan or when mp3dec_ex_seek called) */ +#define MP3D_DO_NOT_SCAN 2 /* do not scan whole stream for duration if vbrtag not found, mp3dec_ex_t::samples will be filled only if mp3dec_ex_t::vbr_tag_found == 1 */ +#ifdef MINIMP3_ALLOW_MONO_STEREO_TRANSITION +#define MP3D_ALLOW_MONO_STEREO_TRANSITION 4 +#define MP3D_FLAGS_MASK 7 +#else +#define MP3D_FLAGS_MASK 3 +#endif + +/* compile-time config */ +#define MINIMP3_PREDECODE_FRAMES 2 /* frames to pre-decode and skip after seek (to fill internal structures) */ +/*#define MINIMP3_SEEK_IDX_LINEAR_SEARCH*/ /* define to use linear index search instead of binary search on seek */ +#define MINIMP3_IO_SIZE (128*1024) /* io buffer size for streaming functions, must be greater than MINIMP3_BUF_SIZE */ +#define MINIMP3_BUF_SIZE (16*1024) /* buffer which can hold minimum 10 consecutive mp3 frames (~16KB) worst case */ +/*#define MINIMP3_SCAN_LIMIT (256*1024)*/ /* how many bytes will be scanned to search first valid mp3 frame, to prevent stall on large non-mp3 files */ +#define MINIMP3_ENABLE_RING 0 /* WIP enable hardware magic ring buffer if available, to make less input buffer memmove(s) in callback IO mode */ + +/* return error codes */ +#define MP3D_E_PARAM -1 +#define MP3D_E_MEMORY -2 +#define MP3D_E_IOERROR -3 +#define MP3D_E_USER -4 /* can be used to stop processing from callbacks without indicating specific error */ +#define MP3D_E_DECODE -5 /* decode error which can't be safely skipped, such as sample rate, layer and channels change */ + +typedef struct +{ + mp3d_sample_t *buffer; + size_t samples; /* channels included, byte size = samples*sizeof(mp3d_sample_t) */ + int channels, hz, layer, avg_bitrate_kbps; +} mp3dec_file_info_t; + +typedef struct +{ + const uint8_t *buffer; + size_t size; +} mp3dec_map_info_t; + +typedef struct +{ + uint64_t sample; + uint64_t offset; +} mp3dec_frame_t; + +typedef struct +{ + mp3dec_frame_t *frames; + size_t num_frames, capacity; +} mp3dec_index_t; + +typedef size_t (*MP3D_READ_CB)(void *buf, size_t size, void *user_data); +typedef int (*MP3D_SEEK_CB)(uint64_t position, void *user_data); + +typedef struct +{ + MP3D_READ_CB read; + void *read_data; + MP3D_SEEK_CB seek; + void *seek_data; +} mp3dec_io_t; + +typedef struct +{ + mp3dec_t mp3d; + mp3dec_map_info_t file; + mp3dec_io_t *io; + mp3dec_index_t index; + uint64_t offset, samples, detected_samples, cur_sample, start_offset, end_offset; + mp3dec_frame_info_t info; + mp3d_sample_t buffer[MINIMP3_MAX_SAMPLES_PER_FRAME]; + size_t input_consumed, input_filled; + int is_file, flags, vbr_tag_found, indexes_built; + int free_format_bytes; + int buffer_samples, buffer_consumed, to_skip, start_delay; + int last_error; +} mp3dec_ex_t; + +typedef int (*MP3D_ITERATE_CB)(void *user_data, const uint8_t *frame, int frame_size, int free_format_bytes, size_t buf_size, uint64_t offset, mp3dec_frame_info_t *info); +typedef int (*MP3D_PROGRESS_CB)(void *user_data, size_t file_size, uint64_t offset, mp3dec_frame_info_t *info); + +#ifdef __cplusplus +extern "C" { +#endif + +/* detect mp3/mpa format */ +int mp3dec_detect_buf(const uint8_t *buf, size_t buf_size); +int mp3dec_detect_cb(mp3dec_io_t *io, uint8_t *buf, size_t buf_size); +/* decode whole buffer block */ +int mp3dec_load_buf(mp3dec_t *dec, const uint8_t *buf, size_t buf_size, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data); +int mp3dec_load_cb(mp3dec_t *dec, mp3dec_io_t *io, uint8_t *buf, size_t buf_size, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data); +/* iterate through frames */ +int mp3dec_iterate_buf(const uint8_t *buf, size_t buf_size, MP3D_ITERATE_CB callback, void *user_data); +int mp3dec_iterate_cb(mp3dec_io_t *io, uint8_t *buf, size_t buf_size, MP3D_ITERATE_CB callback, void *user_data); +/* streaming decoder with seeking capability */ +int mp3dec_ex_open_buf(mp3dec_ex_t *dec, const uint8_t *buf, size_t buf_size, int flags); +int mp3dec_ex_open_cb(mp3dec_ex_t *dec, mp3dec_io_t *io, int flags); +void mp3dec_ex_close(mp3dec_ex_t *dec); +int mp3dec_ex_seek(mp3dec_ex_t *dec, uint64_t position); +size_t mp3dec_ex_read_frame(mp3dec_ex_t *dec, mp3d_sample_t **buf, mp3dec_frame_info_t *frame_info, size_t max_samples); +size_t mp3dec_ex_read(mp3dec_ex_t *dec, mp3d_sample_t *buf, size_t samples); +#ifndef MINIMP3_NO_STDIO +/* stdio versions of file detect, load, iterate and stream */ +int mp3dec_detect(const char *file_name); +int mp3dec_load(mp3dec_t *dec, const char *file_name, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data); +int mp3dec_iterate(const char *file_name, MP3D_ITERATE_CB callback, void *user_data); +int mp3dec_ex_open(mp3dec_ex_t *dec, const char *file_name, int flags); +#ifdef _WIN32 +int mp3dec_detect_w(const wchar_t *file_name); +int mp3dec_load_w(mp3dec_t *dec, const wchar_t *file_name, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data); +int mp3dec_iterate_w(const wchar_t *file_name, MP3D_ITERATE_CB callback, void *user_data); +int mp3dec_ex_open_w(mp3dec_ex_t *dec, const wchar_t *file_name, int flags); +#endif +#endif + +#ifdef __cplusplus +} +#endif +#endif /*MINIMP3_EXT_H*/ + +#if defined(MINIMP3_IMPLEMENTATION) && !defined(_MINIMP3_EX_IMPLEMENTATION_GUARD) +#define _MINIMP3_EX_IMPLEMENTATION_GUARD +#include +#include "minimp3.h" + +static void mp3dec_skip_id3v1(const uint8_t *buf, size_t *pbuf_size) +{ + size_t buf_size = *pbuf_size; +#ifndef MINIMP3_NOSKIP_ID3V1 + if (buf_size >= 128 && !memcmp(buf + buf_size - 128, "TAG", 3)) + { + buf_size -= 128; + if (buf_size >= 227 && !memcmp(buf + buf_size - 227, "TAG+", 4)) + buf_size -= 227; + } +#endif +#ifndef MINIMP3_NOSKIP_APEV2 + if (buf_size > 32 && !memcmp(buf + buf_size - 32, "APETAGEX", 8)) + { + buf_size -= 32; + const uint8_t *tag = buf + buf_size + 8 + 4; + uint32_t tag_size = (uint32_t)(tag[3] << 24) | (tag[2] << 16) | (tag[1] << 8) | tag[0]; + if (buf_size >= tag_size) + buf_size -= tag_size; + } +#endif + *pbuf_size = buf_size; +} + +static size_t mp3dec_skip_id3v2(const uint8_t *buf, size_t buf_size) +{ +#define MINIMP3_ID3_DETECT_SIZE 10 +#ifndef MINIMP3_NOSKIP_ID3V2 + if (buf_size >= MINIMP3_ID3_DETECT_SIZE && !memcmp(buf, "ID3", 3) && !((buf[5] & 15) || (buf[6] & 0x80) || (buf[7] & 0x80) || (buf[8] & 0x80) || (buf[9] & 0x80))) + { + size_t id3v2size = (((buf[6] & 0x7f) << 21) | ((buf[7] & 0x7f) << 14) | ((buf[8] & 0x7f) << 7) | (buf[9] & 0x7f)) + 10; + if ((buf[5] & 16)) + id3v2size += 10; /* footer */ + return id3v2size; + } +#endif + return 0; +} + +static void mp3dec_skip_id3(const uint8_t **pbuf, size_t *pbuf_size) +{ + uint8_t *buf = (uint8_t *)(*pbuf); + size_t buf_size = *pbuf_size; + size_t id3v2size = mp3dec_skip_id3v2(buf, buf_size); + if (id3v2size) + { + if (id3v2size >= buf_size) + id3v2size = buf_size; + buf += id3v2size; + buf_size -= id3v2size; + } + mp3dec_skip_id3v1(buf, &buf_size); + *pbuf = (const uint8_t *)buf; + *pbuf_size = buf_size; +} + +static int mp3dec_check_vbrtag(const uint8_t *frame, int frame_size, uint32_t *frames, int *delay, int *padding) +{ + static const char g_xing_tag[4] = { 'X', 'i', 'n', 'g' }; + static const char g_info_tag[4] = { 'I', 'n', 'f', 'o' }; +#define FRAMES_FLAG 1 +#define BYTES_FLAG 2 +#define TOC_FLAG 4 +#define VBR_SCALE_FLAG 8 + /* Side info offsets after header: + / Mono Stereo + / MPEG1 17 32 + / MPEG2 & 2.5 9 17*/ + bs_t bs[1]; + L3_gr_info_t gr_info[4]; + bs_init(bs, frame + HDR_SIZE, frame_size - HDR_SIZE); + if (HDR_IS_CRC(frame)) + get_bits(bs, 16); + if (L3_read_side_info(bs, gr_info, frame) < 0) + return 0; /* side info corrupted */ + + const uint8_t *tag = frame + HDR_SIZE + bs->pos/8; + if (memcmp(g_xing_tag, tag, 4) && memcmp(g_info_tag, tag, 4)) + return 0; + int flags = tag[7]; + if (!((flags & FRAMES_FLAG))) + return -1; + tag += 8; + *frames = (uint32_t)(tag[0] << 24) | (tag[1] << 16) | (tag[2] << 8) | tag[3]; + tag += 4; + if (flags & BYTES_FLAG) + tag += 4; + if (flags & TOC_FLAG) + tag += 100; + if (flags & VBR_SCALE_FLAG) + tag += 4; + *delay = *padding = 0; + if (*tag) + { /* extension, LAME, Lavc, etc. Should be the same structure. */ + tag += 21; + if (tag - frame + 14 >= frame_size) + return 0; + *delay = ((tag[0] << 4) | (tag[1] >> 4)) + (528 + 1); + *padding = (((tag[1] & 0xF) << 8) | tag[2]) - (528 + 1); + } + return 1; +} + +int mp3dec_detect_buf(const uint8_t *buf, size_t buf_size) +{ + return mp3dec_detect_cb(0, (uint8_t *)buf, buf_size); +} + +int mp3dec_detect_cb(mp3dec_io_t *io, uint8_t *buf, size_t buf_size) +{ + if (!buf || (size_t)-1 == buf_size || (io && buf_size < MINIMP3_BUF_SIZE)) + return MP3D_E_PARAM; + size_t filled = buf_size; + if (io) + { + if (io->seek(0, io->seek_data)) + return MP3D_E_IOERROR; + filled = io->read(buf, MINIMP3_ID3_DETECT_SIZE, io->read_data); + if (filled > MINIMP3_ID3_DETECT_SIZE) + return MP3D_E_IOERROR; + } + if (filled < MINIMP3_ID3_DETECT_SIZE) + return MP3D_E_USER; /* too small, can't be mp3/mpa */ + if (mp3dec_skip_id3v2(buf, filled)) + return 0; /* id3v2 tag is enough evidence */ + if (io) + { + size_t readed = io->read(buf + MINIMP3_ID3_DETECT_SIZE, buf_size - MINIMP3_ID3_DETECT_SIZE, io->read_data); + if (readed > (buf_size - MINIMP3_ID3_DETECT_SIZE)) + return MP3D_E_IOERROR; + filled += readed; + if (filled < MINIMP3_BUF_SIZE) + mp3dec_skip_id3v1(buf, &filled); + } else + { + mp3dec_skip_id3v1(buf, &filled); + if (filled > MINIMP3_BUF_SIZE) + filled = MINIMP3_BUF_SIZE; + } + int free_format_bytes, frame_size; + mp3d_find_frame(buf, filled, &free_format_bytes, &frame_size); + if (frame_size) + return 0; /* MAX_FRAME_SYNC_MATCHES consecutive frames found */ + return MP3D_E_USER; +} + +int mp3dec_load_buf(mp3dec_t *dec, const uint8_t *buf, size_t buf_size, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data) +{ + return mp3dec_load_cb(dec, 0, (uint8_t *)buf, buf_size, info, progress_cb, user_data); +} + +int mp3dec_load_cb(mp3dec_t *dec, mp3dec_io_t *io, uint8_t *buf, size_t buf_size, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data) +{ + if (!dec || !buf || !info || (size_t)-1 == buf_size || (io && buf_size < MINIMP3_BUF_SIZE)) + return MP3D_E_PARAM; + uint64_t detected_samples = 0; + size_t orig_buf_size = buf_size; + int to_skip = 0; + mp3dec_frame_info_t frame_info; + memset(info, 0, sizeof(*info)); + memset(&frame_info, 0, sizeof(frame_info)); + + /* skip id3 */ + size_t filled = 0, consumed = 0; + int eof = 0, ret = 0; + if (io) + { + if (io->seek(0, io->seek_data)) + return MP3D_E_IOERROR; + filled = io->read(buf, MINIMP3_ID3_DETECT_SIZE, io->read_data); + if (filled > MINIMP3_ID3_DETECT_SIZE) + return MP3D_E_IOERROR; + if (MINIMP3_ID3_DETECT_SIZE != filled) + return 0; + size_t id3v2size = mp3dec_skip_id3v2(buf, filled); + if (id3v2size) + { + if (io->seek(id3v2size, io->seek_data)) + return MP3D_E_IOERROR; + filled = io->read(buf, buf_size, io->read_data); + if (filled > buf_size) + return MP3D_E_IOERROR; + } else + { + size_t readed = io->read(buf + MINIMP3_ID3_DETECT_SIZE, buf_size - MINIMP3_ID3_DETECT_SIZE, io->read_data); + if (readed > (buf_size - MINIMP3_ID3_DETECT_SIZE)) + return MP3D_E_IOERROR; + filled += readed; + } + if (filled < MINIMP3_BUF_SIZE) + mp3dec_skip_id3v1(buf, &filled); + } else + { + mp3dec_skip_id3((const uint8_t **)&buf, &buf_size); + if (!buf_size) + return 0; + } + /* try to make allocation size assumption by first frame or vbr tag */ + mp3dec_init(dec); + int samples; + do + { + uint32_t frames; + int i, delay, padding, free_format_bytes = 0, frame_size = 0; + const uint8_t *hdr; + if (io) + { + if (!eof && filled - consumed < MINIMP3_BUF_SIZE) + { /* keep minimum 10 consecutive mp3 frames (~16KB) worst case */ + memmove(buf, buf + consumed, filled - consumed); + filled -= consumed; + consumed = 0; + size_t readed = io->read(buf + filled, buf_size - filled, io->read_data); + if (readed > (buf_size - filled)) + return MP3D_E_IOERROR; + if (readed != (buf_size - filled)) + eof = 1; + filled += readed; + if (eof) + mp3dec_skip_id3v1(buf, &filled); + } + i = mp3d_find_frame(buf + consumed, filled - consumed, &free_format_bytes, &frame_size); + consumed += i; + hdr = buf + consumed; + } else + { + i = mp3d_find_frame(buf, buf_size, &free_format_bytes, &frame_size); + buf += i; + buf_size -= i; + hdr = buf; + } + if (i && !frame_size) + continue; + if (!frame_size) + return 0; + frame_info.channels = HDR_IS_MONO(hdr) ? 1 : 2; + frame_info.hz = hdr_sample_rate_hz(hdr); + frame_info.layer = 4 - HDR_GET_LAYER(hdr); + frame_info.bitrate_kbps = hdr_bitrate_kbps(hdr); + frame_info.frame_bytes = frame_size; + samples = hdr_frame_samples(hdr)*frame_info.channels; + if (3 != frame_info.layer) + break; + int ret = mp3dec_check_vbrtag(hdr, frame_size, &frames, &delay, &padding); + if (ret > 0) + { + padding *= frame_info.channels; + to_skip = delay*frame_info.channels; + detected_samples = samples*(uint64_t)frames; + if (detected_samples >= (uint64_t)to_skip) + detected_samples -= to_skip; + if (padding > 0 && detected_samples >= (uint64_t)padding) + detected_samples -= padding; + if (!detected_samples) + return 0; + } + if (ret) + { + if (io) + { + consumed += frame_size; + } else + { + buf += frame_size; + buf_size -= frame_size; + } + } + break; + } while(1); + size_t allocated = MINIMP3_MAX_SAMPLES_PER_FRAME*sizeof(mp3d_sample_t); + if (detected_samples) + allocated += detected_samples*sizeof(mp3d_sample_t); + else + allocated += (buf_size/frame_info.frame_bytes)*samples*sizeof(mp3d_sample_t); + info->buffer = (mp3d_sample_t*)malloc(allocated); + if (!info->buffer) + return MP3D_E_MEMORY; + /* save info */ + info->channels = frame_info.channels; + info->hz = frame_info.hz; + info->layer = frame_info.layer; + /* decode all frames */ + size_t avg_bitrate_kbps = 0, frames = 0; + do + { + if ((allocated - info->samples*sizeof(mp3d_sample_t)) < MINIMP3_MAX_SAMPLES_PER_FRAME*sizeof(mp3d_sample_t)) + { + allocated *= 2; + mp3d_sample_t *alloc_buf = (mp3d_sample_t*)realloc(info->buffer, allocated); + if (!alloc_buf) + return MP3D_E_MEMORY; + info->buffer = alloc_buf; + } + if (io) + { + if (!eof && filled - consumed < MINIMP3_BUF_SIZE) + { /* keep minimum 10 consecutive mp3 frames (~16KB) worst case */ + memmove(buf, buf + consumed, filled - consumed); + filled -= consumed; + consumed = 0; + size_t readed = io->read(buf + filled, buf_size - filled, io->read_data); + if (readed != (buf_size - filled)) + eof = 1; + filled += readed; + if (eof) + mp3dec_skip_id3v1(buf, &filled); + } + samples = mp3dec_decode_frame(dec, buf + consumed, filled - consumed, info->buffer + info->samples, &frame_info); + consumed += frame_info.frame_bytes; + } else + { + samples = mp3dec_decode_frame(dec, buf, MINIMP3_MIN(buf_size, (size_t)INT_MAX), info->buffer + info->samples, &frame_info); + buf += frame_info.frame_bytes; + buf_size -= frame_info.frame_bytes; + } + if (samples) + { + if (info->hz != frame_info.hz || info->layer != frame_info.layer) + { + ret = MP3D_E_DECODE; + break; + } + if (info->channels && info->channels != frame_info.channels) + { +#ifdef MINIMP3_ALLOW_MONO_STEREO_TRANSITION + info->channels = 0; /* mark file with mono-stereo transition */ +#else + ret = MP3D_E_DECODE; + break; +#endif + } + samples *= frame_info.channels; + if (to_skip) + { + size_t skip = MINIMP3_MIN(samples, to_skip); + to_skip -= skip; + samples -= skip; + memmove(info->buffer, info->buffer + skip, samples*sizeof(mp3d_sample_t)); + } + info->samples += samples; + avg_bitrate_kbps += frame_info.bitrate_kbps; + frames++; + if (progress_cb) + { + ret = progress_cb(user_data, orig_buf_size, orig_buf_size - buf_size, &frame_info); + if (ret) + break; + } + } + } while (frame_info.frame_bytes); + if (detected_samples && info->samples > detected_samples) + info->samples = detected_samples; /* cut padding */ + /* reallocate to normal buffer size */ + if (allocated != info->samples*sizeof(mp3d_sample_t)) + { + mp3d_sample_t *alloc_buf = (mp3d_sample_t*)realloc(info->buffer, info->samples*sizeof(mp3d_sample_t)); + if (!alloc_buf && info->samples) + return MP3D_E_MEMORY; + info->buffer = alloc_buf; + } + if (frames) + info->avg_bitrate_kbps = avg_bitrate_kbps/frames; + return ret; +} + +int mp3dec_iterate_buf(const uint8_t *buf, size_t buf_size, MP3D_ITERATE_CB callback, void *user_data) +{ + const uint8_t *orig_buf = buf; + if (!buf || (size_t)-1 == buf_size || !callback) + return MP3D_E_PARAM; + /* skip id3 */ + mp3dec_skip_id3(&buf, &buf_size); + if (!buf_size) + return 0; + mp3dec_frame_info_t frame_info; + memset(&frame_info, 0, sizeof(frame_info)); + do + { + int free_format_bytes = 0, frame_size = 0, ret; + int i = mp3d_find_frame(buf, buf_size, &free_format_bytes, &frame_size); + buf += i; + buf_size -= i; + if (i && !frame_size) + continue; + if (!frame_size) + break; + const uint8_t *hdr = buf; + frame_info.channels = HDR_IS_MONO(hdr) ? 1 : 2; + frame_info.hz = hdr_sample_rate_hz(hdr); + frame_info.layer = 4 - HDR_GET_LAYER(hdr); + frame_info.bitrate_kbps = hdr_bitrate_kbps(hdr); + frame_info.frame_bytes = frame_size; + + if (callback) + { + if ((ret = callback(user_data, hdr, frame_size, free_format_bytes, buf_size, hdr - orig_buf, &frame_info))) + return ret; + } + buf += frame_size; + buf_size -= frame_size; + } while (1); + return 0; +} + +int mp3dec_iterate_cb(mp3dec_io_t *io, uint8_t *buf, size_t buf_size, MP3D_ITERATE_CB callback, void *user_data) +{ + if (!io || !buf || (size_t)-1 == buf_size || buf_size < MINIMP3_BUF_SIZE || !callback) + return MP3D_E_PARAM; + size_t filled = io->read(buf, MINIMP3_ID3_DETECT_SIZE, io->read_data), consumed = 0; + uint64_t readed = 0; + mp3dec_frame_info_t frame_info; + int eof = 0; + memset(&frame_info, 0, sizeof(frame_info)); + if (filled > MINIMP3_ID3_DETECT_SIZE) + return MP3D_E_IOERROR; + if (MINIMP3_ID3_DETECT_SIZE != filled) + return 0; + size_t id3v2size = mp3dec_skip_id3v2(buf, filled); + if (id3v2size) + { + if (io->seek(id3v2size, io->seek_data)) + return MP3D_E_IOERROR; + filled = io->read(buf, buf_size, io->read_data); + if (filled > buf_size) + return MP3D_E_IOERROR; + readed += id3v2size; + } else + { + size_t readed = io->read(buf + MINIMP3_ID3_DETECT_SIZE, buf_size - MINIMP3_ID3_DETECT_SIZE, io->read_data); + if (readed > (buf_size - MINIMP3_ID3_DETECT_SIZE)) + return MP3D_E_IOERROR; + filled += readed; + } + if (filled < MINIMP3_BUF_SIZE) + mp3dec_skip_id3v1(buf, &filled); + do + { + int free_format_bytes = 0, frame_size = 0, ret; + int i = mp3d_find_frame(buf + consumed, filled - consumed, &free_format_bytes, &frame_size); + if (i && !frame_size) + { + consumed += i; + continue; + } + if (!frame_size) + break; + const uint8_t *hdr = buf + consumed + i; + frame_info.channels = HDR_IS_MONO(hdr) ? 1 : 2; + frame_info.hz = hdr_sample_rate_hz(hdr); + frame_info.layer = 4 - HDR_GET_LAYER(hdr); + frame_info.bitrate_kbps = hdr_bitrate_kbps(hdr); + frame_info.frame_bytes = frame_size; + + readed += i; + if (callback) + { + if ((ret = callback(user_data, hdr, frame_size, free_format_bytes, filled - consumed, readed, &frame_info))) + return ret; + } + readed += frame_size; + consumed += i + frame_size; + if (!eof && filled - consumed < MINIMP3_BUF_SIZE) + { /* keep minimum 10 consecutive mp3 frames (~16KB) worst case */ + memmove(buf, buf + consumed, filled - consumed); + filled -= consumed; + consumed = 0; + size_t readed = io->read(buf + filled, buf_size - filled, io->read_data); + if (readed > (buf_size - filled)) + return MP3D_E_IOERROR; + if (readed != (buf_size - filled)) + eof = 1; + filled += readed; + if (eof) + mp3dec_skip_id3v1(buf, &filled); + } + } while (1); + return 0; +} + +static int mp3dec_load_index(void *user_data, const uint8_t *frame, int frame_size, int free_format_bytes, size_t buf_size, uint64_t offset, mp3dec_frame_info_t *info) +{ + mp3dec_frame_t *idx_frame; + mp3dec_ex_t *dec = (mp3dec_ex_t *)user_data; + if (!dec->index.frames && !dec->start_offset) + { /* detect VBR tag and try to avoid full scan */ + uint32_t frames; + int delay, padding; + dec->info = *info; + dec->start_offset = dec->offset = offset; + dec->end_offset = offset + buf_size; + dec->free_format_bytes = free_format_bytes; /* should not change */ + if (3 == dec->info.layer) + { + int ret = mp3dec_check_vbrtag(frame, frame_size, &frames, &delay, &padding); + if (ret) + dec->start_offset = dec->offset = offset + frame_size; + if (ret > 0) + { + padding *= info->channels; + dec->start_delay = dec->to_skip = delay*info->channels; + dec->samples = hdr_frame_samples(frame)*info->channels*(uint64_t)frames; + if (dec->samples >= (uint64_t)dec->start_delay) + dec->samples -= dec->start_delay; + if (padding > 0 && dec->samples >= (uint64_t)padding) + dec->samples -= padding; + dec->detected_samples = dec->samples; + dec->vbr_tag_found = 1; + return MP3D_E_USER; + } else if (ret < 0) + return 0; + } + } + if (dec->flags & MP3D_DO_NOT_SCAN) + return MP3D_E_USER; + if (dec->index.num_frames + 1 > dec->index.capacity) + { + if (!dec->index.capacity) + dec->index.capacity = 4096; + else + dec->index.capacity *= 2; + mp3dec_frame_t *alloc_buf = (mp3dec_frame_t *)realloc((void*)dec->index.frames, sizeof(mp3dec_frame_t)*dec->index.capacity); + if (!alloc_buf) + return MP3D_E_MEMORY; + dec->index.frames = alloc_buf; + } + idx_frame = &dec->index.frames[dec->index.num_frames++]; + idx_frame->offset = offset; + idx_frame->sample = dec->samples; + if (!dec->buffer_samples && dec->index.num_frames < 256) + { /* for some cutted mp3 frames, bit-reservoir not filled and decoding can't be started from first frames */ + /* try to decode up to 255 first frames till samples starts to decode */ + dec->buffer_samples = mp3dec_decode_frame(&dec->mp3d, frame, MINIMP3_MIN(buf_size, (size_t)INT_MAX), dec->buffer, info); + dec->samples += dec->buffer_samples*info->channels; + } else + dec->samples += hdr_frame_samples(frame)*info->channels; + return 0; +} + +int mp3dec_ex_open_buf(mp3dec_ex_t *dec, const uint8_t *buf, size_t buf_size, int flags) +{ + if (!dec || !buf || (size_t)-1 == buf_size || (flags & (~MP3D_FLAGS_MASK))) + return MP3D_E_PARAM; + memset(dec, 0, sizeof(*dec)); + dec->file.buffer = buf; + dec->file.size = buf_size; + dec->flags = flags; + mp3dec_init(&dec->mp3d); + int ret = mp3dec_iterate_buf(dec->file.buffer, dec->file.size, mp3dec_load_index, dec); + if (ret && MP3D_E_USER != ret) + return ret; + mp3dec_init(&dec->mp3d); + dec->buffer_samples = 0; + dec->indexes_built = !(dec->vbr_tag_found || (flags & MP3D_DO_NOT_SCAN)); + dec->flags &= (~MP3D_DO_NOT_SCAN); + return 0; +} + +#ifndef MINIMP3_SEEK_IDX_LINEAR_SEARCH +static size_t mp3dec_idx_binary_search(mp3dec_index_t *idx, uint64_t position) +{ + size_t end = idx->num_frames, start = 0, index = 0; + while (start <= end) + { + size_t mid = (start + end) / 2; + if (idx->frames[mid].sample >= position) + { /* move left side. */ + if (idx->frames[mid].sample == position) + return mid; + end = mid - 1; + } else + { /* move to right side */ + index = mid; + start = mid + 1; + if (start == idx->num_frames) + break; + } + } + return index; +} +#endif + +int mp3dec_ex_seek(mp3dec_ex_t *dec, uint64_t position) +{ + size_t i; + if (!dec) + return MP3D_E_PARAM; + if (!(dec->flags & MP3D_SEEK_TO_SAMPLE)) + { + if (dec->io) + { + dec->offset = position; + } else + { + dec->offset = MINIMP3_MIN(position, dec->file.size); + } + dec->cur_sample = 0; + goto do_exit; + } + dec->cur_sample = position; + position += dec->start_delay; + if (0 == position) + { /* optimize seek to zero, no index needed */ +seek_zero: + dec->offset = dec->start_offset; + dec->to_skip = 0; + goto do_exit; + } + if (!dec->indexes_built) + { /* no index created yet (vbr tag used to calculate track length or MP3D_DO_NOT_SCAN open flag used) */ + dec->indexes_built = 1; + dec->samples = 0; + dec->buffer_samples = 0; + if (dec->io) + { + if (dec->io->seek(dec->start_offset, dec->io->seek_data)) + return MP3D_E_IOERROR; + int ret = mp3dec_iterate_cb(dec->io, (uint8_t *)dec->file.buffer, dec->file.size, mp3dec_load_index, dec); + if (ret && MP3D_E_USER != ret) + return ret; + } else + { + int ret = mp3dec_iterate_buf(dec->file.buffer + dec->start_offset, dec->file.size - dec->start_offset, mp3dec_load_index, dec); + if (ret && MP3D_E_USER != ret) + return ret; + } + for (i = 0; i < dec->index.num_frames; i++) + dec->index.frames[i].offset += dec->start_offset; + dec->samples = dec->detected_samples; + } + if (!dec->index.frames) + goto seek_zero; /* no frames in file - seek to zero */ +#ifdef MINIMP3_SEEK_IDX_LINEAR_SEARCH + for (i = 0; i < dec->index.num_frames; i++) + { + if (dec->index.frames[i].sample >= position) + break; + } +#else + i = mp3dec_idx_binary_search(&dec->index, position); +#endif + if (i) + { + int to_fill_bytes = 511; + int skip_frames = MINIMP3_PREDECODE_FRAMES +#ifdef MINIMP3_SEEK_IDX_LINEAR_SEARCH + + ((dec->index.frames[i].sample == position) ? 0 : 1) +#endif + ; + i -= MINIMP3_MIN(i, (size_t)skip_frames); + if (3 == dec->info.layer) + { + while (i && to_fill_bytes) + { /* make sure bit-reservoir is filled when we start decoding */ + bs_t bs[1]; + L3_gr_info_t gr_info[4]; + int frame_bytes, frame_size; + const uint8_t *hdr; + if (dec->io) + { + hdr = dec->file.buffer; + if (dec->io->seek(dec->index.frames[i - 1].offset, dec->io->seek_data)) + return MP3D_E_IOERROR; + size_t readed = dec->io->read((uint8_t *)hdr, HDR_SIZE, dec->io->read_data); + if (readed != HDR_SIZE) + return MP3D_E_IOERROR; + frame_size = hdr_frame_bytes(hdr, dec->free_format_bytes) + hdr_padding(hdr); + readed = dec->io->read((uint8_t *)hdr + HDR_SIZE, frame_size - HDR_SIZE, dec->io->read_data); + if (readed != (size_t)(frame_size - HDR_SIZE)) + return MP3D_E_IOERROR; + bs_init(bs, hdr + HDR_SIZE, frame_size - HDR_SIZE); + } else + { + hdr = dec->file.buffer + dec->index.frames[i - 1].offset; + frame_size = hdr_frame_bytes(hdr, dec->free_format_bytes) + hdr_padding(hdr); + bs_init(bs, hdr + HDR_SIZE, frame_size - HDR_SIZE); + } + if (HDR_IS_CRC(hdr)) + get_bits(bs, 16); + i--; + if (L3_read_side_info(bs, gr_info, hdr) < 0) + break; /* frame not decodable, we can start from here */ + frame_bytes = (bs->limit - bs->pos)/8; + to_fill_bytes -= MINIMP3_MIN(to_fill_bytes, frame_bytes); + } + } + } + dec->offset = dec->index.frames[i].offset; + dec->to_skip = position - dec->index.frames[i].sample; + while ((i + 1) < dec->index.num_frames && !dec->index.frames[i].sample && !dec->index.frames[i + 1].sample) + { /* skip not decodable first frames */ + const uint8_t *hdr; + if (dec->io) + { + hdr = dec->file.buffer; + if (dec->io->seek(dec->index.frames[i].offset, dec->io->seek_data)) + return MP3D_E_IOERROR; + size_t readed = dec->io->read((uint8_t *)hdr, HDR_SIZE, dec->io->read_data); + if (readed != HDR_SIZE) + return MP3D_E_IOERROR; + } else + hdr = dec->file.buffer + dec->index.frames[i].offset; + dec->to_skip += hdr_frame_samples(hdr)*dec->info.channels; + i++; + } +do_exit: + if (dec->io) + { + if (dec->io->seek(dec->offset, dec->io->seek_data)) + return MP3D_E_IOERROR; + } + dec->buffer_samples = 0; + dec->buffer_consumed = 0; + dec->input_consumed = 0; + dec->input_filled = 0; + dec->last_error = 0; + mp3dec_init(&dec->mp3d); + return 0; +} + +size_t mp3dec_ex_read_frame(mp3dec_ex_t *dec, mp3d_sample_t **buf, mp3dec_frame_info_t *frame_info, size_t max_samples) +{ + if (!dec || !buf || !frame_info) + { + if (dec) + dec->last_error = MP3D_E_PARAM; + return 0; + } + if (dec->detected_samples && dec->cur_sample >= dec->detected_samples) + return 0; /* at end of stream */ + if (dec->last_error) + return 0; /* error eof state, seek can reset it */ + *buf = NULL; + uint64_t end_offset = dec->end_offset ? dec->end_offset : dec->file.size; + int eof = 0; + while (dec->buffer_consumed == dec->buffer_samples) + { + const uint8_t *dec_buf; + if (dec->io) + { + if (!eof && (dec->input_filled - dec->input_consumed) < MINIMP3_BUF_SIZE) + { /* keep minimum 10 consecutive mp3 frames (~16KB) worst case */ + memmove((uint8_t*)dec->file.buffer, (uint8_t*)dec->file.buffer + dec->input_consumed, dec->input_filled - dec->input_consumed); + dec->input_filled -= dec->input_consumed; + dec->input_consumed = 0; + size_t readed = dec->io->read((uint8_t*)dec->file.buffer + dec->input_filled, dec->file.size - dec->input_filled, dec->io->read_data); + if (readed > (dec->file.size - dec->input_filled)) + { + dec->last_error = MP3D_E_IOERROR; + readed = 0; + } + if (readed != (dec->file.size - dec->input_filled)) + eof = 1; + dec->input_filled += readed; + if (eof) + mp3dec_skip_id3v1((uint8_t*)dec->file.buffer, &dec->input_filled); + } + dec_buf = dec->file.buffer + dec->input_consumed; + if (!(dec->input_filled - dec->input_consumed)) + return 0; + dec->buffer_samples = mp3dec_decode_frame(&dec->mp3d, dec_buf, dec->input_filled - dec->input_consumed, dec->buffer, frame_info); + dec->input_consumed += frame_info->frame_bytes; + } else + { + dec_buf = dec->file.buffer + dec->offset; + uint64_t buf_size = end_offset - dec->offset; + if (!buf_size) + return 0; + dec->buffer_samples = mp3dec_decode_frame(&dec->mp3d, dec_buf, MINIMP3_MIN(buf_size, (uint64_t)INT_MAX), dec->buffer, frame_info); + } + dec->buffer_consumed = 0; + if (dec->info.hz != frame_info->hz || dec->info.layer != frame_info->layer) + { +return_e_decode: + dec->last_error = MP3D_E_DECODE; + return 0; + } + if (dec->buffer_samples) + { + dec->buffer_samples *= frame_info->channels; + if (dec->to_skip) + { + size_t skip = MINIMP3_MIN(dec->buffer_samples, dec->to_skip); + dec->buffer_consumed += skip; + dec->to_skip -= skip; + } + if ( +#ifdef MINIMP3_ALLOW_MONO_STEREO_TRANSITION + !(dec->flags & MP3D_ALLOW_MONO_STEREO_TRANSITION) && +#endif + dec->buffer_consumed != dec->buffer_samples && dec->info.channels != frame_info->channels) + { + goto return_e_decode; + } + } else if (dec->to_skip) + { /* In mp3 decoding not always can start decode from any frame because of bit reservoir, + count skip samples for such frames */ + int frame_samples = hdr_frame_samples(dec_buf)*frame_info->channels; + dec->to_skip -= MINIMP3_MIN(frame_samples, dec->to_skip); + } + dec->offset += frame_info->frame_bytes; + } + size_t out_samples = MINIMP3_MIN((size_t)(dec->buffer_samples - dec->buffer_consumed), max_samples); + if (dec->detected_samples) + { /* count decoded samples to properly cut padding */ + if (dec->cur_sample + out_samples >= dec->detected_samples) + out_samples = dec->detected_samples - dec->cur_sample; + } + dec->cur_sample += out_samples; + *buf = dec->buffer + dec->buffer_consumed; + dec->buffer_consumed += out_samples; + return out_samples; +} + +size_t mp3dec_ex_read(mp3dec_ex_t *dec, mp3d_sample_t *buf, size_t samples) +{ + if (!dec || !buf) + { + if (dec) + dec->last_error = MP3D_E_PARAM; + return 0; + } + mp3dec_frame_info_t frame_info; + memset(&frame_info, 0, sizeof(frame_info)); + size_t samples_requested = samples; + while (samples) + { + mp3d_sample_t *buf_frame = NULL; + size_t read_samples = mp3dec_ex_read_frame(dec, &buf_frame, &frame_info, samples); + if (!read_samples) + { + break; + } + memcpy(buf, buf_frame, read_samples * sizeof(mp3d_sample_t)); + buf += read_samples; + samples -= read_samples; + } + return samples_requested - samples; +} + +int mp3dec_ex_open_cb(mp3dec_ex_t *dec, mp3dec_io_t *io, int flags) +{ + if (!dec || !io || (flags & (~MP3D_FLAGS_MASK))) + return MP3D_E_PARAM; + memset(dec, 0, sizeof(*dec)); +#ifdef MINIMP3_HAVE_RING + int ret; + if (ret = mp3dec_open_ring(&dec->file, MINIMP3_IO_SIZE)) + return ret; +#else + dec->file.size = MINIMP3_IO_SIZE; + dec->file.buffer = (const uint8_t*)malloc(dec->file.size); + if (!dec->file.buffer) + return MP3D_E_MEMORY; +#endif + dec->flags = flags; + dec->io = io; + mp3dec_init(&dec->mp3d); + if (io->seek(0, io->seek_data)) + return MP3D_E_IOERROR; + int ret = mp3dec_iterate_cb(io, (uint8_t *)dec->file.buffer, dec->file.size, mp3dec_load_index, dec); + if (ret && MP3D_E_USER != ret) + return ret; + if (dec->io->seek(dec->start_offset, dec->io->seek_data)) + return MP3D_E_IOERROR; + mp3dec_init(&dec->mp3d); + dec->buffer_samples = 0; + dec->indexes_built = !(dec->vbr_tag_found || (flags & MP3D_DO_NOT_SCAN)); + dec->flags &= (~MP3D_DO_NOT_SCAN); + return 0; +} + + +#ifndef MINIMP3_NO_STDIO + +#if defined(__linux__) || defined(__FreeBSD__) +#include +#include +#include +#include +#include +#include +#if !defined(_GNU_SOURCE) +#include +#include +#endif +#if !defined(MAP_POPULATE) && defined(__linux__) +#define MAP_POPULATE 0x08000 +#elif !defined(MAP_POPULATE) +#define MAP_POPULATE 0 +#endif + +static void mp3dec_close_file(mp3dec_map_info_t *map_info) +{ + if (map_info->buffer && MAP_FAILED != map_info->buffer) + munmap((void *)map_info->buffer, map_info->size); + map_info->buffer = 0; + map_info->size = 0; +} + +static int mp3dec_open_file(const char *file_name, mp3dec_map_info_t *map_info) +{ + if (!file_name) + return MP3D_E_PARAM; + int file; + struct stat st; + memset(map_info, 0, sizeof(*map_info)); +retry_open: + file = open(file_name, O_RDONLY); + if (file < 0 && (errno == EAGAIN || errno == EINTR)) + goto retry_open; + if (file < 0 || fstat(file, &st) < 0) + { + close(file); + return MP3D_E_IOERROR; + } + + map_info->size = st.st_size; +retry_mmap: + map_info->buffer = (const uint8_t *)mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE | MAP_POPULATE, file, 0); + if (MAP_FAILED == map_info->buffer && (errno == EAGAIN || errno == EINTR)) + goto retry_mmap; + close(file); + if (MAP_FAILED == map_info->buffer) + return MP3D_E_IOERROR; + return 0; +} + +#if MINIMP3_ENABLE_RING && defined(__linux__) && defined(_GNU_SOURCE) +#define MINIMP3_HAVE_RING +static void mp3dec_close_ring(mp3dec_map_info_t *map_info) +{ +#if defined(__linux__) && defined(_GNU_SOURCE) + if (map_info->buffer && MAP_FAILED != map_info->buffer) + munmap((void *)map_info->buffer, map_info->size*2); +#else + if (map_info->buffer) + { + shmdt(map_info->buffer); + shmdt(map_info->buffer + map_info->size); + } +#endif + map_info->buffer = 0; + map_info->size = 0; +} + +static int mp3dec_open_ring(mp3dec_map_info_t *map_info, size_t size) +{ + int memfd, page_size; +#if defined(__linux__) && defined(_GNU_SOURCE) + void *buffer; + int res; +#endif + memset(map_info, 0, sizeof(*map_info)); + +#ifdef _SC_PAGESIZE + page_size = sysconf(_SC_PAGESIZE); +#else + page_size = getpagesize(); +#endif + map_info->size = (size + page_size - 1)/page_size*page_size; + +#if defined(__linux__) && defined(_GNU_SOURCE) + memfd = memfd_create("mp3_ring", 0); + if (memfd < 0) + return MP3D_E_MEMORY; + +retry_ftruncate: + res = ftruncate(memfd, map_info->size); + if (res && (errno == EAGAIN || errno == EINTR)) + goto retry_ftruncate; + if (res) + goto error; + +retry_mmap: + map_info->buffer = (const uint8_t *)mmap(NULL, map_info->size*2, PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); + if (MAP_FAILED == map_info->buffer && (errno == EAGAIN || errno == EINTR)) + goto retry_mmap; + if (MAP_FAILED == map_info->buffer || !map_info->buffer) + goto error; +retry_mmap2: + buffer = mmap((void *)map_info->buffer, map_info->size, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED, memfd, 0); + if (MAP_FAILED == map_info->buffer && (errno == EAGAIN || errno == EINTR)) + goto retry_mmap2; + if (MAP_FAILED == map_info->buffer || buffer != (void *)map_info->buffer) + goto error; +retry_mmap3: + buffer = mmap((void *)map_info->buffer + map_info->size, map_info->size, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED, memfd, 0); + if (MAP_FAILED == map_info->buffer && (errno == EAGAIN || errno == EINTR)) + goto retry_mmap3; + if (MAP_FAILED == map_info->buffer || buffer != (void *)(map_info->buffer + map_info->size)) + goto error; + + close(memfd); + return 0; +error: + close(memfd); + mp3dec_close_ring(map_info); + return MP3D_E_MEMORY; +#else + memfd = shmget(IPC_PRIVATE, map_info->size, IPC_CREAT | 0700); + if (memfd < 0) + return MP3D_E_MEMORY; +retry_mmap: + map_info->buffer = (const uint8_t *)mmap(NULL, map_info->size*2, PROT_NONE, MAP_PRIVATE, -1, 0); + if (MAP_FAILED == map_info->buffer && (errno == EAGAIN || errno == EINTR)) + goto retry_mmap; + if (MAP_FAILED == map_info->buffer) + goto error; + if (map_info->buffer != shmat(memfd, map_info->buffer, 0)) + goto error; + if ((map_info->buffer + map_info->size) != shmat(memfd, map_info->buffer + map_info->size, 0)) + goto error; + if (shmctl(memfd, IPC_RMID, NULL) < 0) + return MP3D_E_MEMORY; + return 0; +error: + shmctl(memfd, IPC_RMID, NULL); + mp3dec_close_ring(map_info); + return MP3D_E_MEMORY; +#endif +} +#endif /*MINIMP3_ENABLE_RING*/ +#elif defined(_WIN32) +#include + +static void mp3dec_close_file(mp3dec_map_info_t *map_info) +{ + if (map_info->buffer) + UnmapViewOfFile(map_info->buffer); + map_info->buffer = 0; + map_info->size = 0; +} + +static int mp3dec_open_file_h(HANDLE file, mp3dec_map_info_t *map_info) +{ + memset(map_info, 0, sizeof(*map_info)); + + HANDLE mapping = NULL; + LARGE_INTEGER s; + s.LowPart = GetFileSize(file, (DWORD*)&s.HighPart); + if (s.LowPart == INVALID_FILE_SIZE && GetLastError() != NO_ERROR) + goto error; + map_info->size = s.QuadPart; + + mapping = CreateFileMapping(file, NULL, PAGE_READONLY, 0, 0, NULL); + if (!mapping) + goto error; + map_info->buffer = (const uint8_t*)MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, s.QuadPart); + CloseHandle(mapping); + if (!map_info->buffer) + goto error; + + CloseHandle(file); + return 0; +error: + mp3dec_close_file(map_info); + CloseHandle(file); + return MP3D_E_IOERROR; +} + +static int mp3dec_open_file(const char *file_name, mp3dec_map_info_t *map_info) +{ + if (!file_name) + return MP3D_E_PARAM; + HANDLE file = CreateFileA(file_name, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, 0, 0); + if (INVALID_HANDLE_VALUE == file) + return MP3D_E_IOERROR; + return mp3dec_open_file_h(file, map_info); +} + +static int mp3dec_open_file_w(const wchar_t *file_name, mp3dec_map_info_t *map_info) +{ + if (!file_name) + return MP3D_E_PARAM; + HANDLE file = CreateFileW(file_name, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, 0, 0); + if (INVALID_HANDLE_VALUE == file) + return MP3D_E_IOERROR; + return mp3dec_open_file_h(file, map_info); +} +#else +#include + +static void mp3dec_close_file(mp3dec_map_info_t *map_info) +{ + if (map_info->buffer) + free((void *)map_info->buffer); + map_info->buffer = 0; + map_info->size = 0; +} + +static int mp3dec_open_file(const char *file_name, mp3dec_map_info_t *map_info) +{ + if (!file_name) + return MP3D_E_PARAM; + memset(map_info, 0, sizeof(*map_info)); + FILE *file = fopen(file_name, "rb"); + if (!file) + return MP3D_E_IOERROR; + int res = MP3D_E_IOERROR; + long size = -1; + if (fseek(file, 0, SEEK_END)) + goto error; + size = ftell(file); + if (size < 0) + goto error; + map_info->size = (size_t)size; + if (fseek(file, 0, SEEK_SET)) + goto error; + map_info->buffer = (uint8_t *)malloc(map_info->size); + if (!map_info->buffer) + { + res = MP3D_E_MEMORY; + goto error; + } + if (fread((void *)map_info->buffer, 1, map_info->size, file) != map_info->size) + goto error; + fclose(file); + return 0; +error: + mp3dec_close_file(map_info); + fclose(file); + return res; +} +#endif + +static int mp3dec_detect_mapinfo(mp3dec_map_info_t *map_info) +{ + int ret = mp3dec_detect_buf(map_info->buffer, map_info->size); + mp3dec_close_file(map_info); + return ret; +} + +static int mp3dec_load_mapinfo(mp3dec_t *dec, mp3dec_map_info_t *map_info, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data) +{ + int ret = mp3dec_load_buf(dec, map_info->buffer, map_info->size, info, progress_cb, user_data); + mp3dec_close_file(map_info); + return ret; +} + +static int mp3dec_iterate_mapinfo(mp3dec_map_info_t *map_info, MP3D_ITERATE_CB callback, void *user_data) +{ + int ret = mp3dec_iterate_buf(map_info->buffer, map_info->size, callback, user_data); + mp3dec_close_file(map_info); + return ret; +} + +static int mp3dec_ex_open_mapinfo(mp3dec_ex_t *dec, int flags) +{ + int ret = mp3dec_ex_open_buf(dec, dec->file.buffer, dec->file.size, flags); + dec->is_file = 1; + if (ret) + mp3dec_ex_close(dec); + return ret; +} + +int mp3dec_detect(const char *file_name) +{ + int ret; + mp3dec_map_info_t map_info; + if ((ret = mp3dec_open_file(file_name, &map_info))) + return ret; + return mp3dec_detect_mapinfo(&map_info); +} + +int mp3dec_load(mp3dec_t *dec, const char *file_name, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data) +{ + int ret; + mp3dec_map_info_t map_info; + if ((ret = mp3dec_open_file(file_name, &map_info))) + return ret; + return mp3dec_load_mapinfo(dec, &map_info, info, progress_cb, user_data); +} + +int mp3dec_iterate(const char *file_name, MP3D_ITERATE_CB callback, void *user_data) +{ + int ret; + mp3dec_map_info_t map_info; + if ((ret = mp3dec_open_file(file_name, &map_info))) + return ret; + return mp3dec_iterate_mapinfo(&map_info, callback, user_data); +} + +int mp3dec_ex_open(mp3dec_ex_t *dec, const char *file_name, int flags) +{ + int ret; + if (!dec) + return MP3D_E_PARAM; + if ((ret = mp3dec_open_file(file_name, &dec->file))) + return ret; + return mp3dec_ex_open_mapinfo(dec, flags); +} + +void mp3dec_ex_close(mp3dec_ex_t *dec) +{ +#ifdef MINIMP3_HAVE_RING + if (dec->io) + mp3dec_close_ring(&dec->file); +#else + if (dec->io && dec->file.buffer) + free((void*)dec->file.buffer); +#endif + if (dec->is_file) + mp3dec_close_file(&dec->file); + if (dec->index.frames) + free(dec->index.frames); + memset(dec, 0, sizeof(*dec)); +} + +#ifdef _WIN32 +int mp3dec_detect_w(const wchar_t *file_name) +{ + int ret; + mp3dec_map_info_t map_info; + if ((ret = mp3dec_open_file_w(file_name, &map_info))) + return ret; + return mp3dec_detect_mapinfo(&map_info); +} + +int mp3dec_load_w(mp3dec_t *dec, const wchar_t *file_name, mp3dec_file_info_t *info, MP3D_PROGRESS_CB progress_cb, void *user_data) +{ + int ret; + mp3dec_map_info_t map_info; + if ((ret = mp3dec_open_file_w(file_name, &map_info))) + return ret; + return mp3dec_load_mapinfo(dec, &map_info, info, progress_cb, user_data); +} + +int mp3dec_iterate_w(const wchar_t *file_name, MP3D_ITERATE_CB callback, void *user_data) +{ + int ret; + mp3dec_map_info_t map_info; + if ((ret = mp3dec_open_file_w(file_name, &map_info))) + return ret; + return mp3dec_iterate_mapinfo(&map_info, callback, user_data); +} + +int mp3dec_ex_open_w(mp3dec_ex_t *dec, const wchar_t *file_name, int flags) +{ + int ret; + if ((ret = mp3dec_open_file_w(file_name, &dec->file))) + return ret; + return mp3dec_ex_open_mapinfo(dec, flags); +} +#endif +#else /* MINIMP3_NO_STDIO */ +void mp3dec_ex_close(mp3dec_ex_t *dec) +{ +#ifdef MINIMP3_HAVE_RING + if (dec->io) + mp3dec_close_ring(&dec->file); +#else + if (dec->io && dec->file.buffer) + free((void*)dec->file.buffer); +#endif + if (dec->index.frames) + free(dec->index.frames); + memset(dec, 0, sizeof(*dec)); +} +#endif + +#endif /* MINIMP3_IMPLEMENTATION && !_MINIMP3_EX_IMPLEMENTATION_GUARD */ diff --git a/resources/lib/minimp3/minimp3_wrapper.c b/resources/lib/minimp3/minimp3_wrapper.c new file mode 100644 index 0000000..5c895c2 --- /dev/null +++ b/resources/lib/minimp3/minimp3_wrapper.c @@ -0,0 +1,83 @@ +#define MINIMP3_IMPLEMENTATION +#define MINIMP3_NO_STDIO +#include "minimp3.h" +#include +#include + +typedef struct { + short *pcm; + int samples; // total samples (per channel) + int channels; + int sample_rate; + int error; +} mp3_result_t; + +/** + * Decode an MP3 file from a memory buffer into interleaved 16-bit PCM. + * Caller must free result->pcm via mp3_free(). + */ +void mp3_decode_buffer(const unsigned char *data, int data_size, mp3_result_t *result) { + mp3dec_t dec; + mp3dec_frame_info_t info; + short pcm_frame[MINIMP3_MAX_SAMPLES_PER_FRAME]; + + mp3dec_init(&dec); + + result->pcm = NULL; + result->samples = 0; + result->channels = 0; + result->sample_rate = 0; + result->error = 0; + + int total_samples = 0; + int capacity = 0; + short *output = NULL; + int offset = 0; + + while (offset < data_size) { + int samples = mp3dec_decode_frame(&dec, data + offset, data_size - offset, pcm_frame, &info); + + if (info.frame_bytes == 0) { + break; // no more frames + } + + offset += info.frame_bytes; + + if (samples <= 0) { + continue; + } + + if (result->channels == 0) { + result->channels = info.channels; + result->sample_rate = info.hz; + } + + int new_samples = samples * info.channels; + if (total_samples + new_samples > capacity) { + capacity = (capacity == 0) ? 65536 : capacity * 2; + while (capacity < total_samples + new_samples) { + capacity *= 2; + } + short *tmp = (short *)realloc(output, capacity * sizeof(short)); + if (!tmp) { + free(output); + result->error = 1; + return; + } + output = tmp; + } + + memcpy(output + total_samples, pcm_frame, new_samples * sizeof(short)); + total_samples += new_samples; + } + + result->pcm = output; + result->samples = result->channels > 0 ? total_samples / result->channels : 0; +} + +/** + * Free PCM data allocated by mp3_decode_buffer. + */ +void mp3_free(void *ptr) { + free(ptr); +} \ No newline at end of file diff --git a/resources/lib/minimp3/windows-x86_64/minimp3.dll b/resources/lib/minimp3/windows-x86_64/minimp3.dll new file mode 100755 index 0000000..c7524a4 Binary files /dev/null and b/resources/lib/minimp3/windows-x86_64/minimp3.dll differ diff --git a/resources/lib/minimp3/windows-x86_64/minimp3.pdb b/resources/lib/minimp3/windows-x86_64/minimp3.pdb new file mode 100644 index 0000000..02ca645 Binary files /dev/null and b/resources/lib/minimp3/windows-x86_64/minimp3.pdb differ diff --git a/resources/lib/minimp3/windows-x86_64/minimp3_wrapper.lib b/resources/lib/minimp3/windows-x86_64/minimp3_wrapper.lib new file mode 100644 index 0000000..6860a97 Binary files /dev/null and b/resources/lib/minimp3/windows-x86_64/minimp3_wrapper.lib differ diff --git a/resources/shader/include/visu/gbuffer_layout_pbr.glsl b/resources/shader/include/visu/gbuffer_layout_pbr.glsl new file mode 100644 index 0000000..367c35d --- /dev/null +++ b/resources/shader/include/visu/gbuffer_layout_pbr.glsl @@ -0,0 +1,7 @@ +// PBR GBuffer layout — extends the standard layout with metallic/roughness + emissive +layout (location = 0) out vec3 gbuffer_position; +layout (location = 1) out vec3 gbuffer_vposition; +layout (location = 2) out vec3 gbuffer_normal; +layout (location = 3) out vec4 gbuffer_albedo; // RGB = albedo, A = alpha +layout (location = 4) out vec2 gbuffer_metallic_roughness; // R = metallic, G = roughness +layout (location = 5) out vec3 gbuffer_emissive; diff --git a/resources/shader/visu/particle.frag.glsl b/resources/shader/visu/particle.frag.glsl new file mode 100644 index 0000000..198a38f --- /dev/null +++ b/resources/shader/visu/particle.frag.glsl @@ -0,0 +1,27 @@ +#version 330 core + +in vec4 v_color; +in vec2 v_uv; + +out vec4 fragment_color; + +uniform sampler2D u_texture; +uniform int u_has_texture; + +void main() +{ + vec4 color = v_color; + + if (u_has_texture == 1) { + color *= texture(u_texture, v_uv); + } else { + // soft circular falloff for untextured particles + float dist = length(v_uv - vec2(0.5)); + float alpha = 1.0 - smoothstep(0.3, 0.5, dist); + color.a *= alpha; + } + + if (color.a < 0.01) discard; + + fragment_color = color; +} diff --git a/resources/shader/visu/particle.vert.glsl b/resources/shader/visu/particle.vert.glsl new file mode 100644 index 0000000..b68c4cc --- /dev/null +++ b/resources/shader/visu/particle.vert.glsl @@ -0,0 +1,31 @@ +#version 330 core + +// quad vertices (shared geometry) +layout (location = 0) in vec2 a_quad_pos; // [-0.5, 0.5] + +// per-instance data +layout (location = 1) in vec3 i_position; // world position +layout (location = 2) in vec4 i_color; // RGBA +layout (location = 3) in float i_size; // billboard size + +uniform mat4 u_view; +uniform mat4 u_projection; + +out vec4 v_color; +out vec2 v_uv; + +void main() +{ + v_color = i_color; + v_uv = a_quad_pos + 0.5; // [0, 1] + + // extract camera right and up from view matrix for billboarding + vec3 cam_right = vec3(u_view[0][0], u_view[1][0], u_view[2][0]); + vec3 cam_up = vec3(u_view[0][1], u_view[1][1], u_view[2][1]); + + vec3 world_pos = i_position + + cam_right * a_quad_pos.x * i_size + + cam_up * a_quad_pos.y * i_size; + + gl_Position = u_projection * u_view * vec4(world_pos, 1.0); +} diff --git a/resources/shader/visu/pbr/geometry.frag.glsl b/resources/shader/visu/pbr/geometry.frag.glsl new file mode 100644 index 0000000..112711e --- /dev/null +++ b/resources/shader/visu/pbr/geometry.frag.glsl @@ -0,0 +1,67 @@ +#version 330 core + +#include "visu/gbuffer_layout_pbr.glsl" + +in vec3 v_position; +in vec4 v_vposition; +in vec3 v_normal; +in vec2 v_uv; +in mat3 v_tbn; + +// material uniforms +uniform vec4 u_albedo_color; +uniform float u_metallic; +uniform float u_roughness; +uniform vec3 u_emissive_color; + +// texture flags (bitmask) +uniform int u_texture_flags; + +// textures +uniform sampler2D u_albedo_map; // flag bit 0 +uniform sampler2D u_normal_map; // flag bit 1 +uniform sampler2D u_metallic_roughness_map; // flag bit 2 +uniform sampler2D u_ao_map; // flag bit 3 +uniform sampler2D u_emissive_map; // flag bit 4 + +void main() +{ + // albedo + vec4 albedo = u_albedo_color; + if ((u_texture_flags & 1) != 0) { + albedo *= texture(u_albedo_map, v_uv); + } + + // alpha test for MASK mode + // (alphaCutoff is baked into the check on CPU side by not rendering if below) + + // normal + vec3 N = normalize(v_normal); + if ((u_texture_flags & 2) != 0) { + vec3 tangent_normal = texture(u_normal_map, v_uv).rgb * 2.0 - 1.0; + N = normalize(v_tbn * tangent_normal); + } + + // metallic + roughness + float metallic = u_metallic; + float roughness = u_roughness; + if ((u_texture_flags & 4) != 0) { + vec4 mr = texture(u_metallic_roughness_map, v_uv); + metallic *= mr.b; // glTF convention: blue channel = metallic + roughness *= mr.g; // glTF convention: green channel = roughness + } + + // emissive + vec3 emissive = u_emissive_color; + if ((u_texture_flags & 16) != 0) { + emissive *= texture(u_emissive_map, v_uv).rgb; + } + + // write to GBuffer + gbuffer_position = v_position; + gbuffer_vposition = v_vposition.xyz; + gbuffer_normal = N; + gbuffer_albedo = albedo; + gbuffer_metallic_roughness = vec2(metallic, roughness); + gbuffer_emissive = emissive; +} diff --git a/resources/shader/visu/pbr/geometry.vert.glsl b/resources/shader/visu/pbr/geometry.vert.glsl new file mode 100644 index 0000000..2f14bbb --- /dev/null +++ b/resources/shader/visu/pbr/geometry.vert.glsl @@ -0,0 +1,36 @@ +#version 330 core + +layout (location = 0) in vec3 a_position; +layout (location = 1) in vec3 a_normal; +layout (location = 2) in vec2 a_uv; +layout (location = 3) in vec4 a_tangent; // xyz = tangent, w = handedness + +out vec3 v_position; +out vec4 v_vposition; +out vec3 v_normal; +out vec2 v_uv; +out mat3 v_tbn; + +uniform mat4 projection; +uniform mat4 view; +uniform mat4 model; + +void main() +{ + vec4 world_pos = model * vec4(a_position, 1.0); + v_position = world_pos.xyz; + v_vposition = view * world_pos; + + mat3 normal_matrix = mat3(model); + vec3 N = normalize(normal_matrix * a_normal); + vec3 T = normalize(normal_matrix * a_tangent.xyz); + // re-orthogonalize T with respect to N + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T) * a_tangent.w; + v_tbn = mat3(T, B, N); + + v_normal = N; + v_uv = a_uv; + + gl_Position = projection * view * world_pos; +} diff --git a/resources/shader/visu/pbr/lightpass.frag.glsl b/resources/shader/visu/pbr/lightpass.frag.glsl new file mode 100644 index 0000000..904fad9 --- /dev/null +++ b/resources/shader/visu/pbr/lightpass.frag.glsl @@ -0,0 +1,314 @@ +#version 330 core + +#define PBR_DISTRIBUTION_GGX +#define PBR_GEOMETRY_COOK_TORRANCE +#define MAX_POINT_LIGHTS 32 +#define MAX_SPOT_LIGHTS 16 +#define MAX_SHADOW_CASCADES 4 +#define MAX_SHADOW_POINT_LIGHTS 4 + +in vec2 v_uv; +out vec4 fragment_color; + +// GBuffer textures +uniform sampler2D gbuffer_position; +uniform sampler2D gbuffer_normal; +uniform sampler2D gbuffer_depth; +uniform sampler2D gbuffer_albedo; +uniform sampler2D gbuffer_ao; +uniform sampler2D gbuffer_metallic_roughness; +uniform sampler2D gbuffer_emissive; + +// shadow maps (one per cascade) +uniform sampler2D shadow_map_0; +uniform sampler2D shadow_map_1; +uniform sampler2D shadow_map_2; +uniform sampler2D shadow_map_3; +uniform mat4 light_space_matrices[MAX_SHADOW_CASCADES]; +uniform float cascade_splits[MAX_SHADOW_CASCADES]; +uniform int num_shadow_cascades; +uniform mat4 u_view_matrix; + +// camera +uniform vec3 camera_position; +uniform vec2 camera_resolution; + +// directional light (sun) +uniform vec3 sun_direction; +uniform vec3 sun_color; +uniform float sun_intensity; + +// point lights +struct PointLight { + vec3 position; + vec3 color; + float intensity; + float range; + float constant; + float linear; + float quadratic; +}; +uniform PointLight point_lights[MAX_POINT_LIGHTS]; +uniform int num_point_lights; + +// spot lights +struct SpotLight { + vec3 position; + vec3 direction; + vec3 color; + float intensity; + float range; + float constant; + float linear; + float quadratic; + float innerCutoff; // cos(innerAngle) + float outerCutoff; // cos(outerAngle) +}; +uniform SpotLight spot_lights[MAX_SPOT_LIGHTS]; +uniform int num_spot_lights; + +// point light cubemap shadows +uniform samplerCube point_shadow_map_0; +uniform samplerCube point_shadow_map_1; +uniform samplerCube point_shadow_map_2; +uniform samplerCube point_shadow_map_3; +uniform int num_point_shadow_lights; +uniform vec3 point_shadow_positions[MAX_SHADOW_POINT_LIGHTS]; +uniform float point_shadow_far_planes[MAX_SHADOW_POINT_LIGHTS]; +// maps shadow light index to point_lights[] index (-1 = no shadow) +uniform int point_shadow_light_indices[MAX_SHADOW_POINT_LIGHTS]; + +const float gamma = 2.2; +const float PI = 3.14159265359; +const float exposure = 1.5; + +vec3 fresnel(vec3 F0, float cosTheta) +{ + return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); +} + +float distribution_GGX(float NdotH, float roughness) +{ + float a = roughness * roughness; + float a2 = a * a; + float d = NdotH * NdotH * (a2 - 1.0) + 1.0; + return a2 / (PI * d * d); +} + +float geometry_cook_torrance(float NdotL, float NdotV, float NdotH, float VdotH) +{ + float G1 = (2.0 * NdotH * NdotV) / VdotH; + float G2 = (2.0 * NdotH * NdotL) / VdotH; + return min(1.0, min(G1, G2)); +} + +vec3 pbr_specular(vec3 N, vec3 V, vec3 H, vec3 L, vec3 F0, float roughness) +{ + float NdotH = max(0.0, dot(N, H)); + float NdotV = max(1e-7, dot(N, V)); + float NdotL = max(1e-7, dot(N, L)); + float VdotH = max(0.0, dot(V, H)); + + float D = distribution_GGX(NdotH, roughness); + float G = geometry_cook_torrance(NdotL, NdotV, NdotH, VdotH); + vec3 F = fresnel(F0, VdotH); + + return (D * F * G) / (4.0 * NdotL * NdotV); +} + +vec3 calculate_light(vec3 L, vec3 radiance, vec3 N, vec3 V, vec3 albedo, float metallic, float roughness) +{ + vec3 H = normalize(L + V); + vec3 F0 = mix(vec3(0.04), albedo, metallic); + vec3 F = fresnel(F0, max(0.0, dot(H, V))); + vec3 specular = pbr_specular(N, V, H, L, F0, roughness); + + float NdotL = max(dot(N, L), 0.0); + vec3 kD = (1.0 - F) * (1.0 - metallic); + + return (kD * albedo / PI + specular) * radiance * NdotL; +} + +// PCF shadow sampling (3x3 kernel) +float sampleShadowMap(sampler2D shadowMap, vec4 lightSpacePos, float bias) +{ + // perspective divide + vec3 projCoords = lightSpacePos.xyz / lightSpacePos.w; + projCoords = projCoords * 0.5 + 0.5; // to [0,1] + + // outside shadow map = fully lit + if (projCoords.z > 1.0) return 1.0; + if (projCoords.x < 0.0 || projCoords.x > 1.0 || projCoords.y < 0.0 || projCoords.y > 1.0) return 1.0; + + float currentDepth = projCoords.z; + + // PCF 3x3 + float shadow = 0.0; + vec2 texelSize = 1.0 / textureSize(shadowMap, 0); + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r; + shadow += currentDepth - bias > pcfDepth ? 0.0 : 1.0; + } + } + return shadow / 9.0; +} + +float computeShadow(vec3 worldPos, vec3 N, vec3 L) +{ + if (num_shadow_cascades == 0) return 1.0; + + // determine which cascade this fragment belongs to by view-space depth + vec4 viewPos = u_view_matrix * vec4(worldPos, 1.0); + float depth = abs(viewPos.z); + + int cascadeIndex = num_shadow_cascades - 1; + for (int i = 0; i < num_shadow_cascades; i++) { + if (depth < cascade_splits[i]) { + cascadeIndex = i; + break; + } + } + + // transform to light space + vec4 lightSpacePos = light_space_matrices[cascadeIndex] * vec4(worldPos, 1.0); + + // slope-scaled bias (larger for steeper angles) + float bias = max(0.003 * (1.0 - dot(N, L)), 0.001); + // increase bias for farther cascades + bias *= float(cascadeIndex + 1) * 0.5; + + // select the correct shadow map sampler + float shadow; + if (cascadeIndex == 0) shadow = sampleShadowMap(shadow_map_0, lightSpacePos, bias); + else if (cascadeIndex == 1) shadow = sampleShadowMap(shadow_map_1, lightSpacePos, bias); + else if (cascadeIndex == 2) shadow = sampleShadowMap(shadow_map_2, lightSpacePos, bias); + else shadow = sampleShadowMap(shadow_map_3, lightSpacePos, bias); + + return shadow; +} + +float samplePointShadow(samplerCube shadowMap, vec3 fragToLight, float farPlane) +{ + float closestDepth = texture(shadowMap, fragToLight).r; + closestDepth *= farPlane; + float currentDepth = length(fragToLight); + + // bias based on distance to prevent shadow acne + float bias = max(0.05 * (1.0 - currentDepth / farPlane), 0.005); + + return currentDepth - bias > closestDepth ? 0.0 : 1.0; +} + +float computePointShadow(int pointLightIndex, vec3 fragPos) +{ + // check each shadow-casting light to see if it matches this point light index + for (int s = 0; s < num_point_shadow_lights; s++) { + if (point_shadow_light_indices[s] != pointLightIndex) continue; + + vec3 fragToLight = fragPos - point_shadow_positions[s]; + float farPlane = point_shadow_far_planes[s]; + + if (s == 0) return samplePointShadow(point_shadow_map_0, fragToLight, farPlane); + else if (s == 1) return samplePointShadow(point_shadow_map_1, fragToLight, farPlane); + else if (s == 2) return samplePointShadow(point_shadow_map_2, fragToLight, farPlane); + else return samplePointShadow(point_shadow_map_3, fragToLight, farPlane); + } + return 1.0; // no shadow map for this light +} + +vec3 tone_mapping_ACESFilm(vec3 x) +{ + x *= exposure; + float a = 2.51; + float b = 0.03; + float c = 2.43; + float d = 0.59; + float e = 0.14; + return clamp((x*(a*x+b))/(x*(c*x+d)+e), 0.0, 1.0); +} + +void main() +{ + vec3 pos = texture(gbuffer_position, v_uv).rgb; + vec3 normal = texture(gbuffer_normal, v_uv).rgb; + vec3 albedo = texture(gbuffer_albedo, v_uv).rgb; + float ao = texture(gbuffer_ao, v_uv).r; + vec2 mr = texture(gbuffer_metallic_roughness, v_uv).rg; + vec3 emissive = texture(gbuffer_emissive, v_uv).rgb; + + float metallic = mr.r; + float roughness = mr.g; + + vec3 N = normalize(normal); + vec3 V = normalize(camera_position - pos); + vec3 L_sun = normalize(sun_direction); + + // shadow for directional light + float shadow = computeShadow(pos, N, L_sun); + + // directional light (with shadow) + vec3 Lo = calculate_light(L_sun, sun_color * sun_intensity, N, V, albedo, metallic, roughness) * shadow; + + // point lights (with cubemap shadows) + for (int i = 0; i < num_point_lights; i++) { + vec3 light_vec = point_lights[i].position - pos; + float distance = length(light_vec); + + if (distance > point_lights[i].range) continue; + + vec3 L = light_vec / distance; + float attenuation = 1.0 / ( + point_lights[i].constant + + point_lights[i].linear * distance + + point_lights[i].quadratic * distance * distance + ); + float smooth_falloff = 1.0 - smoothstep(point_lights[i].range * 0.75, point_lights[i].range, distance); + attenuation *= smooth_falloff; + + float point_shadow = computePointShadow(i, pos); + + vec3 radiance = point_lights[i].color * point_lights[i].intensity * attenuation; + Lo += calculate_light(L, radiance, N, V, albedo, metallic, roughness) * point_shadow; + } + + // spot lights + for (int i = 0; i < num_spot_lights; i++) { + vec3 light_vec = spot_lights[i].position - pos; + float distance = length(light_vec); + + if (distance > spot_lights[i].range) continue; + + vec3 L = light_vec / distance; + + // cone attenuation + float theta = dot(L, normalize(-spot_lights[i].direction)); + float epsilon = spot_lights[i].innerCutoff - spot_lights[i].outerCutoff; + float spot_intensity = clamp((theta - spot_lights[i].outerCutoff) / epsilon, 0.0, 1.0); + + if (spot_intensity <= 0.0) continue; + + float attenuation = 1.0 / ( + spot_lights[i].constant + + spot_lights[i].linear * distance + + spot_lights[i].quadratic * distance * distance + ); + float smooth_falloff = 1.0 - smoothstep(spot_lights[i].range * 0.75, spot_lights[i].range, distance); + attenuation *= smooth_falloff * spot_intensity; + + vec3 radiance = spot_lights[i].color * spot_lights[i].intensity * attenuation; + Lo += calculate_light(L, radiance, N, V, albedo, metallic, roughness); + } + + // ambient + vec3 ambient = vec3(0.03) * albedo * ao; + Lo *= ao; + + vec3 color = ambient + Lo + emissive; + + // HDR tone mapping + gamma + color = tone_mapping_ACESFilm(color); + color = pow(color, vec3(1.0 / gamma)); + + fragment_color = vec4(color, 1.0); +} diff --git a/resources/shader/visu/pbr/lightpass.vert.glsl b/resources/shader/visu/pbr/lightpass.vert.glsl new file mode 100644 index 0000000..d546ac4 --- /dev/null +++ b/resources/shader/visu/pbr/lightpass.vert.glsl @@ -0,0 +1,3 @@ +#version 330 core + +#include "visu/fullscreen_quad.glsl" diff --git a/resources/shader/visu/pbr/point_shadow_depth.frag.glsl b/resources/shader/visu/pbr/point_shadow_depth.frag.glsl new file mode 100644 index 0000000..b890421 --- /dev/null +++ b/resources/shader/visu/pbr/point_shadow_depth.frag.glsl @@ -0,0 +1,13 @@ +#version 330 core + +in vec3 v_world_pos; + +uniform vec3 u_light_pos; +uniform float u_far_plane; + +void main() +{ + // write linear distance normalized by far plane + float dist = length(v_world_pos - u_light_pos); + gl_FragDepth = dist / u_far_plane; +} diff --git a/resources/shader/visu/pbr/point_shadow_depth.vert.glsl b/resources/shader/visu/pbr/point_shadow_depth.vert.glsl new file mode 100644 index 0000000..3792043 --- /dev/null +++ b/resources/shader/visu/pbr/point_shadow_depth.vert.glsl @@ -0,0 +1,15 @@ +#version 330 core + +layout (location = 0) in vec3 a_position; + +uniform mat4 model; +uniform mat4 u_light_space; + +out vec3 v_world_pos; + +void main() +{ + vec4 worldPos = model * vec4(a_position, 1.0); + v_world_pos = worldPos.xyz; + gl_Position = u_light_space * worldPos; +} diff --git a/resources/shader/visu/pbr/shadow_depth.frag.glsl b/resources/shader/visu/pbr/shadow_depth.frag.glsl new file mode 100644 index 0000000..e333e87 --- /dev/null +++ b/resources/shader/visu/pbr/shadow_depth.frag.glsl @@ -0,0 +1,7 @@ +#version 330 core + +// depth-only pass — the fragment shader can be empty +// (depth is written automatically by the rasterizer) +void main() +{ +} diff --git a/resources/shader/visu/pbr/shadow_depth.vert.glsl b/resources/shader/visu/pbr/shadow_depth.vert.glsl new file mode 100644 index 0000000..5a87bcf --- /dev/null +++ b/resources/shader/visu/pbr/shadow_depth.vert.glsl @@ -0,0 +1,11 @@ +#version 330 core + +layout (location = 0) in vec3 a_position; + +uniform mat4 u_light_space; +uniform mat4 model; + +void main() +{ + gl_Position = u_light_space * model * vec4(a_position, 1.0); +} diff --git a/resources/shader/visu/pbr/skinned_geometry.frag.glsl b/resources/shader/visu/pbr/skinned_geometry.frag.glsl new file mode 100644 index 0000000..112711e --- /dev/null +++ b/resources/shader/visu/pbr/skinned_geometry.frag.glsl @@ -0,0 +1,67 @@ +#version 330 core + +#include "visu/gbuffer_layout_pbr.glsl" + +in vec3 v_position; +in vec4 v_vposition; +in vec3 v_normal; +in vec2 v_uv; +in mat3 v_tbn; + +// material uniforms +uniform vec4 u_albedo_color; +uniform float u_metallic; +uniform float u_roughness; +uniform vec3 u_emissive_color; + +// texture flags (bitmask) +uniform int u_texture_flags; + +// textures +uniform sampler2D u_albedo_map; // flag bit 0 +uniform sampler2D u_normal_map; // flag bit 1 +uniform sampler2D u_metallic_roughness_map; // flag bit 2 +uniform sampler2D u_ao_map; // flag bit 3 +uniform sampler2D u_emissive_map; // flag bit 4 + +void main() +{ + // albedo + vec4 albedo = u_albedo_color; + if ((u_texture_flags & 1) != 0) { + albedo *= texture(u_albedo_map, v_uv); + } + + // alpha test for MASK mode + // (alphaCutoff is baked into the check on CPU side by not rendering if below) + + // normal + vec3 N = normalize(v_normal); + if ((u_texture_flags & 2) != 0) { + vec3 tangent_normal = texture(u_normal_map, v_uv).rgb * 2.0 - 1.0; + N = normalize(v_tbn * tangent_normal); + } + + // metallic + roughness + float metallic = u_metallic; + float roughness = u_roughness; + if ((u_texture_flags & 4) != 0) { + vec4 mr = texture(u_metallic_roughness_map, v_uv); + metallic *= mr.b; // glTF convention: blue channel = metallic + roughness *= mr.g; // glTF convention: green channel = roughness + } + + // emissive + vec3 emissive = u_emissive_color; + if ((u_texture_flags & 16) != 0) { + emissive *= texture(u_emissive_map, v_uv).rgb; + } + + // write to GBuffer + gbuffer_position = v_position; + gbuffer_vposition = v_vposition.xyz; + gbuffer_normal = N; + gbuffer_albedo = albedo; + gbuffer_metallic_roughness = vec2(metallic, roughness); + gbuffer_emissive = emissive; +} diff --git a/resources/shader/visu/pbr/skinned_geometry.vert.glsl b/resources/shader/visu/pbr/skinned_geometry.vert.glsl new file mode 100644 index 0000000..66cbf8a --- /dev/null +++ b/resources/shader/visu/pbr/skinned_geometry.vert.glsl @@ -0,0 +1,59 @@ +#version 330 core + +#define MAX_BONES 128 + +layout (location = 0) in vec3 a_position; +layout (location = 1) in vec3 a_normal; +layout (location = 2) in vec2 a_uv; +layout (location = 3) in vec4 a_tangent; // xyz = tangent, w = handedness +layout (location = 4) in vec4 a_bone_indices; // stored as float, cast to int +layout (location = 5) in vec4 a_bone_weights; + +out vec3 v_position; +out vec4 v_vposition; +out vec3 v_normal; +out vec2 v_uv; +out mat3 v_tbn; + +uniform mat4 projection; +uniform mat4 view; +uniform mat4 model; +uniform mat4 u_bone_matrices[MAX_BONES]; +uniform int u_skinned; // 1 = apply skinning, 0 = static mesh + +void main() +{ + vec4 local_pos = vec4(a_position, 1.0); + vec3 local_normal = a_normal; + vec3 local_tangent = a_tangent.xyz; + + if (u_skinned == 1) { + ivec4 bone_ids = ivec4(a_bone_indices); + vec4 w = a_bone_weights; + + mat4 bone_transform = u_bone_matrices[bone_ids.x] * w.x + + u_bone_matrices[bone_ids.y] * w.y + + u_bone_matrices[bone_ids.z] * w.z + + u_bone_matrices[bone_ids.w] * w.w; + + local_pos = bone_transform * local_pos; + local_normal = mat3(bone_transform) * local_normal; + local_tangent = mat3(bone_transform) * local_tangent; + } + + vec4 world_pos = model * local_pos; + v_position = world_pos.xyz; + v_vposition = view * world_pos; + + mat3 normal_matrix = mat3(model); + vec3 N = normalize(normal_matrix * local_normal); + vec3 T = normalize(normal_matrix * local_tangent); + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T) * a_tangent.w; + v_tbn = mat3(T, B, N); + + v_normal = N; + v_uv = a_uv; + + gl_Position = projection * view * world_pos; +} diff --git a/resources/shader/visu/pbr/terrain.frag.glsl b/resources/shader/visu/pbr/terrain.frag.glsl new file mode 100644 index 0000000..c83f29b --- /dev/null +++ b/resources/shader/visu/pbr/terrain.frag.glsl @@ -0,0 +1,72 @@ +#version 330 core + +#include "visu/gbuffer_layout_pbr.glsl" + +in vec3 v_position; +in vec4 v_vposition; +in vec3 v_normal; +in vec2 v_uv; +in mat3 v_tbn; + +// blend map: R = layer 0, G = layer 1, B = layer 2, A = layer 3 +uniform sampler2D u_blend_map; + +// terrain layer textures +uniform sampler2D u_layer_0; +uniform sampler2D u_layer_1; +uniform sampler2D u_layer_2; +uniform sampler2D u_layer_3; + +// tiling for each layer +uniform vec4 u_layer_tiling; // x,y,z,w = tiling for layers 0-3 + +// which layers are active (bitmask) +uniform int u_active_layers; + +// material properties +uniform float u_metallic; +uniform float u_roughness; + +void main() +{ + vec4 blend = texture(u_blend_map, v_uv); + + // normalize blend weights (in case they don't sum to 1) + float total = blend.r + blend.g + blend.b + blend.a; + if (total > 0.001) { + blend /= total; + } else { + blend = vec4(1.0, 0.0, 0.0, 0.0); // fallback to layer 0 + } + + // sample each layer at tiled UVs and blend + vec4 albedo = vec4(0.0); + + if ((u_active_layers & 1) != 0) { + albedo += texture(u_layer_0, v_uv * u_layer_tiling.x) * blend.r; + } + if ((u_active_layers & 2) != 0) { + albedo += texture(u_layer_1, v_uv * u_layer_tiling.y) * blend.g; + } + if ((u_active_layers & 4) != 0) { + albedo += texture(u_layer_2, v_uv * u_layer_tiling.z) * blend.b; + } + if ((u_active_layers & 8) != 0) { + albedo += texture(u_layer_3, v_uv * u_layer_tiling.w) * blend.a; + } + + // fallback: if no layers active, use blend map as color + if (u_active_layers == 0) { + albedo = vec4(0.3, 0.5, 0.2, 1.0); // default green + } + + vec3 N = normalize(v_normal); + + // write to GBuffer + gbuffer_position = v_position; + gbuffer_vposition = v_vposition.xyz; + gbuffer_normal = N; + gbuffer_albedo = albedo; + gbuffer_metallic_roughness = vec2(u_metallic, u_roughness); + gbuffer_emissive = vec3(0.0); +} diff --git a/resources/shader/visu/pbr/terrain.vert.glsl b/resources/shader/visu/pbr/terrain.vert.glsl new file mode 100644 index 0000000..e907cdc --- /dev/null +++ b/resources/shader/visu/pbr/terrain.vert.glsl @@ -0,0 +1,35 @@ +#version 330 core + +layout (location = 0) in vec3 a_position; +layout (location = 1) in vec3 a_normal; +layout (location = 2) in vec2 a_uv; +layout (location = 3) in vec4 a_tangent; + +out vec3 v_position; +out vec4 v_vposition; +out vec3 v_normal; +out vec2 v_uv; +out mat3 v_tbn; + +uniform mat4 projection; +uniform mat4 view; +uniform mat4 model; + +void main() +{ + vec4 world_pos = model * vec4(a_position, 1.0); + v_position = world_pos.xyz; + v_vposition = view * world_pos; + + mat3 normal_matrix = mat3(model); + vec3 N = normalize(normal_matrix * a_normal); + vec3 T = normalize(normal_matrix * a_tangent.xyz); + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T) * a_tangent.w; + v_tbn = mat3(T, B, N); + + v_normal = N; + v_uv = a_uv; + + gl_Position = projection * view * world_pos; +} diff --git a/resources/shader/visu/postprocess/bloom_composite.frag.glsl b/resources/shader/visu/postprocess/bloom_composite.frag.glsl new file mode 100644 index 0000000..726e34e --- /dev/null +++ b/resources/shader/visu/postprocess/bloom_composite.frag.glsl @@ -0,0 +1,16 @@ +#version 330 core + +out vec4 FragColor; +in vec2 TexCoords; + +uniform sampler2D u_scene; +uniform sampler2D u_bloom; +uniform float u_bloom_intensity; + +void main() +{ + vec3 scene = texture(u_scene, TexCoords).rgb; + vec3 bloom = texture(u_bloom, TexCoords).rgb; + + FragColor = vec4(scene + bloom * u_bloom_intensity, 1.0); +} diff --git a/resources/shader/visu/postprocess/bloom_composite.vert.glsl b/resources/shader/visu/postprocess/bloom_composite.vert.glsl new file mode 100644 index 0000000..840bb1d --- /dev/null +++ b/resources/shader/visu/postprocess/bloom_composite.vert.glsl @@ -0,0 +1,12 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +out vec2 TexCoords; + +void main() +{ + gl_Position = vec4(aPos, 1.0); + TexCoords = aTexCoord; +} diff --git a/resources/shader/visu/postprocess/bloom_extract.frag.glsl b/resources/shader/visu/postprocess/bloom_extract.frag.glsl new file mode 100644 index 0000000..9fd5b9b --- /dev/null +++ b/resources/shader/visu/postprocess/bloom_extract.frag.glsl @@ -0,0 +1,25 @@ +#version 330 core + +out vec4 FragColor; +in vec2 TexCoords; + +uniform sampler2D u_texture; +uniform float u_threshold; +uniform float u_soft_threshold; + +void main() +{ + vec4 color = texture(u_texture, TexCoords); + float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); + + // soft knee threshold + float knee = u_threshold * u_soft_threshold; + float soft = brightness - u_threshold + knee; + soft = clamp(soft, 0.0, 2.0 * knee); + soft = soft * soft / (4.0 * knee + 0.00001); + + float contribution = max(soft, brightness - u_threshold); + contribution /= max(brightness, 0.00001); + + FragColor = color * contribution; +} diff --git a/resources/shader/visu/postprocess/bloom_extract.vert.glsl b/resources/shader/visu/postprocess/bloom_extract.vert.glsl new file mode 100644 index 0000000..840bb1d --- /dev/null +++ b/resources/shader/visu/postprocess/bloom_extract.vert.glsl @@ -0,0 +1,12 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +out vec2 TexCoords; + +void main() +{ + gl_Position = vec4(aPos, 1.0); + TexCoords = aTexCoord; +} diff --git a/resources/shader/visu/postprocess/blur_gaussian.frag.glsl b/resources/shader/visu/postprocess/blur_gaussian.frag.glsl new file mode 100644 index 0000000..7b126bf --- /dev/null +++ b/resources/shader/visu/postprocess/blur_gaussian.frag.glsl @@ -0,0 +1,24 @@ +#version 330 core + +out vec4 FragColor; +in vec2 TexCoords; + +uniform sampler2D u_texture; +uniform vec2 u_direction; // (1/w, 0) or (0, 1/h) + +// 9-tap Gaussian weights +const float weights[5] = float[](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); + +void main() +{ + vec3 result = texture(u_texture, TexCoords).rgb * weights[0]; + + for (int i = 1; i < 5; ++i) + { + vec2 offset = u_direction * float(i); + result += texture(u_texture, TexCoords + offset).rgb * weights[i]; + result += texture(u_texture, TexCoords - offset).rgb * weights[i]; + } + + FragColor = vec4(result, 1.0); +} diff --git a/resources/shader/visu/postprocess/blur_gaussian.vert.glsl b/resources/shader/visu/postprocess/blur_gaussian.vert.glsl new file mode 100644 index 0000000..840bb1d --- /dev/null +++ b/resources/shader/visu/postprocess/blur_gaussian.vert.glsl @@ -0,0 +1,12 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +out vec2 TexCoords; + +void main() +{ + gl_Position = vec4(aPos, 1.0); + TexCoords = aTexCoord; +} diff --git a/resources/shader/visu/postprocess/dof.frag.glsl b/resources/shader/visu/postprocess/dof.frag.glsl new file mode 100644 index 0000000..598d1e2 --- /dev/null +++ b/resources/shader/visu/postprocess/dof.frag.glsl @@ -0,0 +1,35 @@ +#version 330 core + +out vec4 FragColor; +in vec2 TexCoords; + +uniform sampler2D u_scene; +uniform sampler2D u_depth; +uniform sampler2D u_blurred; + +uniform float u_focus_distance; +uniform float u_focus_range; +uniform float u_near_plane; +uniform float u_far_plane; +uniform float u_max_blur; + +float linearizeDepth(float d) +{ + float z_ndc = d * 2.0 - 1.0; + return (2.0 * u_near_plane * u_far_plane) / (u_far_plane + u_near_plane - z_ndc * (u_far_plane - u_near_plane)); +} + +void main() +{ + float depth = texture(u_depth, TexCoords).r; + float linearDepth = linearizeDepth(depth); + + // circle of confusion based on distance from focus plane + float coc = abs(linearDepth - u_focus_distance) / u_focus_range; + coc = clamp(coc, 0.0, u_max_blur); + + vec3 sharp = texture(u_scene, TexCoords).rgb; + vec3 blurred = texture(u_blurred, TexCoords).rgb; + + FragColor = vec4(mix(sharp, blurred, coc), 1.0); +} diff --git a/resources/shader/visu/postprocess/dof.vert.glsl b/resources/shader/visu/postprocess/dof.vert.glsl new file mode 100644 index 0000000..840bb1d --- /dev/null +++ b/resources/shader/visu/postprocess/dof.vert.glsl @@ -0,0 +1,12 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +out vec2 TexCoords; + +void main() +{ + gl_Position = vec4(aPos, 1.0); + TexCoords = aTexCoord; +} diff --git a/resources/shader/visu/postprocess/motion_blur.frag.glsl b/resources/shader/visu/postprocess/motion_blur.frag.glsl new file mode 100644 index 0000000..3b04ba7 --- /dev/null +++ b/resources/shader/visu/postprocess/motion_blur.frag.glsl @@ -0,0 +1,52 @@ +#version 330 core + +out vec4 FragColor; +in vec2 TexCoords; + +uniform sampler2D u_scene; +uniform sampler2D u_depth; + +uniform mat4 u_current_vp_inverse; +uniform mat4 u_previous_vp; +uniform float u_blur_strength; +uniform int u_num_samples; + +void main() +{ + float depth = texture(u_depth, TexCoords).r; + + // reconstruct world position from depth + vec4 ndc = vec4(TexCoords * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 worldPos = u_current_vp_inverse * ndc; + worldPos /= worldPos.w; + + // reproject to previous frame + vec4 prevClip = u_previous_vp * worldPos; + vec2 prevUV = (prevClip.xy / prevClip.w) * 0.5 + 0.5; + + // velocity + vec2 velocity = (TexCoords - prevUV) * u_blur_strength; + + // clamp velocity magnitude + float speed = length(velocity); + float maxSpeed = 0.05; + if (speed > maxSpeed) { + velocity = velocity / speed * maxSpeed; + } + + // accumulate samples along velocity + vec3 color = texture(u_scene, TexCoords).rgb; + float totalWeight = 1.0; + + for (int i = 1; i < u_num_samples; ++i) + { + float t = float(i) / float(u_num_samples - 1) - 0.5; + vec2 sampleUV = TexCoords + velocity * t; + sampleUV = clamp(sampleUV, 0.0, 1.0); + + color += texture(u_scene, sampleUV).rgb; + totalWeight += 1.0; + } + + FragColor = vec4(color / totalWeight, 1.0); +} diff --git a/resources/shader/visu/postprocess/motion_blur.vert.glsl b/resources/shader/visu/postprocess/motion_blur.vert.glsl new file mode 100644 index 0000000..840bb1d --- /dev/null +++ b/resources/shader/visu/postprocess/motion_blur.vert.glsl @@ -0,0 +1,12 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec2 aTexCoord; + +out vec2 TexCoords; + +void main() +{ + gl_Position = vec4(aPos, 1.0); + TexCoords = aTexCoord; +} diff --git a/src/AI/BT/ActionNode.php b/src/AI/BT/ActionNode.php new file mode 100644 index 0000000..db66882 --- /dev/null +++ b/src/AI/BT/ActionNode.php @@ -0,0 +1,26 @@ +action)($context); + } +} diff --git a/src/AI/BT/ConditionNode.php b/src/AI/BT/ConditionNode.php new file mode 100644 index 0000000..4ec02fa --- /dev/null +++ b/src/AI/BT/ConditionNode.php @@ -0,0 +1,27 @@ +condition)($context) ? BTStatus::Success : BTStatus::Failure; + } +} diff --git a/src/AI/BT/InverterNode.php b/src/AI/BT/InverterNode.php new file mode 100644 index 0000000..506938a --- /dev/null +++ b/src/AI/BT/InverterNode.php @@ -0,0 +1,34 @@ + Failure, Running stays Running). + */ +class InverterNode extends BTNode +{ + public function __construct( + private BTNode $child, + ) { + } + + public function tick(BTContext $context): BTStatus + { + $status = $this->child->tick($context); + + return match ($status) { + BTStatus::Success => BTStatus::Failure, + BTStatus::Failure => BTStatus::Success, + BTStatus::Running => BTStatus::Running, + }; + } + + public function reset(): void + { + $this->child->reset(); + } +} diff --git a/src/AI/BT/ParallelNode.php b/src/AI/BT/ParallelNode.php new file mode 100644 index 0000000..1424d68 --- /dev/null +++ b/src/AI/BT/ParallelNode.php @@ -0,0 +1,63 @@ + $children + * @param int $requiredSuccesses Number of children that must succeed (0 = all) + */ + public function __construct( + private array $children, + private int $requiredSuccesses = 0, + ) { + if ($this->requiredSuccesses <= 0) { + $this->requiredSuccesses = count($this->children); + } + } + + public function tick(BTContext $context): BTStatus + { + $successCount = 0; + $failureCount = 0; + $total = count($this->children); + + foreach ($this->children as $child) { + $status = $child->tick($context); + + if ($status === BTStatus::Success) { + $successCount++; + } elseif ($status === BTStatus::Failure) { + $failureCount++; + } + } + + if ($successCount >= $this->requiredSuccesses) { + return BTStatus::Success; + } + + $maxPossibleSuccesses = $total - $failureCount; + if ($maxPossibleSuccesses < $this->requiredSuccesses) { + return BTStatus::Failure; + } + + return BTStatus::Running; + } + + public function reset(): void + { + foreach ($this->children as $child) { + $child->reset(); + } + } +} diff --git a/src/AI/BT/RepeaterNode.php b/src/AI/BT/RepeaterNode.php new file mode 100644 index 0000000..7ab416d --- /dev/null +++ b/src/AI/BT/RepeaterNode.php @@ -0,0 +1,54 @@ +child->tick($context); + + if ($status === BTStatus::Running) { + return BTStatus::Running; + } + + if ($status === BTStatus::Failure) { + $this->iteration = 0; + return BTStatus::Failure; + } + + // child succeeded + $this->iteration++; + $this->child->reset(); + + if ($this->maxRepetitions > 0 && $this->iteration >= $this->maxRepetitions) { + $this->iteration = 0; + return BTStatus::Success; + } + + return BTStatus::Running; + } + + public function reset(): void + { + $this->iteration = 0; + $this->child->reset(); + } +} diff --git a/src/AI/BT/SelectorNode.php b/src/AI/BT/SelectorNode.php new file mode 100644 index 0000000..ac13a65 --- /dev/null +++ b/src/AI/BT/SelectorNode.php @@ -0,0 +1,52 @@ + $children + */ + public function __construct( + private array $children, + ) { + } + + public function tick(BTContext $context): BTStatus + { + while ($this->currentIndex < count($this->children)) { + $status = $this->children[$this->currentIndex]->tick($context); + + if ($status === BTStatus::Running) { + return BTStatus::Running; + } + + if ($status === BTStatus::Success) { + $this->currentIndex = 0; + return BTStatus::Success; + } + + $this->currentIndex++; + } + + $this->currentIndex = 0; + return BTStatus::Failure; + } + + public function reset(): void + { + $this->currentIndex = 0; + foreach ($this->children as $child) { + $child->reset(); + } + } +} diff --git a/src/AI/BT/SequenceNode.php b/src/AI/BT/SequenceNode.php new file mode 100644 index 0000000..3203078 --- /dev/null +++ b/src/AI/BT/SequenceNode.php @@ -0,0 +1,52 @@ + $children + */ + public function __construct( + private array $children, + ) { + } + + public function tick(BTContext $context): BTStatus + { + while ($this->currentIndex < count($this->children)) { + $status = $this->children[$this->currentIndex]->tick($context); + + if ($status === BTStatus::Running) { + return BTStatus::Running; + } + + if ($status === BTStatus::Failure) { + $this->currentIndex = 0; + return BTStatus::Failure; + } + + $this->currentIndex++; + } + + $this->currentIndex = 0; + return BTStatus::Success; + } + + public function reset(): void + { + $this->currentIndex = 0; + foreach ($this->children as $child) { + $child->reset(); + } + } +} diff --git a/src/AI/BT/SucceederNode.php b/src/AI/BT/SucceederNode.php new file mode 100644 index 0000000..0b8776c --- /dev/null +++ b/src/AI/BT/SucceederNode.php @@ -0,0 +1,34 @@ +child->tick($context); + + if ($status === BTStatus::Running) { + return BTStatus::Running; + } + + return BTStatus::Success; + } + + public function reset(): void + { + $this->child->reset(); + } +} diff --git a/src/AI/BTContext.php b/src/AI/BTContext.php new file mode 100644 index 0000000..ed7329b --- /dev/null +++ b/src/AI/BTContext.php @@ -0,0 +1,36 @@ + + */ + public array $blackboard = []; + + public function __construct( + public readonly int $entity, + public readonly EntitiesInterface $entities, + public readonly float $deltaTime, + ) { + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->blackboard[$key] ?? $default; + } + + public function set(string $key, mixed $value): void + { + $this->blackboard[$key] = $value; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->blackboard); + } +} diff --git a/src/AI/BTNode.php b/src/AI/BTNode.php new file mode 100644 index 0000000..68b7fee --- /dev/null +++ b/src/AI/BTNode.php @@ -0,0 +1,12 @@ +|null Array of path nodes (start to goal) or null if no path + */ + public function findPath(GridGraph $grid, int $startX, int $startY, int $goalX, int $goalY): ?array + { + $grid->resetNodes(); + + $start = $grid->getNode($startX, $startY); + $goal = $grid->getNode($goalX, $goalY); + + if ($start === null || $goal === null) { + return null; + } + + if (!$start->walkable || !$goal->walkable) { + return null; + } + + $start->gCost = 0.0; + $start->hCost = $this->heuristic($start, $goal); + + /** @var array $openSet keyed by "x,y" */ + $openSet = []; + $openSet["{$start->x},{$start->y}"] = $start; + + while (count($openSet) > 0) { + // find node with lowest fCost + $current = $this->getLowestFCost($openSet); + $key = "{$current->x},{$current->y}"; + unset($openSet[$key]); + $current->closed = true; + + // reached goal + if ($current === $goal) { + return $this->reconstructPath($goal); + } + + foreach ($grid->getNeighbors($current) as $neighbor) { + if ($neighbor->closed) { + continue; + } + + $moveCost = $this->movementCost($current, $neighbor); + $tentativeG = $current->gCost + $moveCost; + + if ($tentativeG < $neighbor->gCost) { + $neighbor->gCost = $tentativeG; + $neighbor->hCost = $this->heuristic($neighbor, $goal); + $neighbor->parent = $current; + + $nKey = "{$neighbor->x},{$neighbor->y}"; + if (!isset($openSet[$nKey])) { + $openSet[$nKey] = $neighbor; + } + } + } + } + + return null; // no path found + } + + private function heuristic(PathNode $a, PathNode $b): float + { + // octile distance (exact for 8-directional movement) + $dx = abs($a->x - $b->x); + $dy = abs($a->y - $b->y); + return max($dx, $dy) + (M_SQRT2 - 1) * min($dx, $dy); + } + + private function movementCost(PathNode $from, PathNode $to): float + { + $dx = abs($from->x - $to->x); + $dy = abs($from->y - $to->y); + + return ($dx + $dy > 1) ? M_SQRT2 : 1.0; + } + + /** + * @param array $openSet + */ + private function getLowestFCost(array $openSet): PathNode + { + $best = null; + $bestF = PHP_FLOAT_MAX; + + foreach ($openSet as $node) { + $f = $node->fCost(); + if ($f < $bestF || ($f === $bestF && $best !== null && $node->hCost < $best->hCost)) { + $best = $node; + $bestF = $f; + } + } + + assert($best !== null); + return $best; + } + + /** + * @return array + */ + private function reconstructPath(PathNode $goal): array + { + $path = []; + $current = $goal; + + while ($current !== null) { + $path[] = $current; + $current = $current->parent; + } + + return array_reverse($path); + } +} diff --git a/src/AI/Pathfinding/GridGraph.php b/src/AI/Pathfinding/GridGraph.php new file mode 100644 index 0000000..aaede92 --- /dev/null +++ b/src/AI/Pathfinding/GridGraph.php @@ -0,0 +1,95 @@ +> + */ + private array $nodes = []; + + public function __construct( + public readonly int $width, + public readonly int $height, + public readonly bool $allowDiagonal = true, + ) { + for ($y = 0; $y < $height; $y++) { + for ($x = 0; $x < $width; $x++) { + $this->nodes[$y][$x] = new PathNode($x, $y); + } + } + } + + public function getNode(int $x, int $y): ?PathNode + { + return $this->nodes[$y][$x] ?? null; + } + + public function setWalkable(int $x, int $y, bool $walkable): void + { + if (isset($this->nodes[$y][$x])) { + // recreate node to preserve readonly walkable + $this->nodes[$y][$x] = new PathNode($x, $y, $walkable); + } + } + + public function isInBounds(int $x, int $y): bool + { + return $x >= 0 && $x < $this->width && $y >= 0 && $y < $this->height; + } + + /** + * @return array + */ + public function getNeighbors(PathNode $node): array + { + $neighbors = []; + $dirs = [ + [0, -1], [0, 1], [-1, 0], [1, 0], // cardinal + ]; + + if ($this->allowDiagonal) { + $dirs[] = [-1, -1]; + $dirs[] = [1, -1]; + $dirs[] = [-1, 1]; + $dirs[] = [1, 1]; + } + + foreach ($dirs as [$dx, $dy]) { + $nx = $node->x + $dx; + $ny = $node->y + $dy; + + if (!$this->isInBounds($nx, $ny)) { + continue; + } + + $neighbor = $this->nodes[$ny][$nx]; + if (!$neighbor->walkable) { + continue; + } + + // for diagonal: check that both cardinal neighbors are walkable (no corner cutting) + if ($dx !== 0 && $dy !== 0 && $this->allowDiagonal) { + $cardX = $this->nodes[$node->y][$nx] ?? null; + $cardY = $this->nodes[$ny][$node->x] ?? null; + if ($cardX === null || !$cardX->walkable || $cardY === null || !$cardY->walkable) { + continue; + } + } + + $neighbors[] = $neighbor; + } + + return $neighbors; + } + + public function resetNodes(): void + { + for ($y = 0; $y < $this->height; $y++) { + for ($x = 0; $x < $this->width; $x++) { + $this->nodes[$y][$x]->reset(); + } + } + } +} diff --git a/src/AI/Pathfinding/NavMesh.php b/src/AI/Pathfinding/NavMesh.php new file mode 100644 index 0000000..a3029e7 --- /dev/null +++ b/src/AI/Pathfinding/NavMesh.php @@ -0,0 +1,218 @@ + + */ + private array $triangles = []; + + /** + * Add a triangle to the navmesh. + */ + public function addTriangle(Vec3 $v0, Vec3 $v1, Vec3 $v2): int + { + $index = count($this->triangles); + $this->triangles[] = new NavMeshTriangle($index, $v0, $v1, $v2); + return $index; + } + + /** + * Connect two triangles as neighbors (bidirectional). + */ + public function connectTriangles(int $a, int $b): void + { + if (!isset($this->triangles[$a]) || !isset($this->triangles[$b])) { + return; + } + + if (!in_array($b, $this->triangles[$a]->neighbors, true)) { + $this->triangles[$a]->neighbors[] = $b; + } + if (!in_array($a, $this->triangles[$b]->neighbors, true)) { + $this->triangles[$b]->neighbors[] = $a; + } + } + + /** + * Auto-detect shared edges and connect triangles. + * Two triangles sharing 2 vertices are considered neighbors. + */ + public function buildConnectivity(float $epsilon = 0.001): void + { + $count = count($this->triangles); + for ($i = 0; $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + if ($this->sharesEdge($this->triangles[$i], $this->triangles[$j], $epsilon)) { + $this->connectTriangles($i, $j); + } + } + } + } + + private function sharesEdge(NavMeshTriangle $a, NavMeshTriangle $b, float $epsilon): bool + { + $vertsA = [$a->v0, $a->v1, $a->v2]; + $vertsB = [$b->v0, $b->v1, $b->v2]; + + $shared = 0; + foreach ($vertsA as $va) { + foreach ($vertsB as $vb) { + $dx = $va->x - $vb->x; + $dy = $va->y - $vb->y; + $dz = $va->z - $vb->z; + if (($dx * $dx + $dy * $dy + $dz * $dz) < $epsilon * $epsilon) { + $shared++; + if ($shared >= 2) { + return true; + } + break; + } + } + } + + return false; + } + + /** + * Find which triangle contains the given XZ position. + */ + public function findTriangle(float $x, float $z): ?NavMeshTriangle + { + foreach ($this->triangles as $tri) { + if ($tri->containsPoint($x, $z)) { + return $tri; + } + } + return null; + } + + /** + * Find a path between two world positions using A* on the triangle graph. + * + * @return array|null Waypoints from start to goal (triangle centers + goal) + */ + public function findPath(Vec3 $start, Vec3 $goal): ?array + { + $startTri = $this->findTriangle($start->x, $start->z); + $goalTri = $this->findTriangle($goal->x, $goal->z); + + if ($startTri === null || $goalTri === null) { + return null; + } + + if ($startTri->index === $goalTri->index) { + return [$start, $goal]; + } + + // A* on triangle graph + /** @var array */ + $gScore = []; + /** @var array */ + $cameFrom = []; + /** @var array */ + $openSet = []; + + $gScore[$startTri->index] = 0.0; + $openSet[$startTri->index] = $this->distVec3($start, $startTri->center) + + $this->distVec3($startTri->center, $goalTri->center); + + /** @var array */ + $closedSet = []; + + while (count($openSet) > 0) { + // get lowest fScore + $currentIdx = $this->getLowestF($openSet); + unset($openSet[$currentIdx]); + + if ($currentIdx === $goalTri->index) { + return $this->reconstructNavPath($cameFrom, $currentIdx, $start, $goal); + } + + $closedSet[$currentIdx] = true; + $current = $this->triangles[$currentIdx]; + + foreach ($current->neighbors as $neighborIdx) { + if (isset($closedSet[$neighborIdx])) { + continue; + } + + $neighbor = $this->triangles[$neighborIdx]; + $tentativeG = ($gScore[$currentIdx] ?? PHP_FLOAT_MAX) + + $this->distVec3($current->center, $neighbor->center); + + if ($tentativeG < ($gScore[$neighborIdx] ?? PHP_FLOAT_MAX)) { + $gScore[$neighborIdx] = $tentativeG; + $cameFrom[$neighborIdx] = $currentIdx; + $openSet[$neighborIdx] = $tentativeG + $this->distVec3($neighbor->center, $goalTri->center); + } + } + } + + return null; + } + + /** + * @param array $openSet + */ + private function getLowestF(array $openSet): int + { + $bestIdx = -1; + $bestF = PHP_FLOAT_MAX; + + foreach ($openSet as $idx => $f) { + if ($f < $bestF) { + $bestF = $f; + $bestIdx = $idx; + } + } + + return $bestIdx; + } + + /** + * @param array $cameFrom + * @return array + */ + private function reconstructNavPath(array $cameFrom, int $currentIdx, Vec3 $start, Vec3 $goal): array + { + $indices = [$currentIdx]; + while (isset($cameFrom[$currentIdx])) { + $currentIdx = $cameFrom[$currentIdx]; + $indices[] = $currentIdx; + } + + $indices = array_reverse($indices); + + $path = [$start]; + // skip first and last triangle centers (start/goal replace them) + for ($i = 1; $i < count($indices) - 1; $i++) { + $path[] = $this->triangles[$indices[$i]]->center; + } + $path[] = $goal; + + return $path; + } + + private function distVec3(Vec3 $a, Vec3 $b): float + { + $dx = $a->x - $b->x; + $dy = $a->y - $b->y; + $dz = $a->z - $b->z; + return sqrt($dx * $dx + $dy * $dy + $dz * $dz); + } + + public function getTriangleCount(): int + { + return count($this->triangles); + } + + public function getTriangle(int $index): ?NavMeshTriangle + { + return $this->triangles[$index] ?? null; + } +} diff --git a/src/AI/Pathfinding/NavMeshTriangle.php b/src/AI/Pathfinding/NavMeshTriangle.php new file mode 100644 index 0000000..1be72e3 --- /dev/null +++ b/src/AI/Pathfinding/NavMeshTriangle.php @@ -0,0 +1,62 @@ + Adjacent triangle indices + */ + public array $neighbors = []; + + public function __construct( + public readonly int $index, + public readonly Vec3 $v0, + public readonly Vec3 $v1, + public readonly Vec3 $v2, + ) { + $this->center = new Vec3( + ($v0->x + $v1->x + $v2->x) / 3.0, + ($v0->y + $v1->y + $v2->y) / 3.0, + ($v0->z + $v1->z + $v2->z) / 3.0, + ); + } + + public function containsPoint(float $x, float $z): bool + { + return self::pointInTriangle2D( + $x, $z, + $this->v0->x, $this->v0->z, + $this->v1->x, $this->v1->z, + $this->v2->x, $this->v2->z, + ); + } + + private static function pointInTriangle2D( + float $px, float $pz, + float $ax, float $az, + float $bx, float $bz, + float $cx, float $cz, + ): bool { + $d1 = self::sign2D($px, $pz, $ax, $az, $bx, $bz); + $d2 = self::sign2D($px, $pz, $bx, $bz, $cx, $cz); + $d3 = self::sign2D($px, $pz, $cx, $cz, $ax, $az); + + $hasNeg = ($d1 < 0) || ($d2 < 0) || ($d3 < 0); + $hasPos = ($d1 > 0) || ($d2 > 0) || ($d3 > 0); + + return !($hasNeg && $hasPos); + } + + private static function sign2D( + float $px, float $pz, + float $ax, float $az, + float $bx, float $bz, + ): float { + return ($px - $bx) * ($az - $bz) - ($ax - $bx) * ($pz - $bz); + } +} diff --git a/src/AI/Pathfinding/PathNode.php b/src/AI/Pathfinding/PathNode.php new file mode 100644 index 0000000..a4ef900 --- /dev/null +++ b/src/AI/Pathfinding/PathNode.php @@ -0,0 +1,31 @@ +gCost + $this->hCost; + } + + public function reset(): void + { + $this->gCost = PHP_FLOAT_MAX; + $this->hCost = 0.0; + $this->parent = null; + $this->closed = false; + } +} diff --git a/src/AI/StateInterface.php b/src/AI/StateInterface.php new file mode 100644 index 0000000..7b040a2 --- /dev/null +++ b/src/AI/StateInterface.php @@ -0,0 +1,14 @@ + + */ + private array $states = []; + + /** + * @var array + */ + private array $transitions = []; + + private ?string $currentStateName = null; + + public function addState(StateInterface $state): void + { + $this->states[$state->getName()] = $state; + } + + public function addTransition(StateTransition $transition): void + { + $this->transitions[] = $transition; + } + + public function setInitialState(string $name): void + { + if (!isset($this->states[$name])) { + throw new \InvalidArgumentException("State '{$name}' not registered."); + } + $this->currentStateName = $name; + } + + public function getCurrentStateName(): ?string + { + return $this->currentStateName; + } + + public function getCurrentState(): ?StateInterface + { + if ($this->currentStateName === null) { + return null; + } + return $this->states[$this->currentStateName] ?? null; + } + + public function update(BTContext $context): void + { + if ($this->currentStateName === null) { + return; + } + + // check transitions + foreach ($this->transitions as $transition) { + if ($transition->fromState !== $this->currentStateName) { + continue; + } + + if (!$transition->evaluate($context)) { + continue; + } + + if (!isset($this->states[$transition->toState])) { + continue; + } + + // transition + $this->states[$this->currentStateName]->onExit($context); + $this->currentStateName = $transition->toState; + $this->states[$this->currentStateName]->onEnter($context); + return; + } + + // update current state + $this->states[$this->currentStateName]->onUpdate($context); + } + + /** + * Force transition to a state (bypassing conditions) + */ + public function forceTransition(string $name, BTContext $context): void + { + if (!isset($this->states[$name])) { + throw new \InvalidArgumentException("State '{$name}' not registered."); + } + + if ($this->currentStateName !== null && isset($this->states[$this->currentStateName])) { + $this->states[$this->currentStateName]->onExit($context); + } + + $this->currentStateName = $name; + $this->states[$name]->onEnter($context); + } +} diff --git a/src/AI/StateTransition.php b/src/AI/StateTransition.php new file mode 100644 index 0000000..3e87d75 --- /dev/null +++ b/src/AI/StateTransition.php @@ -0,0 +1,23 @@ +condition)($context); + } +} diff --git a/src/Asset/AssetManager.php b/src/Asset/AssetManager.php new file mode 100644 index 0000000..d0a70c8 --- /dev/null +++ b/src/Asset/AssetManager.php @@ -0,0 +1,146 @@ + + */ + private array $textures = []; + + /** + * Loaded JSON data cache. + * + * @var array> + */ + private array $jsonCache = []; + + /** + * Base path for asset lookups. + */ + private string $basePath; + + /** + * GL state for texture creation. + */ + private GLState $gl; + + public function __construct(GLState $gl, string $basePath) + { + $this->gl = $gl; + $this->basePath = rtrim($basePath, '/'); + } + + /** + * Resolves a relative asset path to an absolute path. + */ + public function resolvePath(string $relativePath): string + { + return $this->basePath . '/' . ltrim($relativePath, '/'); + } + + /** + * Loads and caches a texture from a file path (relative to basePath). + */ + public function loadTexture(string $path, ?TextureOptions $options = null): Texture + { + if (isset($this->textures[$path])) { + return $this->textures[$path]; + } + + $fullPath = $this->resolvePath($path); + + $texture = new Texture($this->gl, $path); + $texture->loadFromFile($fullPath, $options); + + $this->textures[$path] = $texture; + return $texture; + } + + /** + * Returns a previously loaded texture or null. + */ + public function getTexture(string $path): ?Texture + { + return $this->textures[$path] ?? null; + } + + /** + * Checks if a texture is already cached. + */ + public function hasTexture(string $path): bool + { + return isset($this->textures[$path]); + } + + /** + * Loads and caches a JSON file (relative to basePath). + * + * @return array + */ + public function loadJson(string $path): array + { + if (isset($this->jsonCache[$path])) { + return $this->jsonCache[$path]; + } + + $fullPath = $this->resolvePath($path); + + if (!file_exists($fullPath)) { + throw new \RuntimeException("JSON asset not found: {$fullPath}"); + } + + $content = file_get_contents($fullPath); + if ($content === false) { + throw new \RuntimeException("Failed to read JSON asset: {$fullPath}"); + } + + $data = json_decode($content, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in asset: {$fullPath}"); + } + + $this->jsonCache[$path] = $data; + return $data; + } + + /** + * Removes a texture from the cache (freeing GPU memory). + */ + public function unloadTexture(string $path): void + { + unset($this->textures[$path]); + } + + /** + * Removes a JSON file from the cache. + */ + public function unloadJson(string $path): void + { + unset($this->jsonCache[$path]); + } + + /** + * Clears all cached assets. + */ + public function clear(): void + { + $this->textures = []; + $this->jsonCache = []; + } + + /** + * Returns the base path. + */ + public function getBasePath(): string + { + return $this->basePath; + } +} diff --git a/src/Asset/AssetManagerInterface.php b/src/Asset/AssetManagerInterface.php new file mode 100644 index 0000000..a756216 --- /dev/null +++ b/src/Asset/AssetManagerInterface.php @@ -0,0 +1,56 @@ + + */ + public function loadJson(string $path): array; + + /** + * Removes a texture from the cache. + */ + public function unloadTexture(string $path): void; + + /** + * Removes a JSON file from the cache. + */ + public function unloadJson(string $path): void; + + /** + * Clears all cached assets. + */ + public function clear(): void; + + /** + * Returns the base path. + */ + public function getBasePath(): string; +} diff --git a/src/Audio/AudioBackendInterface.php b/src/Audio/AudioBackendInterface.php new file mode 100644 index 0000000..e331630 --- /dev/null +++ b/src/Audio/AudioBackendInterface.php @@ -0,0 +1,57 @@ +pcmData); + } +} diff --git a/src/Audio/AudioManager.php b/src/Audio/AudioManager.php new file mode 100644 index 0000000..1f0a9bb --- /dev/null +++ b/src/Audio/AudioManager.php @@ -0,0 +1,256 @@ + AudioClipData). + * + * @var array + */ + private array $clipCache = []; + + /** + * Per-channel volume (0.0 to 1.0). + * + * @var array + */ + private array $channelVolumes = []; + + /** + * Currently playing music clip (for looping). + */ + private ?AudioClipData $currentMusic = null; + + /** + * Whether music is currently playing. + */ + private bool $musicPlaying = false; + + /** + * Stream handle for music playback. + */ + private ?int $musicStreamHandle = null; + + /** + * Create an AudioManager with explicit backend. + */ + public function __construct(AudioBackendInterface $backend) + { + $this->backend = $backend; + + // Default volumes + foreach (AudioChannel::cases() as $channel) { + $this->channelVolumes[$channel->value] = 1.0; + } + } + + /** + * Auto-detect the best available audio backend. + * Priority: SDL3 (if SDL instance provided) -> OpenAL -> exception. + */ + public static function create(mixed $sdl = null): self + { + // FFI-based backends (SDL3, OpenAL) require the FFI extension. + // We use string class names to avoid autoloading classes with FFI typed properties. + if (class_exists('FFI', false)) { + // Try SDL3 first if an SDL instance is available + if ($sdl !== null) { + try { + /** @var class-string */ + $cls = 'VISU\\Audio\\Backend\\SDL3AudioBackend'; + return new self(new $cls($sdl)); + } catch (\Throwable) { + // Fall through to OpenAL + } + } + + // Try OpenAL + try { + /** @var class-string */ + $cls = 'VISU\\Audio\\Backend\\OpenALAudioBackend'; + if ($cls::isAvailable()) { + return new self(new $cls()); + } + } catch (\Throwable) { + // Fall through to php-glfw + } + } + + // Try php-glfw built-in audio (miniaudio) + if (PHPGLFWAudioBackend::isAvailable()) { + return new self(new PHPGLFWAudioBackend()); + } + + throw new \RuntimeException( + 'No audio backend available.' + ); + } + + /** + * Get the active backend name. + */ + public function getBackendName(): string + { + return $this->backend->getName(); + } + + /** + * Get the active backend instance. + */ + public function getBackend(): AudioBackendInterface + { + return $this->backend; + } + + /** + * Load an audio file (WAV or MP3) and return AudioClipData. Results are cached. + */ + public function loadClip(string $path): AudioClipData + { + if (isset($this->clipCache[$path])) { + return $this->clipCache[$path]; + } + + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + if ($ext === 'mp3') { + // php-glfw backend handles MP3 natively via soundFromDisk(), + // so we only need the source path, not decoded PCM data. + if ($this->backend instanceof PHPGLFWAudioBackend) { + $clip = new AudioClipData('', 44100, 2, 16, $path); + } elseif (class_exists('FFI', false)) { + if ($this->mp3Decoder === null) { + $this->mp3Decoder = new Mp3Decoder(); + } + $clip = $this->mp3Decoder->decode($path); + } else { + throw new \RuntimeException("MP3 decoding requires FFI extension: {$path}"); + } + } else { + $clip = $this->backend->loadWav($path); + } + + $this->clipCache[$path] = $clip; + return $clip; + } + + /** + * Play a sound effect (one-shot). + */ + public function playSound(string $path, AudioChannel $channel = AudioChannel::SFX): void + { + $clip = $this->loadClip($path); + $volume = $this->channelVolumes[$channel->value] ?? 1.0; + $this->backend->play($clip, $volume); + } + + /** + * Start playing a music track. Stops any currently playing music. + */ + public function playMusic(string $path): void + { + $this->stopMusic(); + $this->currentMusic = $this->loadClip($path); + $this->musicPlaying = true; + $this->musicStreamHandle = $this->backend->streamStart($this->currentMusic); + $musicVol = $this->channelVolumes[AudioChannel::Music->value] ?? 1.0; + $this->backend->streamSetVolume($this->musicStreamHandle, $musicVol); + } + + /** + * Stop the currently playing music. + */ + public function stopMusic(): void + { + if ($this->musicStreamHandle !== null) { + $this->backend->streamStop($this->musicStreamHandle); + $this->musicStreamHandle = null; + } + $this->musicPlaying = false; + $this->currentMusic = null; + } + + /** + * Check if music is currently playing. + */ + public function isMusicPlaying(): bool + { + return $this->musicPlaying; + } + + /** + * Set volume for a specific channel (0.0 to 1.0). + */ + public function setChannelVolume(AudioChannel $channel, float $volume): void + { + $this->channelVolumes[$channel->value] = max(0.0, min(1.0, $volume)); + + // Apply live to music stream + if ($channel === AudioChannel::Music && $this->musicStreamHandle !== null) { + $this->backend->streamSetVolume($this->musicStreamHandle, $this->channelVolumes[$channel->value]); + } + } + + /** + * Get volume for a specific channel. + */ + public function getChannelVolume(AudioChannel $channel): float + { + return $this->channelVolumes[$channel->value] ?? 1.0; + } + + /** + * Play an AudioClipData directly. + */ + public function play(AudioClipData $clip, float $volume = 1.0): void + { + $this->backend->play($clip, $volume); + } + + /** + * Call once per game loop tick. + * Handles music looping when the stream buffer runs low. + */ + public function update(): void + { + if ($this->musicPlaying && $this->currentMusic !== null && $this->musicStreamHandle !== null) { + $queued = $this->backend->streamQueued($this->musicStreamHandle); + if ($queued < $this->currentMusic->getByteLength() / 2) { + $this->backend->streamEnqueue($this->musicStreamHandle, $this->currentMusic); + } + } + } + + /** + * Unload a cached clip. + */ + public function unloadClip(string $path): void + { + unset($this->clipCache[$path]); + } + + /** + * Clear all cached clips. + */ + public function clearCache(): void + { + $this->clipCache = []; + } + + public function __destruct() + { + if ($this->musicStreamHandle !== null) { + $this->backend->streamStop($this->musicStreamHandle); + } + $this->backend->shutdown(); + } +} diff --git a/src/Audio/AudioStream.php b/src/Audio/AudioStream.php new file mode 100644 index 0000000..ffdd3b8 --- /dev/null +++ b/src/Audio/AudioStream.php @@ -0,0 +1,49 @@ +sdl->ffi->SDL_PutAudioStreamData($this->stream, $buffer, $length); + } + + public function resume(): bool + { + return (bool) $this->sdl->ffi->SDL_ResumeAudioStream($this->stream); + } + + public function pause(): bool + { + return (bool) $this->sdl->ffi->SDL_PauseAudioStream($this->stream); + } + + public function clear(): bool + { + return (bool) $this->sdl->ffi->SDL_ClearAudioStream($this->stream); + } + + public function getQueued(): int + { + return $this->sdl->ffi->SDL_GetAudioStreamQueued($this->stream); + } + + public function destroy(): void + { + $this->sdl->ffi->SDL_DestroyAudioStream($this->stream); + } + + public function getNative(): CData + { + return $this->stream; + } +} diff --git a/src/Audio/Backend/OpenALAudioBackend.php b/src/Audio/Backend/OpenALAudioBackend.php new file mode 100644 index 0000000..593b0a6 --- /dev/null +++ b/src/Audio/Backend/OpenALAudioBackend.php @@ -0,0 +1,528 @@ + {source, buffers[], clip} + * @var array + */ + private array $streams = []; + private int $nextHandle = 1; + + /** + * Tracks OpenAL buffer IDs to free them later. + * @var int[] + */ + private array $allocatedBuffers = []; + + public function __construct() + { + $this->al = $this->createFFI(); + + // Open default device + $this->device = $this->al->alcOpenDevice(null); + if (FFI::isNull($this->device)) { + throw new \RuntimeException('OpenAL: Failed to open default audio device'); + } + + // Create and activate context + $this->context = $this->al->alcCreateContext($this->device, null); + if (FFI::isNull($this->context)) { + $this->al->alcCloseDevice($this->device); + throw new \RuntimeException('OpenAL: Failed to create audio context'); + } + + $this->al->alcMakeContextCurrent($this->context); + + // Pre-allocate SFX source pool + $this->initSfxPool(); + } + + private function createFFI(): FFI + { + $declarations = <<<'CDEF' +typedef unsigned int ALuint; +typedef int ALint; +typedef int ALsizei; +typedef int ALenum; +typedef float ALfloat; +typedef char ALchar; +typedef void ALvoid; +typedef unsigned char ALboolean; + +typedef void ALCdevice; +typedef void ALCcontext; +typedef int ALCenum; +typedef int ALCint; + +// Device & Context +ALCdevice* alcOpenDevice(const ALchar *devicename); +ALCcontext* alcCreateContext(ALCdevice *device, const ALCint *attrlist); +ALboolean alcMakeContextCurrent(ALCcontext *context); +void alcDestroyContext(ALCcontext *context); +ALboolean alcCloseDevice(ALCdevice *device); + +// Buffers +void alGenBuffers(ALsizei n, ALuint *buffers); +void alDeleteBuffers(ALsizei n, const ALuint *buffers); +void alBufferData(ALuint buffer, ALenum format, const ALvoid *data, ALsizei size, ALsizei freq); + +// Sources +void alGenSources(ALsizei n, ALuint *sources); +void alDeleteSources(ALsizei n, const ALuint *sources); +void alSourcei(ALuint source, ALenum param, ALint value); +void alSourcef(ALuint source, ALenum param, ALfloat value); +void alSourcePlay(ALuint source); +void alSourceStop(ALuint source); +void alGetSourcei(ALuint source, ALenum param, ALint *value); + +// Streaming (buffer queue) +void alSourceQueueBuffers(ALuint source, ALsizei nb, const ALuint *buffers); +void alSourceUnqueueBuffers(ALuint source, ALsizei nb, ALuint *buffers); + +// Error +ALenum alGetError(void); +CDEF; + + $libPath = self::findLibrary(); + return FFI::cdef($declarations, $libPath); + } + + private static function findLibrary(): string + { + $candidates = [ + // macOS – system framework + '/System/Library/Frameworks/OpenAL.framework/OpenAL', + // macOS – Homebrew (linked) + '/opt/homebrew/lib/libopenal.dylib', + '/usr/local/lib/libopenal.dylib', + // Linux + '/usr/lib/x86_64-linux-gnu/libopenal.so.1', + '/usr/lib/x86_64-linux-gnu/libopenal.so', + '/usr/lib/aarch64-linux-gnu/libopenal.so.1', + '/usr/lib/aarch64-linux-gnu/libopenal.so', + '/usr/lib/libopenal.so.1', + '/usr/lib/libopenal.so', + ]; + + // macOS – Homebrew keg-only (openal-soft is not symlinked by default) + if (PHP_OS_FAMILY === 'Darwin') { + foreach (['/opt/homebrew/opt/openal-soft/lib', '/usr/local/opt/openal-soft/lib'] as $kegDir) { + if (is_dir($kegDir)) { + $candidates[] = $kegDir . '/libopenal.dylib'; + } + } + // Scan Homebrew Cellar for any installed version + foreach (['/opt/homebrew/Cellar/openal-soft', '/usr/local/Cellar/openal-soft'] as $cellarDir) { + if (is_dir($cellarDir)) { + $versions = @scandir($cellarDir, SCANDIR_SORT_DESCENDING); + if ($versions) { + foreach ($versions as $ver) { + if ($ver[0] === '.') continue; + $candidates[] = $cellarDir . '/' . $ver . '/lib/libopenal.dylib'; + } + } + } + } + } + + // Linux – scan common lib directories for any libopenal variant + if (PHP_OS_FAMILY === 'Linux') { + foreach (['/usr/lib', '/usr/local/lib'] as $libDir) { + $matches = @glob($libDir . '/*/libopenal.so*'); + if ($matches) { + array_push($candidates, ...$matches); + } + } + } + + foreach ($candidates as $path) { + if (file_exists($path)) { + return $path; + } + } + + // Last resort: let the dynamic linker try + $fallbacks = PHP_OS_FAMILY === 'Windows' + ? ['OpenAL32.dll', 'soft_oal.dll'] + : ['libopenal.so.1', 'libopenal.so', 'libopenal.dylib']; + + foreach ($fallbacks as $name) { + try { + \FFI::cdef('void alGetError(void);', $name); + return $name; + } catch (\FFI\Exception) { + continue; + } + } + + throw new \RuntimeException( + 'OpenAL shared library not found. Install OpenAL Soft (e.g. brew install openal-soft, apt install libopenal-dev).' + ); + } + + /** + * Detect whether OpenAL is available on this system without fully initializing. + */ + public static function isAvailable(): bool + { + try { + self::findLibrary(); + return true; + } catch (\RuntimeException) { + return false; + } + } + + private function initSfxPool(): void + { + $sources = $this->al->new("ALuint[{$this->sfxSourceCount}]"); + $this->al->alGenSources($this->sfxSourceCount, $sources); + + for ($i = 0; $i < $this->sfxSourceCount; $i++) { + $this->sfxSources[$i] = $sources[$i]; + } + } + + private function getALFormat(AudioClipData $clip): int + { + if ($clip->channels === 1) { + return $clip->bitsPerSample === 8 ? self::AL_FORMAT_MONO8 : self::AL_FORMAT_MONO16; + } + return $clip->bitsPerSample === 8 ? self::AL_FORMAT_STEREO8 : self::AL_FORMAT_STEREO16; + } + + public function loadWav(string $path): AudioClipData + { + return self::parseWavFile($path); + } + + /** + * Parse a WAV file into AudioClipData (pure PHP, no SDL dependency). + */ + private static function parseWavFile(string $path): AudioClipData + { + $data = file_get_contents($path); + if ($data === false) { + throw new \RuntimeException("Failed to read WAV file: {$path}"); + } + + if (strlen($data) < 44) { + throw new \RuntimeException("WAV file too small: {$path}"); + } + + // RIFF header + $riff = substr($data, 0, 4); + $wave = substr($data, 8, 4); + if ($riff !== 'RIFF' || $wave !== 'WAVE') { + throw new \RuntimeException("Not a valid WAV file: {$path}"); + } + + // Find fmt chunk + $offset = 12; + $fmtFound = false; + $channels = 0; + $sampleRate = 0; + $bitsPerSample = 0; + + while ($offset < strlen($data) - 8) { + $chunkId = substr($data, $offset, 4); + $unpacked = unpack('V', substr($data, $offset + 4, 4)); + $chunkSize = $unpacked !== false ? $unpacked[1] : 0; + + if ($chunkId === 'fmt ') { + $fmt = unpack('vAudioFormat/vChannels/VSampleRate/VByteRate/vBlockAlign/vBitsPerSample', substr($data, $offset + 8, 16)); + if ($fmt === false) { + throw new \RuntimeException('Failed to parse WAV fmt chunk'); + } + $channels = $fmt['Channels']; + $sampleRate = $fmt['SampleRate']; + $bitsPerSample = $fmt['BitsPerSample']; + $fmtFound = true; + } + + if ($chunkId === 'data') { + if (!$fmtFound) { + throw new \RuntimeException("WAV fmt chunk not found before data: {$path}"); + } + $pcm = substr($data, $offset + 8, $chunkSize); + return new AudioClipData($pcm, $sampleRate, $channels, $bitsPerSample, $path); + } + + $offset += 8 + $chunkSize; + // Chunks are padded to even size + if ($chunkSize % 2 !== 0) { + $offset++; + } + } + + throw new \RuntimeException("WAV data chunk not found: {$path}"); + } + + public function play(AudioClipData $clip, float $volume = 1.0): void + { + // Round-robin through SFX source pool + $sourceId = $this->sfxSources[$this->sfxSourceIndex % $this->sfxSourceCount]; + $this->sfxSourceIndex++; + + // Stop if currently playing + $this->al->alSourceStop($sourceId); + + // Create buffer and upload PCM data + $bufId = $this->al->new('ALuint'); + $this->al->alGenBuffers(1, FFI::addr($bufId)); + $bufIdVal = $bufId->cdata; + $this->allocatedBuffers[] = $bufIdVal; + + $format = $this->getALFormat($clip); + $len = $clip->getByteLength(); + $pcmData = $clip->pcmData; + $pcmBuf = $this->al->new("uint8_t[$len]"); + FFI::memcpy($pcmBuf, $pcmData, $len); + + $this->al->alBufferData($bufIdVal, $format, $pcmBuf, $len, $clip->sampleRate); + + $this->al->alSourcei($sourceId, self::AL_BUFFER, $bufIdVal); + $this->al->alSourcef($sourceId, self::AL_GAIN, $volume); + $this->al->alSourcei($sourceId, self::AL_LOOPING, self::AL_FALSE); + $this->al->alSourcePlay($sourceId); + } + + public function streamStart(AudioClipData $clip): int + { + // Create a dedicated source for streaming + $srcId = $this->al->new('ALuint'); + $this->al->alGenSources(1, FFI::addr($srcId)); + $sourceId = $srcId->cdata; + + $format = $this->getALFormat($clip); + + // Split clip into streaming buffers (4 x quarter) + $totalLen = $clip->getByteLength(); + $numBuffers = 4; + $chunkSize = (int) ceil($totalLen / $numBuffers); + + // Align chunk to frame boundary + $frameSize = $clip->channels * ($clip->bitsPerSample / 8); + $chunkSize = (int)(floor($chunkSize / $frameSize) * $frameSize); + if ($chunkSize < $frameSize) { + $chunkSize = (int) $frameSize; + } + + $bufferIds = []; + for ($i = 0; $i < $numBuffers; $i++) { + $offset = $i * $chunkSize; + $remaining = $totalLen - $offset; + if ($remaining <= 0) break; + $size = min($chunkSize, $remaining); + + $bufId = $this->al->new('ALuint'); + $this->al->alGenBuffers(1, FFI::addr($bufId)); + $bufIdVal = $bufId->cdata; + $bufferIds[] = $bufIdVal; + + $chunk = substr($clip->pcmData, $offset, $size); + $pcmBuf = $this->al->new("uint8_t[$size]"); + FFI::memcpy($pcmBuf, $chunk, $size); + + $this->al->alBufferData($bufIdVal, $format, $pcmBuf, $size, $clip->sampleRate); + + $alBuf = $this->al->new('ALuint'); + $alBuf->cdata = $bufIdVal; + $this->al->alSourceQueueBuffers($sourceId, 1, FFI::addr($alBuf)); + } + + $this->al->alSourcePlay($sourceId); + + $handle = $this->nextHandle++; + $this->streams[$handle] = [ + 'source' => $sourceId, + 'buffers' => $bufferIds, + 'clip' => $clip, + 'chunkSize' => $chunkSize, + ]; + + return $handle; + } + + public function streamQueued(int $handle): int + { + if (!isset($this->streams[$handle])) { + return 0; + } + + $sourceId = $this->streams[$handle]['source']; + $queued = $this->al->new('ALint'); + $this->al->alGetSourcei($sourceId, self::AL_BUFFERS_QUEUED, FFI::addr($queued)); + + $processed = $this->al->new('ALint'); + $this->al->alGetSourcei($sourceId, self::AL_BUFFERS_PROCESSED, FFI::addr($processed)); + + $remaining = (int)$queued->cdata - (int)$processed->cdata; + return $remaining * $this->streams[$handle]['chunkSize']; + } + + public function streamEnqueue(int $handle, AudioClipData $clip): void + { + if (!isset($this->streams[$handle])) { + return; + } + + $stream = &$this->streams[$handle]; + $sourceId = $stream['source']; + $format = $this->getALFormat($clip); + + // Unqueue processed buffers first + $processed = $this->al->new('ALint'); + $this->al->alGetSourcei($sourceId, self::AL_BUFFERS_PROCESSED, FFI::addr($processed)); + + for ($i = 0; $i < $processed->cdata; $i++) { + $unqueued = $this->al->new('ALuint'); + $this->al->alSourceUnqueueBuffers($sourceId, 1, FFI::addr($unqueued)); + $this->al->alDeleteBuffers(1, FFI::addr($unqueued)); + } + + // Queue new data in chunks + $totalLen = $clip->getByteLength(); + $chunkSize = $stream['chunkSize']; + $numChunks = (int) ceil($totalLen / $chunkSize); + + $newBuffers = []; + for ($i = 0; $i < $numChunks; $i++) { + $offset = $i * $chunkSize; + $remaining = $totalLen - $offset; + if ($remaining <= 0) break; + $size = min($chunkSize, $remaining); + + $bufId = $this->al->new('ALuint'); + $this->al->alGenBuffers(1, FFI::addr($bufId)); + $bufIdVal = $bufId->cdata; + $newBuffers[] = $bufIdVal; + + $chunk = substr($clip->pcmData, $offset, $size); + $pcmBuf = $this->al->new("uint8_t[$size]"); + FFI::memcpy($pcmBuf, $chunk, $size); + + $this->al->alBufferData($bufIdVal, $format, $pcmBuf, $size, $clip->sampleRate); + + $alBuf = $this->al->new('ALuint'); + $alBuf->cdata = $bufIdVal; + $this->al->alSourceQueueBuffers($sourceId, 1, FFI::addr($alBuf)); + } + + $stream['buffers'] = array_merge($stream['buffers'], $newBuffers); + + // Resume if stopped + $state = $this->al->new('ALint'); + $this->al->alGetSourcei($sourceId, self::AL_SOURCE_STATE, FFI::addr($state)); + if ($state->cdata !== self::AL_PLAYING) { + $this->al->alSourcePlay($sourceId); + } + } + + public function streamSetVolume(int $handle, float $volume): void + { + if (!isset($this->streams[$handle])) return; + $this->al->alSourcef($this->streams[$handle]['source'], self::AL_GAIN, $volume); + } + + public function streamStop(int $handle): void + { + if (!isset($this->streams[$handle])) { + return; + } + + $stream = $this->streams[$handle]; + $sourceId = $stream['source']; + + $this->al->alSourceStop($sourceId); + + // Unqueue all buffers + $queued = $this->al->new('ALint'); + $this->al->alGetSourcei($sourceId, self::AL_BUFFERS_QUEUED, FFI::addr($queued)); + for ($i = 0; $i < $queued->cdata; $i++) { + $buf = $this->al->new('ALuint'); + $this->al->alSourceUnqueueBuffers($sourceId, 1, FFI::addr($buf)); + $this->al->alDeleteBuffers(1, FFI::addr($buf)); + } + + $srcBuf = $this->al->new('ALuint'); + $srcBuf->cdata = $sourceId; + $this->al->alDeleteSources(1, FFI::addr($srcBuf)); + + unset($this->streams[$handle]); + } + + public function shutdown(): void + { + // Stop all streams + foreach (array_keys($this->streams) as $handle) { + $this->streamStop($handle); + } + + // Clean up SFX sources + if (count($this->sfxSources) > 0) { + $sources = $this->al->new("ALuint[{$this->sfxSourceCount}]"); + for ($i = 0; $i < $this->sfxSourceCount; $i++) { + $sources[$i] = $this->sfxSources[$i]; + } + $this->al->alDeleteSources($this->sfxSourceCount, $sources); + } + + // Clean up allocated buffers + foreach ($this->allocatedBuffers as $bufId) { + $buf = $this->al->new('ALuint'); + $buf->cdata = $bufId; + $this->al->alDeleteBuffers(1, FFI::addr($buf)); + } + + $this->al->alcMakeContextCurrent(null); + $this->al->alcDestroyContext($this->context); + $this->al->alcCloseDevice($this->device); + } + + public function getName(): string + { + return 'OpenAL'; + } +} diff --git a/src/Audio/Backend/PHPGLFWAudioBackend.php b/src/Audio/Backend/PHPGLFWAudioBackend.php new file mode 100644 index 0000000..d64ed08 --- /dev/null +++ b/src/Audio/Backend/PHPGLFWAudioBackend.php @@ -0,0 +1,118 @@ + loaded sounds by path */ + private array $sounds = []; + + /** @var array active streams by handle */ + private array $streams = []; + + private int $nextHandle = 1; + + public function __construct() + { + if (!class_exists(GLAudioEngine::class)) { + throw new \RuntimeException('GL\Audio\Engine not available — php-glfw was built without audio support.'); + } + $this->engine = new GLAudioEngine([]); + $this->engine->start(); + } + + public static function isAvailable(): bool + { + return class_exists(GLAudioEngine::class); + } + + public function loadWav(string $path): AudioClipData + { + // AudioClipData is not used for actual playback in this backend, + // but we still return a valid object to satisfy the interface. + // The real sound is loaded lazily via getSound() when played. + return new AudioClipData('', 44100, 2, 16, $path); + } + + public function play(AudioClipData $clip, float $volume = 1.0): void + { + $sound = $this->getSound($clip->sourcePath); + $sound->setVolume($volume); + $sound->setLoop(false); + $sound->play(); + } + + public function streamStart(AudioClipData $clip): int + { + $sound = $this->getSound($clip->sourcePath); + $sound->setLoop(true); + $sound->setVolume(1.0); + $sound->play(); + + $handle = $this->nextHandle++; + $this->streams[$handle] = $sound; + return $handle; + } + + public function streamQueued(int $handle): int + { + $sound = $this->streams[$handle] ?? null; + if ($sound === null || !$sound->isPlaying()) { + return 0; + } + // Return a large value so AudioManager doesn't try to re-enqueue + return PHP_INT_MAX; + } + + public function streamEnqueue(int $handle, AudioClipData $clip): void + { + // Looping is handled natively by GL\Audio\Sound — nothing to do + } + + public function streamSetVolume(int $handle, float $volume): void + { + $sound = $this->streams[$handle] ?? null; + $sound?->setVolume($volume); + } + + public function streamStop(int $handle): void + { + $sound = $this->streams[$handle] ?? null; + $sound?->stop(); + unset($this->streams[$handle]); + } + + public function shutdown(): void + { + foreach ($this->streams as $sound) { + $sound->stop(); + } + $this->streams = []; + $this->sounds = []; + $this->engine->stop(); + } + + public function getName(): string + { + return 'php-glfw (miniaudio)'; + } + + private function getSound(string $path): GLSound + { + if (!isset($this->sounds[$path])) { + $this->sounds[$path] = $this->engine->soundFromDisk($path); + } + return $this->sounds[$path]; + } +} diff --git a/src/Audio/Backend/SDL3AudioBackend.php b/src/Audio/Backend/SDL3AudioBackend.php new file mode 100644 index 0000000..eeeb3c2 --- /dev/null +++ b/src/Audio/Backend/SDL3AudioBackend.php @@ -0,0 +1,180 @@ + + */ + private array $streams = []; + + private int $nextHandle = 1; + + public function __construct( + private SDL $sdl, + int $sampleRate = 44100, + int $channels = 2, + ) { + $spec = $sdl->ffi->new('SDL_AudioSpec'); + $spec->format = 0x8010; // SDL_AUDIO_S16 + $spec->channels = $channels; + $spec->freq = $sampleRate; + + $stream = $sdl->ffi->SDL_OpenAudioDeviceStream( + SDL::AUDIO_DEVICE_DEFAULT_PLAYBACK, + FFI::addr($spec), + null, + null + ); + + if ($stream === null) { + throw new SDLException('SDL_OpenAudioDeviceStream failed: ' . $sdl->getError()); + } + + $this->mainStream = $stream; + $sdl->ffi->SDL_ResumeAudioStream($stream); + } + + public function loadWav(string $path): AudioClipData + { + $spec = $this->sdl->ffi->new('SDL_AudioSpec'); + $audioBuf = $this->sdl->ffi->new('uint8_t*'); + $audioLen = $this->sdl->ffi->new('uint32_t'); + + $ok = $this->sdl->ffi->SDL_LoadWAV( + $path, + FFI::addr($spec), + FFI::addr($audioBuf), + FFI::addr($audioLen) + ); + + if (!$ok) { + throw new SDLException("SDL_LoadWAV failed for '{$path}': " . $this->sdl->getError()); + } + + $length = (int) $audioLen->cdata; + $pcm = FFI::string($audioBuf, $length); + + // Free SDL-allocated buffer + $this->sdl->ffi->SDL_free($audioBuf); + + $bitsPerSample = match ($spec->format) { + 0x8010 => 16, // SDL_AUDIO_S16 + 0x8008 => 8, // SDL_AUDIO_S8 + 0x8020 => 32, // SDL_AUDIO_S32 + default => 16, + }; + + return new AudioClipData($pcm, $spec->freq, $spec->channels, $bitsPerSample, $path); + } + + public function play(AudioClipData $clip, float $volume = 1.0): void + { + $data = $clip->pcmData; + $len = strlen($data); + + $buf = FFI::new("uint8_t[$len]"); + FFI::memcpy($buf, $data, $len); + + $this->sdl->ffi->SDL_PutAudioStreamData($this->mainStream, $buf, $len); + } + + public function streamStart(AudioClipData $clip): int + { + $spec = $this->sdl->ffi->new('SDL_AudioSpec'); + $spec->format = 0x8010; + $spec->channels = $clip->channels; + $spec->freq = $clip->sampleRate; + + $stream = $this->sdl->ffi->SDL_OpenAudioDeviceStream( + SDL::AUDIO_DEVICE_DEFAULT_PLAYBACK, + FFI::addr($spec), + null, + null + ); + + if ($stream === null) { + throw new SDLException('SDL_OpenAudioDeviceStream failed: ' . $this->sdl->getError()); + } + + $this->sdl->ffi->SDL_ResumeAudioStream($stream); + + $handle = $this->nextHandle++; + $this->streams[$handle] = $stream; + + // Enqueue initial data + $this->streamEnqueue($handle, $clip); + + return $handle; + } + + public function streamQueued(int $handle): int + { + if (!isset($this->streams[$handle])) { + return 0; + } + return $this->sdl->ffi->SDL_GetAudioStreamQueued($this->streams[$handle]); + } + + public function streamEnqueue(int $handle, AudioClipData $clip): void + { + if (!isset($this->streams[$handle])) { + return; + } + + $data = $clip->pcmData; + $len = strlen($data); + $buf = FFI::new("uint8_t[$len]"); + FFI::memcpy($buf, $data, $len); + + $this->sdl->ffi->SDL_PutAudioStreamData($this->streams[$handle], $buf, $len); + } + + public function streamSetVolume(int $handle, float $volume): void + { + // SDL3 stream API does not support per-stream volume natively + } + + public function streamStop(int $handle): void + { + if (!isset($this->streams[$handle])) { + return; + } + + $this->sdl->ffi->SDL_ClearAudioStream($this->streams[$handle]); + $this->sdl->ffi->SDL_DestroyAudioStream($this->streams[$handle]); + unset($this->streams[$handle]); + } + + public function shutdown(): void + { + foreach (array_keys($this->streams) as $handle) { + $this->streamStop($handle); + } + + $this->sdl->ffi->SDL_DestroyAudioStream($this->mainStream); + } + + public function getName(): string + { + return 'SDL3'; + } + + public static function isAvailable(): bool + { + return class_exists('FFI', false) && class_exists(SDL::class); + } +} diff --git a/src/Audio/Mp3Decoder.php b/src/Audio/Mp3Decoder.php new file mode 100644 index 0000000..42788af --- /dev/null +++ b/src/Audio/Mp3Decoder.php @@ -0,0 +1,124 @@ +ffi = FFI::cdef(<<<'CDEF' +typedef struct { + short *pcm; + int samples; + int channels; + int sample_rate; + int error; +} mp3_result_t; + +void mp3_decode_buffer(const unsigned char *data, int data_size, mp3_result_t *result); +void mp3_free(void *ptr); +CDEF, $libPath); + } + + /** + * Decode an MP3 file into AudioClipData (16-bit PCM). + */ + public function decode(string $path): AudioClipData + { + $data = @file_get_contents($path); + if ($data === false) { + throw new \RuntimeException("Failed to read MP3 file: {$path}"); + } + + return $this->decodeBuffer($data, $path); + } + + /** + * Decode MP3 data from a string buffer into AudioClipData. + */ + public function decodeBuffer(string $data, string $sourcePath = ''): AudioClipData + { + $len = strlen($data); + if ($len === 0) { + throw new \RuntimeException("Empty MP3 data for: {$sourcePath}"); + } + + $inputBuf = FFI::new("unsigned char[{$len}]"); + FFI::memcpy($inputBuf, $data, $len); + + $result = $this->ffi->new('mp3_result_t'); + $this->ffi->mp3_decode_buffer($inputBuf, $len, FFI::addr($result)); + + if ($result->error !== 0) { + throw new \RuntimeException("minimp3 decode error for: {$sourcePath}"); + } + + if ($result->samples === 0 || FFI::isNull($result->pcm)) { + throw new \RuntimeException("No audio frames decoded from: {$sourcePath}"); + } + + $totalSamples = $result->samples * $result->channels; + $byteLength = $totalSamples * 2; // 16-bit = 2 bytes per sample + $pcm = FFI::string($result->pcm, $byteLength); + + $clipData = new AudioClipData( + pcmData: $pcm, + sampleRate: $result->sample_rate, + channels: $result->channels, + bitsPerSample: 16, + sourcePath: $sourcePath, + ); + + $this->ffi->mp3_free($result->pcm); + + return $clipData; + } + + private static function findLibrary(): string + { + $platformDir = self::getPlatformDir(); + $filename = PHP_OS_FAMILY === 'Windows' ? 'minimp3.dll' + : (PHP_OS_FAMILY === 'Darwin' ? 'libminimp3.dylib' : 'libminimp3.so'); + + $candidates = []; + + // Relative to VISU_PATH_ROOT if defined + if (defined('VISU_PATH_ROOT')) { + $root = VISU_PATH_ROOT; + $candidates[] = "{$root}/resources/lib/minimp3/{$platformDir}/{$filename}"; + } + + // Relative to this file + $dir = dirname(__DIR__, 2) . '/resources/lib/minimp3'; + $candidates[] = "{$dir}/{$platformDir}/{$filename}"; + + foreach ($candidates as $path) { + if (file_exists($path)) { + return $path; + } + } + + throw new \RuntimeException( + "minimp3 shared library not found for {$platformDir}. Run: resources/lib/minimp3/build.sh" + ); + } + + private static function getPlatformDir(): string + { + $arch = php_uname('m'); + + return match (PHP_OS_FAMILY) { + 'Darwin' => $arch === 'x86_64' ? 'darwin-x86_64' : 'darwin-arm64', + 'Windows' => 'windows-x86_64', + default => 'linux-x86_64', // Linux and others + }; + } +} diff --git a/src/Build/BuildConfig.php b/src/Build/BuildConfig.php new file mode 100644 index 0000000..ed8fc4b --- /dev/null +++ b/src/Build/BuildConfig.php @@ -0,0 +1,125 @@ + */ + public array $phpExtensions = ['glfw', 'mbstring']; + + /** @var array */ + public array $phpExtraLibs = ['-lc++']; + + /** @var array> Platform-specific native libs to bundle alongside the binary */ + public array $bundleLibs = []; + + /** @var array Glob patterns to exclude from PHAR */ + public array $pharExclude = ['**/tests', '**/Tests', '**/test', '**/docs', '**/doc', '**/editor', '**/.git', '**/.idea', '**/.phpunit*', '**/examples']; + + /** @var array Additional PHP files to require in stub */ + public array $additionalRequires = []; + + /** @var array Resource dirs that stay external (not in PHAR) */ + public array $externalResources = []; + + /** @var array> Platform-specific config */ + public array $platforms = []; + + /** @var array> Build type definitions with constant overrides */ + public array $buildTypes = []; + + public string $projectRoot; + + private function __construct(string $projectRoot) + { + $this->projectRoot = $projectRoot; + } + + /** + * Load config from build.json with fallbacks from composer.json + */ + public static function load(string $projectRoot): self + { + $config = new self($projectRoot); + + // Read defaults from composer.json + $composerFile = $projectRoot . '/composer.json'; + if (file_exists($composerFile)) { + $composer = json_decode((string) file_get_contents($composerFile), true); + if (is_array($composer)) { + if (isset($composer['name'])) { + $parts = explode('/', $composer['name']); + $config->name = ucfirst(end($parts)); + } + if (isset($composer['version'])) { + $config->version = $composer['version']; + } + } + } + + // Override with build.json if present + $buildFile = $projectRoot . '/build.json'; + if (file_exists($buildFile)) { + $build = json_decode((string) file_get_contents($buildFile), true); + if (is_array($build)) { + $config->applyBuildJson($build); + } + } + + return $config; + } + + /** + * @param array $data + */ + private function applyBuildJson(array $data): void + { + if (isset($data['name'])) $this->name = $data['name']; + if (isset($data['identifier'])) $this->identifier = $data['identifier']; + if (isset($data['version'])) $this->version = $data['version']; + if (isset($data['entry'])) $this->entry = $data['entry']; + if (isset($data['run'])) $this->run = $data['run']; + + if (isset($data['php']['extensions'])) $this->phpExtensions = $data['php']['extensions']; + if (isset($data['php']['extraLibs'])) $this->phpExtraLibs = $data['php']['extraLibs']; + if (isset($data['php']['bundleLibs'])) $this->bundleLibs = $data['php']['bundleLibs']; + + if (isset($data['phar']['exclude'])) $this->pharExclude = $data['phar']['exclude']; + if (isset($data['phar']['additionalRequires'])) $this->additionalRequires = $data['phar']['additionalRequires']; + + if (isset($data['resources']['external'])) $this->externalResources = $data['resources']['external']; + if (isset($data['platforms'])) $this->platforms = $data['platforms']; + if (isset($data['buildTypes'])) $this->buildTypes = $data['buildTypes']; + } + + /** + * Dump config summary for dry-run output + * + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'identifier' => $this->identifier, + 'version' => $this->version, + 'entry' => $this->entry, + 'php.extensions' => $this->phpExtensions, + 'php.extraLibs' => $this->phpExtraLibs, + 'php.bundleLibs' => $this->bundleLibs, + 'phar.exclude' => $this->pharExclude, + 'phar.additionalRequires' => $this->additionalRequires, + 'resources.external' => $this->externalResources, + 'platforms' => $this->platforms, + 'buildTypes' => $this->buildTypes, + ]; + } +} diff --git a/src/Build/GameBuilder.php b/src/Build/GameBuilder.php new file mode 100644 index 0000000..29a9ae5 --- /dev/null +++ b/src/Build/GameBuilder.php @@ -0,0 +1,240 @@ +config = $config; + $this->pharBuilder = new PharBuilder($config); + $this->staticPhpResolver = new StaticPhpResolver(); + $this->platformPackager = new PlatformPackager($config); + } + + /** + * @param callable(string, string): void $logger fn(level, message) + */ + public function setLogger(callable $logger): void + { + $this->logger = $logger; + $this->staticPhpResolver->setLogger(fn(string $msg) => $logger('info', $msg)); + } + + /** + * Run the full build pipeline. + * + * @return array{outputPath: string, pharSize: int, binarySize: int, bundleSize: int} + */ + public function build(string $platform, string $outputDir, ?string $microSfxPath = null, ?string $arch = null, string $variant = 'base', string $buildType = 'full'): array + { + $arch = $arch ?? StaticPhpResolver::detectArch(); + $suffix = $variant !== 'base' ? "-{$variant}" : ''; + if ($buildType !== 'full') { + $suffix .= "-{$buildType}"; + } + $platformOutputDir = $outputDir . '/' . $platform . '-' . $arch . $suffix; + + // Clean previous build output + if (is_dir($platformOutputDir)) { + $this->log('info', 'Cleaning previous build...'); + $this->removeDirectory($platformOutputDir); + } + mkdir($platformOutputDir, 0755, true); + + $tempDir = sys_get_temp_dir() . '/visu-build-' . $this->config->name . '-' . getmypid(); + + try { + // Phase 1: Prepare vendor (install --no-dev) + $this->log('info', 'Installing production dependencies...'); + $this->prepareVendor(); + + // Phase 2: Stage sources + $stagingDir = $tempDir . '/staging'; + $this->log('info', 'Staging sources...'); + $this->pharBuilder->stage($stagingDir); + $fileCount = $this->countFiles($stagingDir); + $this->log('success', "Staged {$fileCount} files"); + + // Phase 2b: Inject version and apply build type constant overrides + $this->applyBuildTypeConstants($stagingDir, ['GAME_VERSION' => $this->config->version]); + $this->log('info', "Set GAME_VERSION to {$this->config->version}"); + if ($buildType !== 'full' && isset($this->config->buildTypes[$buildType]['constants'])) { + $this->applyBuildTypeConstants($stagingDir, $this->config->buildTypes[$buildType]['constants']); + $this->log('info', "Applied build type '{$buildType}' constants"); + } + + // Phase 3: Create PHAR + $pharPath = $tempDir . '/' . strtolower($this->config->name) . '.phar'; + $this->log('info', 'Creating PHAR archive...'); + $this->pharBuilder->build($stagingDir, $pharPath); + $pharSize = filesize($pharPath); + $this->log('success', sprintf('PHAR created: %.2f MB', $pharSize / 1024 / 1024)); + + // Phase 4: Resolve static PHP binary + $this->log('info', 'Resolving micro.sfx binary...'); + $sfxPath = $this->staticPhpResolver->resolve($microSfxPath, $platform, $arch, $variant); + $this->log('success', 'Found micro.sfx: ' . $sfxPath); + + // Phase 5: Combine executable + $combinedPath = $tempDir . '/' . $this->config->name; + $this->log('info', 'Combining executable...'); + $this->combineExecutable($sfxPath, $pharPath, $combinedPath); + $binarySize = filesize($combinedPath); + $this->log('success', sprintf('Binary: %.2f MB', $binarySize / 1024 / 1024)); + + // Phase 6: Package for platform + $this->log('info', "Packaging for {$platform}..."); + $outputPath = $this->platformPackager->package($combinedPath, $platformOutputDir, $platform, $variant); + $this->log('success', 'Output: ' . $outputPath); + + // Phase 7: Report + $bundleSize = $this->getDirectorySize($outputPath); + + return [ + 'outputPath' => $outputPath, + 'pharSize' => (int) $pharSize, + 'binarySize' => (int) $binarySize, + 'bundleSize' => $bundleSize, + ]; + } finally { + // Cleanup temp dir + if (is_dir($tempDir)) { + $this->removeDirectory($tempDir); + } + + // Restore dev dependencies + $this->restoreVendor(); + } + } + + private function prepareVendor(): void + { + // Use --no-dev to exclude dev dependencies from the build. + // We run `composer update --no-dev` instead of `install --no-dev` + // because the lock file may contain dev-only platform requirements + // that would cause `install --no-dev` to fail. + $cmd = sprintf( + 'cd %s && composer update --no-dev --no-interaction --ignore-platform-reqs 2>&1', + escapeshellarg($this->config->projectRoot) + ); + exec($cmd, $output, $returnCode); + if ($returnCode !== 0) { + throw new \RuntimeException("composer update --no-dev failed:\n" . implode("\n", $output)); + } + } + + private function restoreVendor(): void + { + // Restore dev dependencies after build + $cmd = sprintf( + 'cd %s && composer update --no-interaction --ignore-platform-reqs 2>&1', + escapeshellarg($this->config->projectRoot) + ); + exec($cmd); + } + + private function combineExecutable(string $sfxPath, string $pharPath, string $outputPath): void + { + // Concatenate micro.sfx + game.phar into a single executable + $out = fopen($outputPath, 'wb'); + if ($out === false) { + throw new \RuntimeException("Failed to open output file: {$outputPath}"); + } + try { + foreach ([$sfxPath, $pharPath] as $inputFile) { + $in = fopen($inputFile, 'rb'); + if ($in === false) { + throw new \RuntimeException("Failed to open input file: {$inputFile}"); + } + stream_copy_to_stream($in, $out); + fclose($in); + } + } finally { + fclose($out); + } + chmod($outputPath, 0755); + } + + private function countFiles(string $dir): int + { + $count = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $_) { + $count++; + } + return $count; + } + + private function getDirectorySize(string $path): int + { + if (is_file($path)) { + return (int) filesize($path); + } + $size = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ($iterator as $file) { + $size += $file->getSize(); + } + return $size; + } + + private function removeDirectory(string $dir): void + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } + + /** + * Patch define() calls in bootstrap_constants.php with build-type overrides. + * + * @param array $constants + */ + private function applyBuildTypeConstants(string $stagingDir, array $constants): void + { + $file = $stagingDir . '/bootstrap_constants.php'; + if (!file_exists($file)) { + return; + } + + $content = file_get_contents($file); + if ($content === false) { + return; + } + foreach ($constants as $name => $value) { + $phpValue = var_export($value, true); + // Match: define('NAME', ) or define("NAME", ) + $pattern = "/(define\(\s*['\"]" . preg_quote($name, '/') . "['\"]\s*,\s*).+?(\))/"; + $replacement = '${1}' . $phpValue . '${2}'; + $content = preg_replace($pattern, $replacement, $content) ?? $content; + } + file_put_contents($file, $content); + } + + private function log(string $level, string $message): void + { + if ($this->logger) { + ($this->logger)($level, $message); + } + } +} diff --git a/src/Build/PharBuilder.php b/src/Build/PharBuilder.php new file mode 100644 index 0000000..5886f83 --- /dev/null +++ b/src/Build/PharBuilder.php @@ -0,0 +1,324 @@ +config = $config; + } + + /** + * Create staging directory with resolved symlinks and filtered content + */ + public function stage(string $stagingDir): void + { + if (is_dir($stagingDir)) { + $this->removeDirectory($stagingDir); + } + mkdir($stagingDir, 0755, true); + + $projectRoot = $this->config->projectRoot; + + // Stage vendor/ with symlink resolution and exclude filtering + $vendorSrc = $projectRoot . '/vendor'; + $vendorDst = $stagingDir . '/vendor'; + if (is_dir($vendorSrc)) { + $this->copyDirectoryFiltered($vendorSrc, $vendorDst, $this->config->pharExclude); + } + + // Stage src/ + $srcDir = $projectRoot . '/src'; + if (is_dir($srcDir)) { + $this->copyDirectory($srcDir, $stagingDir . '/src'); + } + + // Stage root PHP files and config + $rootFiles = ['bootstrap.php', 'bootstrap_constants.php', 'app.ctn', 'game.php']; + foreach ($rootFiles as $file) { + $path = $projectRoot . '/' . $file; + if (file_exists($path)) { + copy($path, $stagingDir . '/' . $file); + } + } + + // Stage entry file if different from defaults + $entry = $this->config->entry; + if (!in_array($entry, $rootFiles) && file_exists($projectRoot . '/' . $entry)) { + copy($projectRoot . '/' . $entry, $stagingDir . '/' . $entry); + } + + // Stage small resource directories (exclude external ones like audio) + $resourcesDir = $projectRoot . '/resources'; + if (is_dir($resourcesDir)) { + $external = array_map(function ($path) { + return basename($path); + }, $this->config->externalResources); + + $entries = scandir($resourcesDir); + if ($entries !== false) { + foreach ($entries as $item) { + if ($item === '.' || $item === '..') continue; + $itemPath = $resourcesDir . '/' . $item; + if (is_dir($itemPath) && !in_array($item, $external) && !in_array('resources/' . $item, $this->config->externalResources)) { + $this->copyDirectory($itemPath, $stagingDir . '/resources/' . $item); + } elseif (is_file($itemPath)) { + @mkdir($stagingDir . '/resources', 0755, true); + copy($itemPath, $stagingDir . '/resources/' . $item); + } + } + } + } + } + + /** + * Create PHAR from staged directory + */ + public function build(string $stagingDir, string $pharPath): void + { + if (file_exists($pharPath)) { + unlink($pharPath); + } + + $phar = new Phar($pharPath, 0, basename($pharPath)); + $phar->startBuffering(); + $phar->buildFromDirectory($stagingDir); + $phar->setStub($this->generateStub()); + $phar->stopBuffering(); + } + + /** + * Generate the PHAR stub programmatically. + * Handles micro SAPI, macOS .app bundles, VISU path constants, + * framework resource extraction, and additional requires. + */ + public function generateStub(): string + { + $additionalRequires = ''; + foreach ($this->config->additionalRequires as $require) { + $additionalRequires .= "\nrequire_once \$pharBase . '/{$require}';"; + } + + $runCode = ''; + if ($this->config->run !== '') { + $runCode = "\n" . $this->config->run; + } + + return <<<'STUB_START' + 'WARNING', + E_NOTICE, E_USER_NOTICE => 'NOTICE', + E_DEPRECATED, E_USER_DEPRECATED => 'DEPRECATED', + default => 'ERROR', + }; + $__engineLog("{$type}: {$message} in {$file}:{$line}"); + return false; // let PHP handle it too +}); +set_exception_handler(function(\Throwable $e) use ($__engineLog) { + $__engineLog("FATAL: Uncaught " . get_class($e) . ": " . $e->getMessage()); + $__engineLog(" in " . $e->getFile() . ":" . $e->getLine()); + $__engineLog(" Stack trace:\n" . $e->getTraceAsString()); +}); +register_shutdown_function(function() use ($__engineLog, $engineLogPath) { + $error = error_get_last(); + if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) { + $__engineLog("FATAL ERROR: {$error['message']} in {$error['file']}:{$error['line']}"); + } + $__engineLog("Engine shutdown."); +}); + +define('DS', DIRECTORY_SEPARATOR); +define('VISU_PATH_ROOT', $resourceBase); +define('VISU_PATH_CACHE', $resourceBase . DS . 'var' . DS . 'cache'); +define('VISU_PATH_STORE', $resourceBase . DS . 'var' . DS . 'store'); +define('VISU_PATH_RESOURCES', $resourceBase . DS . 'resources'); +define('VISU_PATH_APPCONFIG', $resourceBase); +// Vendor lives inside the PHAR +define('VISU_PATH_VENDOR', $pharBase . '/vendor'); +// Framework resources (fonts, shaders) need real filesystem access +define('VISU_PATH_FRAMEWORK_RESOURCES', $resourceBase . DS . 'visu-resources'); +define('VISU_PATH_FRAMEWORK_RESOURCES_SHADER', VISU_PATH_FRAMEWORK_RESOURCES . DS . 'shader'); +define('VISU_PATH_FRAMEWORK_RESOURCES_FONT', VISU_PATH_FRAMEWORK_RESOURCES . DS . 'fonts'); + +$__engineLog("Resource base: " . $resourceBase); +$__engineLog("PHAR base: " . $pharBase); + +@mkdir(VISU_PATH_CACHE, 0755, true); +@mkdir(VISU_PATH_STORE, 0755, true); +@mkdir(VISU_PATH_RESOURCES, 0755, true); + +// Extract visu framework resources (fonts, shaders) on first run +if (!is_dir(VISU_PATH_FRAMEWORK_RESOURCES)) { + $__engineLog("Extracting VISU framework resources..."); + $pharVisuRes = $pharBase . '/vendor/phpgl/visu/resources'; + @mkdir(VISU_PATH_FRAMEWORK_RESOURCES, 0755, true); + foreach (['fonts', 'shader'] as $subdir) { + $src = $pharVisuRes . '/' . $subdir; + $dstBase = VISU_PATH_FRAMEWORK_RESOURCES . DS . $subdir; + if (is_dir($src)) { + @mkdir($dstBase, 0755, true); + $srcLen = strlen($src); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $file) { + if (!$file->isFile()) continue; + $rel = substr($file->getPathname(), $srcLen + 1); + $target = $dstBase . DS . $rel; + @mkdir(dirname($target), 0755, true); + copy($file->getPathname(), $target); + } + } + } + $__engineLog("Framework resources extracted."); +} + +// Extract game resources (locales, shaders, etc.) from PHAR +// Always overwrite to ensure updates are applied. +$pharResources = $pharBase . '/resources'; +if (is_dir($pharResources)) { + $resIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($pharResources, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + $pharResLen = strlen($pharResources); + foreach ($resIterator as $resItem) { + $relPath = substr($resItem->getPathname(), $pharResLen + 1); + $targetPath = VISU_PATH_RESOURCES . DS . $relPath; + if ($resItem->isDir()) { + @mkdir($targetPath, 0755, true); + } else { + @mkdir(dirname($targetPath), 0755, true); + copy($resItem->getPathname(), $targetPath); + } + } +} + +// Extract app.ctn if not present +$appCtn = $resourceBase . '/app.ctn'; +if (!file_exists($appCtn)) { + $pharAppCtn = $pharBase . '/app.ctn'; + if (file_exists($pharAppCtn)) { + file_put_contents($appCtn, file_get_contents($pharAppCtn)); + $__engineLog("Extracted app.ctn"); + } +} + +// Ensure container_map.php exists in vendor (write to resource base) +$containerMapFile = $resourceBase . DS . 'vendor' . DS . 'container_map.php'; +if (!file_exists($containerMapFile)) { + @mkdir(dirname($containerMapFile), 0755, true); + file_put_contents($containerMapFile, " $excludePatterns Glob patterns (e.g. "tests", "docs") + */ + private function copyDirectoryFiltered(string $src, string $dst, array $excludePatterns): void + { + @mkdir($dst, 0755, true); + // Normalize exclude patterns: strip leading **/ for simple basename matching + $excludes = array_map(fn(string $p) => str_replace('**/', '', $p), $excludePatterns); + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS), + RecursiveIteratorIterator::SELF_FIRST + ); + + $srcLen = strlen($src); + foreach ($iterator as $item) { + $relPath = substr($item->getPathname(), $srcLen + 1); + + // Check if any path segment matches an exclude pattern + $skip = false; + foreach ($excludes as $exclude) { + if (fnmatch($exclude, $relPath) || fnmatch('*/' . $exclude, $relPath) || fnmatch($exclude, basename($relPath))) { + $skip = true; + break; + } + } + if ($skip) { + continue; + } + + $target = $dst . '/' . $relPath; + if ($item->isDir()) { + @mkdir($target, 0755, true); + } else { + @mkdir(dirname($target), 0755, true); + copy($item->getRealPath() ?: $item->getPathname(), $target); + } + } + } + + private function copyDirectory(string $src, string $dst): void + { + @mkdir($dst, 0755, true); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) { + $target = $dst . '/' . substr($item->getPathname(), strlen($src) + 1); + if ($item->isDir()) { + @mkdir($target, 0755, true); + } else { + @mkdir(dirname($target), 0755, true); + copy($item->getPathname(), $target); + } + } + } + + private function removeDirectory(string $dir): void + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } +} diff --git a/src/Build/PlatformPackager.php b/src/Build/PlatformPackager.php new file mode 100644 index 0000000..7fb1e76 --- /dev/null +++ b/src/Build/PlatformPackager.php @@ -0,0 +1,188 @@ +config = $config; + } + + /** + * Package the combined binary for the target platform + * + * @return string Path to the output directory/bundle + */ + public function package(string $binaryPath, string $outputDir, string $platform, string $variant = 'base'): string + { + return match ($platform) { + 'macos' => $this->packageMacOS($binaryPath, $outputDir, $variant), + 'windows' => $this->packageFlat($binaryPath, $outputDir, '.exe', 'windows', $variant), + 'linux' => $this->packageFlat($binaryPath, $outputDir, '', 'linux', $variant), + default => throw new \RuntimeException("Unsupported platform: {$platform}"), + }; + } + + private function packageMacOS(string $binaryPath, string $outputDir, string $variant = 'base'): string + { + $name = $this->config->name; + $appDir = $outputDir . "/{$name}.app"; + $contentsDir = $appDir . '/Contents'; + $macosDir = $contentsDir . '/MacOS'; + $resourcesDir = $contentsDir . '/Resources'; + + // Create bundle structure + foreach ([$macosDir, $resourcesDir] as $dir) { + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + } + + // Copy binary + copy($binaryPath, $macosDir . '/' . $name); + chmod($macosDir . '/' . $name, 0755); + + // Copy bundle libs (e.g. libsteam_api.dylib) next to binary + $this->copyBundleLibs($macosDir, 'macos'); + + // Generate Info.plist + $this->writeInfoPlist($contentsDir); + + // Copy external resources to Resources/ + $this->copyExternalResources($resourcesDir); + + // Copy icon if configured + $macosConfig = $this->config->platforms['macos'] ?? []; + if (isset($macosConfig['icon'])) { + $iconPath = $this->config->projectRoot . '/' . $macosConfig['icon']; + if (file_exists($iconPath)) { + copy($iconPath, $resourcesDir . '/' . basename($iconPath)); + } + } + + return $appDir; + } + + private function packageFlat(string $binaryPath, string $outputDir, string $extension, string $platform, string $variant = 'base'): string + { + $name = $this->config->name; + $dir = $outputDir . '/' . $name; + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $targetName = $name . $extension; + copy($binaryPath, $dir . '/' . $targetName); + chmod($dir . '/' . $targetName, 0755); + + // Copy bundle libs next to binary + $this->copyBundleLibs($dir, $platform); + + // Copy external resources alongside binary + $this->copyExternalResources($dir); + + return $dir; + } + + private function writeInfoPlist(string $contentsDir): void + { + $name = $this->config->name; + $identifier = $this->config->identifier; + $version = $this->config->version; + $macosConfig = $this->config->platforms['macos'] ?? []; + $minVersion = $macosConfig['minimumVersion'] ?? '12.0'; + + $iconFile = ''; + if (isset($macosConfig['icon'])) { + $iconFile = basename($macosConfig['icon']); + } + + $plist = << + + + + CFBundleName + {$name} + CFBundleDisplayName + {$name} + CFBundleIdentifier + {$identifier} + CFBundleVersion + {$version} + CFBundleShortVersionString + {$version} + CFBundleExecutable + {$name} + CFBundlePackageType + APPL + CFBundleIconFile + {$iconFile} + LSMinimumSystemVersion + {$minVersion} + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + +XML; + + file_put_contents($contentsDir . '/Info.plist', $plist); + } + + private function copyBundleLibs(string $targetDir, string $platform): void + { + $libs = $this->config->bundleLibs[$platform] ?? []; + foreach ($libs as $lib) { + $src = $lib['src']; + $optional = $lib['optional'] ?? false; + + // Resolve relative paths against project root + if ($src !== '' && $src[0] !== '/') { + $src = $this->config->projectRoot . '/' . $src; + } + + if (!file_exists($src)) { + if ($optional) { + continue; + } + throw new \RuntimeException("Bundle lib not found: {$src}"); + } + copy($src, $targetDir . '/' . basename($src)); + } + } + + private function copyExternalResources(string $targetDir): void + { + foreach ($this->config->externalResources as $resourcePath) { + $src = $this->config->projectRoot . '/' . $resourcePath; + if (!is_dir($src)) continue; + + $dst = $targetDir . '/' . $resourcePath; + $this->copyDirectory($src, $dst); + } + } + + private function copyDirectory(string $src, string $dst): void + { + @mkdir($dst, 0755, true); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) { + $target = $dst . '/' . substr($item->getPathname(), strlen($src) + 1); + if ($item->isDir()) { + @mkdir($target, 0755, true); + } else { + @mkdir(dirname($target), 0755, true); + copy($item->getPathname(), $target); + } + } + } +} diff --git a/src/Build/StaticPhpResolver.php b/src/Build/StaticPhpResolver.php new file mode 100644 index 0000000..c7bb4bf --- /dev/null +++ b/src/Build/StaticPhpResolver.php @@ -0,0 +1,286 @@ +cacheDir = $home . '/.visu/build-cache'; + } + + /** + * @param callable(string): void $logger + */ + public function setLogger(callable $logger): void + { + $this->logger = $logger; + } + + /** + * Resolve a micro.sfx binary path. + * Priority: explicit path > cached > download from GitHub Release + */ + public function resolve(?string $explicitPath, string $platform, string $arch, string $variant = 'base'): string + { + // 1. Explicit path from CLI + if ($explicitPath !== null) { + if (!file_exists($explicitPath)) { + throw new \RuntimeException("micro.sfx not found at: {$explicitPath}"); + } + return $explicitPath; + } + + // 2. Check cache (variant-specific, then fallback to base) + $cacheKey = $variant !== 'base' ? "{$platform}-{$arch}-{$variant}" : "{$platform}-{$arch}"; + $cachedPath = $this->cacheDir . "/{$cacheKey}/micro.sfx"; + if (file_exists($cachedPath)) { + return $cachedPath; + } + + // 3. Download from GitHub Release + $this->log("No cached micro.sfx for {$cacheKey}, checking GitHub releases..."); + $downloaded = $this->downloadFromRelease($platform, $arch, $variant); + if ($downloaded !== null) { + return $downloaded; + } + + throw new \RuntimeException( + "No micro.sfx binary found for {$variant}/{$platform}-{$arch}.\n\n" . + "Options:\n" . + " 1. Provide one with --micro-sfx \n" . + " 2. Trigger the 'Build Game micro.sfx' workflow in hmennen90/static-php-cli\n" . + " 3. Cache a pre-built binary:\n" . + " mkdir -p ~/.visu/build-cache/{$cacheKey}\n" . + " cp /path/to/micro.sfx ~/.visu/build-cache/{$cacheKey}/micro.sfx" + ); + } + + /** + * Download micro.sfx from the latest GitHub Release. + * + * @param string $variant "base" or "steam" + */ + private function downloadFromRelease(string $platform, string $arch, string $variant = 'base'): ?string + { + // Map platform/arch to static-php-cli naming convention + $osName = match (true) { + $platform === 'macos' && $arch === 'arm64' => 'macos-aarch64', + $platform === 'macos' && $arch === 'x86_64' => 'macos-x86_64', + $platform === 'linux' && $arch === 'arm64' => 'linux-aarch64', + $platform === 'linux' && $arch === 'x86_64' => 'linux-x86_64', + $platform === 'windows' => 'windows-x86_64', + default => "{$platform}-{$arch}", + }; + + // Find latest release with micro.sfx assets + $releaseUrl = $this->findLatestRuntimeRelease(); + if ($releaseUrl === null) { + $this->log("No runtime releases found on GitHub"); + return null; + } + + // Find matching asset by searching release assets with flexible pattern + // Assets are named: micro-sfx-{variant}-{phpVersion}-{osName}.zip + $downloadUrl = null; + $matchedAsset = null; + $prefix = "micro-sfx-{$variant}-"; + $suffix = "-{$osName}.zip"; + + $json = $this->httpGet($releaseUrl); + $release = $json !== null ? json_decode($json, true) : null; + if (is_array($release) && isset($release['assets'])) { + foreach ($release['assets'] as $asset) { + $name = $asset['name'] ?? ''; + if (str_starts_with($name, $prefix) && str_ends_with($name, $suffix)) { + $downloadUrl = $asset['browser_download_url'] ?? null; + $matchedAsset = $name; + break; + } + } + } + + if ($downloadUrl === null) { + $this->log("No matching micro.sfx asset found for {$variant}/{$osName}"); + return null; + } + + // Download and cache + $this->log("Downloading {$matchedAsset}..."); + $tempFile = tempnam(sys_get_temp_dir(), 'visu-micro-'); + if ($tempFile === false) { + return null; + } + + $content = $this->httpGet($downloadUrl); + if ($content === null) { + @unlink($tempFile); + return null; + } + + file_put_contents($tempFile, $content); + + // If it's a zip, extract micro.sfx from it + $actualFile = $tempFile; + if (str_ends_with($matchedAsset, '.zip') && class_exists(\ZipArchive::class)) { + $zip = new \ZipArchive(); + if ($zip->open($tempFile) === true) { + $extractDir = sys_get_temp_dir() . '/visu-micro-extract-' . getmypid(); + $zip->extractTo($extractDir); + $zip->close(); + // Find micro.sfx inside + foreach (['micro.sfx', 'micro.sfx.exe'] as $binName) { + $candidate = $extractDir . '/' . $binName; + if (!file_exists($candidate)) { + $candidate = $extractDir . '/buildroot/bin/' . $binName; + } + if (file_exists($candidate)) { + $actualFile = $candidate; + break; + } + } + } + } + + $cacheKey = $variant !== 'base' ? "{$platform}-{$arch}-{$variant}" : "{$platform}-{$arch}"; + $cachedDir = $this->cacheDir . "/{$cacheKey}"; + if (!is_dir($cachedDir)) { + mkdir($cachedDir, 0755, true); + } + $cachedPath = $cachedDir . '/micro.sfx'; + copy($actualFile, $cachedPath); + chmod($cachedPath, 0755); + + // Cleanup + @unlink($tempFile); + if (isset($extractDir) && is_dir($extractDir)) { + $this->removeDir($extractDir); + } + + $size = filesize($cachedPath); + $this->log(sprintf("Downloaded and cached: %s (%.1f MB)", $cachedPath, $size / 1024 / 1024)); + + return $cachedPath; + } + + /** + * Find the latest GitHub Release that contains micro.sfx assets. + */ + private function findLatestRuntimeRelease(): ?string + { + $url = "https://api.github.com/repos/" . self::GITHUB_REPO . "/releases"; + $json = $this->httpGet($url); + if ($json === null) { + return null; + } + + $releases = json_decode($json, true); + if (!is_array($releases)) { + return null; + } + + foreach ($releases as $release) { + if (!isset($release['tag_name'], $release['url'])) { + continue; + } + if (!empty($release['assets'])) { + foreach ($release['assets'] as $asset) { + if (str_contains($asset['name'] ?? '', 'micro')) { + return $release['url']; + } + } + } + } + + return null; + } + + private function removeDir(string $dir): void + { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($dir); + } + + /** + * HTTP GET with proper headers for GitHub API + */ + private function httpGet(string $url): ?string + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => [ + 'User-Agent: VISU-Build/1.0', + 'Accept: application/vnd.github+json', + ], + 'timeout' => 30, + 'follow_location' => true, + ], + ]); + + $result = @file_get_contents($url, false, $context); + return $result !== false ? $result : null; + } + + /** + * Cache a micro.sfx binary for future use + */ + public function cache(string $sourcePath, string $platform, string $arch): string + { + $dir = $this->cacheDir . "/{$platform}-{$arch}"; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $target = $dir . '/micro.sfx'; + copy($sourcePath, $target); + chmod($target, 0755); + + return $target; + } + + /** + * Detect current platform + */ + public static function detectPlatform(): string + { + return match (PHP_OS_FAMILY) { + 'Darwin' => 'macos', + 'Windows' => 'windows', + default => 'linux', + }; + } + + /** + * Detect current architecture + */ + public static function detectArch(): string + { + $uname = php_uname('m'); + return match (true) { + str_contains($uname, 'arm64'), str_contains($uname, 'aarch64') => 'arm64', + default => 'x86_64', + }; + } + + private function log(string $message): void + { + if ($this->logger) { + ($this->logger)($message); + } + } +} diff --git a/src/Command/BuildCommand.php b/src/Command/BuildCommand.php new file mode 100644 index 0000000..9447b02 --- /dev/null +++ b/src/Command/BuildCommand.php @@ -0,0 +1,227 @@ + ['platform' => 'macos', 'arch' => 'arm64'], + 'linux-x86_64' => ['platform' => 'linux', 'arch' => 'x86_64'], + 'windows-x86_64' => ['platform' => 'windows', 'arch' => 'x86_64'], + ]; + + /** + * @var array> + */ + protected $expectedArguments = [ + 'platform' => [ + 'description' => 'Target: macos-arm64, linux-x86_64, linux-arm64, windows-x86_64, macos, linux, windows, all (default: auto-detect)', + 'defaultValue' => '', + ], + 'dry-run' => [ + 'longPrefix' => 'dry-run', + 'description' => 'Show resolved configuration without building', + 'noValue' => true, + ], + 'micro-sfx' => [ + 'longPrefix' => 'micro-sfx', + 'description' => 'Path to a pre-built micro.sfx binary', + 'defaultValue' => '', + ], + 'output' => [ + 'prefix' => 'o', + 'longPrefix' => 'output', + 'description' => 'Output directory (default: build/)', + 'defaultValue' => '', + ], + 'variant' => [ + 'longPrefix' => 'variant', + 'description' => 'Build variant: base or steam (default: base)', + 'defaultValue' => 'base', + ], + 'type' => [ + 'longPrefix' => 'type', + 'description' => 'Build type as defined in build.json buildTypes (default: full)', + 'defaultValue' => 'full', + ], + ]; + + public function execute(): void + { + $projectRoot = VISU_PATH_ROOT; + $config = BuildConfig::load($projectRoot); + + $outputArg = (string) $this->cli->arguments->get('output'); + $outputDir = $outputArg !== '' ? $outputArg : $projectRoot . '/build'; + + $microSfxArg = (string) $this->cli->arguments->get('micro-sfx'); + $microSfxPath = $microSfxArg !== '' ? $microSfxArg : null; + + $variant = (string) $this->cli->arguments->get('variant'); + if (!in_array($variant, ['base', 'steam'], true)) { + $this->cli->out("Error: Unknown variant '{$variant}'. Use 'base' or 'steam'."); + return; + } + + $buildType = (string) $this->cli->arguments->get('type'); + if ($buildType !== 'full' && !isset($config->buildTypes[$buildType])) { + $available = empty($config->buildTypes) ? '(none defined)' : implode(', ', array_keys($config->buildTypes)); + $this->cli->out("Error: Unknown build type '{$buildType}'. Available: full, {$available}"); + return; + } + + $targets = $this->resolveTargets((string) $this->cli->arguments->get('platform')); + + if ($this->cli->arguments->defined('dry-run')) { + $this->dryRun($config, $targets, $outputDir, $microSfxPath); + return; + } + + if (ini_get('phar.readonly')) { + $this->cli->out('Error: phar.readonly is enabled. Run with:'); + $this->cli->out(' php -d phar.readonly=0 vendor/bin/visu build'); + return; + } + + $builder = new GameBuilder($config); + $builder->setLogger(function (string $level, string $message): void { + match ($level) { + 'success' => $this->success($message), + 'info' => $this->info($message), + default => $this->cli->out($message), + }; + }); + + $results = []; + foreach ($targets as $targetName => $target) { + $this->cli->out(''); + $this->cli->out('VISU Game Builder'); + $this->cli->out(str_repeat('-', 50)); + $this->cli->out(" Game: {$config->name} v{$config->version}"); + $this->cli->out(" Target: {$targetName}"); + $this->cli->out(" Variant: {$variant}"); + $this->cli->out(" Type: {$buildType}"); + $this->cli->out(" Output: {$outputDir}"); + $this->cli->out(str_repeat('-', 50)); + $this->cli->out(''); + + try { + $result = $builder->build( + $target['platform'], + $outputDir, + $microSfxPath, + $target['arch'], + $variant, + $buildType, + ); + $results[$targetName] = $result; + + $this->cli->out(''); + $this->success("{$targetName} complete!"); + $this->cli->out(sprintf(' PHAR: %.2f MB', $result['pharSize'] / 1024 / 1024)); + $this->cli->out(sprintf(' Binary: %.2f MB', $result['binarySize'] / 1024 / 1024)); + $this->cli->out(sprintf(' Total: %.2f MB', $result['bundleSize'] / 1024 / 1024)); + $this->cli->out(' Output: ' . $result['outputPath']); + } catch (\Throwable $e) { + $this->cli->out(''); + $this->cli->out("{$targetName} failed: " . $e->getMessage()); + if ($this->verbose) { + $this->cli->out($e->getTraceAsString()); + } + } + } + + if (count($results) > 0) { + $this->cli->out(''); + $this->cli->out(str_repeat('=', 50)); + $this->success(sprintf('Built %d/%d targets', count($results), count($targets))); + foreach ($results as $name => $r) { + $this->cli->out(sprintf(' %-20s %.2f MB %s', $name, $r['bundleSize'] / 1024 / 1024, $r['outputPath'])); + } + $this->cli->out(str_repeat('=', 50)); + } + } + + /** + * Resolve platform argument to list of build targets. + * + * @return array + */ + private function resolveTargets(string $platformArg): array + { + if ($platformArg === '' || $platformArg === 'auto') { + $platform = StaticPhpResolver::detectPlatform(); + $arch = StaticPhpResolver::detectArch(); + $key = "{$platform}-{$arch}"; + return [$key => ['platform' => $platform, 'arch' => $arch]]; + } + + if ($platformArg === 'all') { + return self::TARGETS; + } + + // Exact target match: macos-arm64, linux-x86_64, etc. + if (isset(self::TARGETS[$platformArg])) { + return [$platformArg => self::TARGETS[$platformArg]]; + } + + // Platform-only match: "macos" → all macos targets, "linux" → all linux targets + $matched = []; + foreach (self::TARGETS as $name => $target) { + if ($target['platform'] === $platformArg) { + $matched[$name] = $target; + } + } + + if (!empty($matched)) { + return $matched; + } + + throw new \RuntimeException( + "Unknown target: {$platformArg}\n" . + "Available: " . implode(', ', array_keys(self::TARGETS)) . ", macos, linux, windows, all" + ); + } + + /** + * @param array $targets + */ + private function dryRun(BuildConfig $config, array $targets, string $outputDir, ?string $microSfxPath): void + { + $this->cli->out(''); + $this->cli->out('VISU Build — Dry Run'); + $this->cli->out(str_repeat('-', 50)); + + $data = $config->toArray(); + $data['output'] = $outputDir; + $data['micro-sfx'] = $microSfxPath ?? '(auto-resolve)'; + $data['targets'] = implode(', ', array_keys($targets)); + + foreach ($data as $key => $value) { + if (is_array($value)) { + $this->cli->out(sprintf(' %-25s %s', $key, json_encode($value))); + } else { + $this->cli->out(sprintf(' %-25s %s', $key, $value)); + } + } + + $this->cli->out(''); + $resolver = new StaticPhpResolver(); + foreach ($targets as $name => $target) { + try { + $sfxPath = $resolver->resolve($microSfxPath, $target['platform'], $target['arch']); + $this->success("{$name}: micro.sfx found at {$sfxPath}"); + } catch (\RuntimeException $e) { + $this->cli->out("{$name}: " . explode("\n", $e->getMessage())[0]); + } + } + + $this->cli->out(str_repeat('-', 50)); + } +} diff --git a/src/Command/SetupCommand.php b/src/Command/SetupCommand.php new file mode 100644 index 0000000..27c8352 --- /dev/null +++ b/src/Command/SetupCommand.php @@ -0,0 +1,44 @@ +> + */ + protected $expectedArguments = [ + 'non-interactive' => [ + 'prefix' => 'n', + 'longPrefix' => 'non-interactive', + 'description' => 'Skip prompts and create all missing files automatically.', + 'noValue' => true, + ], + ]; + + public function execute(): void + { + $interactive = !$this->cli->arguments->get('non-interactive'); + + $setup = new ProjectSetup( + projectRoot: VISU_PATH_ROOT, + interactive: $interactive, + output: function (string $line): void { + $this->cli->out($line); + }, + confirm: function (string $question): bool { + $input = $this->cli->input($question . ' [Y/n]'); + $answer = trim((string) $input->prompt()); + return $answer === '' || strtolower($answer) === 'y'; + }, + ); + + $setup->run(); + } +} diff --git a/src/Command/TranspileCommand.php b/src/Command/TranspileCommand.php new file mode 100644 index 0000000..f271c1e --- /dev/null +++ b/src/Command/TranspileCommand.php @@ -0,0 +1,200 @@ +> + */ + protected $expectedArguments = [ + 'force' => [ + 'prefix' => 'f', + 'longPrefix' => 'force', + 'description' => 'Force re-transpile all files, ignoring the hash registry', + 'noValue' => true, + ], + 'scenes' => [ + 'longPrefix' => 'scenes', + 'description' => 'Directory containing scene JSON files (default: resources/scenes)', + 'defaultValue' => '', + ], + 'ui' => [ + 'longPrefix' => 'ui', + 'description' => 'Directory containing UI JSON files (default: resources/ui)', + 'defaultValue' => '', + ], + 'prefabs' => [ + 'longPrefix' => 'prefabs', + 'description' => 'Directory containing prefab JSON files (default: resources/prefabs)', + 'defaultValue' => '', + ], + 'output' => [ + 'prefix' => 'o', + 'longPrefix' => 'output', + 'description' => 'Output directory for generated PHP files (default: var/cache/transpiled)', + 'defaultValue' => '', + ], + ]; + + public function __construct( + private ComponentRegistry $componentRegistry, + ) { + } + + public function execute(): void + { + $resourcesDir = VISU_PATH_RESOURCES; + $cacheDir = VISU_PATH_CACHE; + + $scenesArg = (string) $this->cli->arguments->get('scenes'); + $uiArg = (string) $this->cli->arguments->get('ui'); + $prefabsArg = (string) $this->cli->arguments->get('prefabs'); + $outputArg = (string) $this->cli->arguments->get('output'); + + $scenesDir = $scenesArg !== '' ? $scenesArg : $resourcesDir . '/scenes'; + $uiDir = $uiArg !== '' ? $uiArg : $resourcesDir . '/ui'; + $prefabsDir = $prefabsArg !== '' ? $prefabsArg : $resourcesDir . '/prefabs'; + $outputDir = $outputArg !== '' ? $outputArg : $cacheDir . '/transpiled'; + $force = $this->cli->arguments->defined('force'); + + $registry = new TranspilerRegistry($cacheDir); + + $sceneTranspiler = new SceneTranspiler($this->componentRegistry); + $uiTranspiler = new UITranspiler(); + $prefabTranspiler = new PrefabTranspiler($this->componentRegistry); + + $stats = ['transpiled' => 0, 'skipped' => 0, 'errors' => 0]; + + // Transpile scenes + if (is_dir($scenesDir)) { + $this->info("Scanning scenes: {$scenesDir}"); + $this->transpileDirectory( + $scenesDir, + $outputDir . '/Scenes', + 'VISU\\Generated\\Scenes', + $sceneTranspiler, + $registry, + $force, + $stats, + ); + } else { + $this->info("Scenes directory not found: {$scenesDir}", true); + } + + // Transpile UI layouts + if (is_dir($uiDir)) { + $this->info("Scanning UI layouts: {$uiDir}"); + $this->transpileDirectory( + $uiDir, + $outputDir . '/UI', + 'VISU\\Generated\\UI', + $uiTranspiler, + $registry, + $force, + $stats, + ); + } else { + $this->info("UI directory not found: {$uiDir}", true); + } + + // Transpile prefabs + if (is_dir($prefabsDir)) { + $this->info("Scanning prefabs: {$prefabsDir}"); + $this->transpileDirectory( + $prefabsDir, + $outputDir . '/Prefabs', + 'VISU\\Generated\\Prefabs', + $prefabTranspiler, + $registry, + $force, + $stats, + ); + } else { + $this->info("Prefabs directory not found: {$prefabsDir}", true); + } + + $registry->save(); + + $this->cli->out(''); + if ($stats['errors'] > 0) { + $this->cli->out(sprintf( + '[done] %d transpiled, %d skipped, %d errors', + $stats['transpiled'], + $stats['skipped'], + $stats['errors'], + )); + } else { + $this->success(sprintf( + '%d transpiled, %d skipped, 0 errors', + $stats['transpiled'], + $stats['skipped'], + ), false, 'done'); + } + } + + /** + * @param SceneTranspiler|UITranspiler|PrefabTranspiler $transpiler + * @param array{transpiled: int, skipped: int, errors: int} $stats + */ + private function transpileDirectory( + string $sourceDir, + string $outputDir, + string $namespace, + object $transpiler, + TranspilerRegistry $registry, + bool $force, + array &$stats, + ): void { + $files = glob($sourceDir . '/*.json'); + if ($files === false || count($files) === 0) { + $this->info(' No JSON files found.', true); + return; + } + + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + foreach ($files as $jsonPath) { + $baseName = pathinfo($jsonPath, PATHINFO_FILENAME); + $className = $this->toClassName($baseName); + $outputPath = $outputDir . '/' . $className . '.php'; + + if (!$force && !$registry->needsUpdate($jsonPath)) { + $this->info(" skip {$baseName} (unchanged)", true); + $stats['skipped']++; + continue; + } + + try { + $code = $transpiler->transpile($jsonPath, $className, $namespace); + file_put_contents($outputPath, $code); + $registry->record($jsonPath, $outputPath); + $this->success(" {$baseName} -> {$className}.php"); + $stats['transpiled']++; + } catch (\Throwable $e) { + $this->cli->out("[error] {$baseName}: {$e->getMessage()}"); + $stats['errors']++; + } + } + } + + /** + * Converts a file basename like "office_level1" to a PascalCase class name "OfficeLevel1". + */ + private function toClassName(string $baseName): string + { + // Replace non-alphanumeric with space, then ucwords, then remove spaces + $cleaned = preg_replace('/[^a-zA-Z0-9]+/', ' ', $baseName) ?? $baseName; + return str_replace(' ', '', ucwords($cleaned)); + } +} diff --git a/src/Command/WorldEditorCommand.php b/src/Command/WorldEditorCommand.php new file mode 100644 index 0000000..ffde60b --- /dev/null +++ b/src/Command/WorldEditorCommand.php @@ -0,0 +1,119 @@ + [ + 'prefix' => 'H', + 'longPrefix' => 'host', + 'description' => 'Host to bind the development server to', + 'defaultValue' => '127.0.0.1', + ], + 'port' => [ + 'prefix' => 'p', + 'longPrefix' => 'port', + 'description' => 'Port to bind the development server to', + 'defaultValue' => 8765, + 'castTo' => 'int', + ], + 'ws-port' => [ + 'longPrefix' => 'ws-port', + 'description' => 'Port for the WebSocket server (live-preview)', + 'defaultValue' => 8766, + 'castTo' => 'int', + ], + 'no-ws' => [ + 'longPrefix' => 'no-ws', + 'description' => 'Disable the WebSocket server', + 'noValue' => true, + ], + ]; + + public function execute(): void + { + $host = (string) $this->cli->arguments->get('host'); + $port = (int) $this->cli->arguments->get('port'); + $wsPort = (int) $this->cli->arguments->get('ws-port'); + $noWs = $this->cli->arguments->defined('no-ws'); + + $worldsDir = VISU_PATH_ROOT . '/worlds'; + if (!is_dir($worldsDir)) { + mkdir($worldsDir, 0755, true); + } + + $distDir = __DIR__ . '/../../resources/editor/dist'; + $routerFile = __DIR__ . '/../WorldEditor/WorldEditorRouter.php'; + + if (!is_dir($distDir)) { + $this->cli->error('Editor assets not found at: ' . realpath(__DIR__ . '/../../resources/editor/dist')); + $this->cli->out('Run cd editor && npm install && npm run build first.'); + return; + } + + $url = "http://{$host}:{$port}"; + + $this->info("Starting world editor at {$url}"); + $this->info("Worlds directory: {$worldsDir}"); + + // Start WebSocket server in a background process + $wsProcess = null; + if (!$noWs) { + $wsScript = __DIR__ . '/../WorldEditor/WebSocket/ws_server.php'; + if (file_exists($wsScript)) { + $wsCmd = sprintf( + 'php %s %s %d &', + escapeshellarg($wsScript), + escapeshellarg($host), + $wsPort + ); + $wsProcess = @popen($wsCmd, 'r'); + $this->info("WebSocket server at ws://{$host}:{$wsPort}"); + } else { + $this->info("WebSocket server script not found, skipping.", true); + } + } + + $this->cli->out('Press Ctrl+C to stop the server.'); + + // Pass config to the router via environment variables + $resourcesDir = defined('VISU_PATH_RESOURCES') ? VISU_PATH_RESOURCES : getcwd() . '/resources'; + $cacheDir = defined('VISU_PATH_CACHE') ? VISU_PATH_CACHE : getcwd() . '/var/cache'; + + putenv("VISU_WORLDS_DIR={$worldsDir}"); + putenv("VISU_EDITOR_DIST={$distDir}"); + putenv("VISU_PATH_RESOURCES={$resourcesDir}"); + putenv("VISU_PATH_CACHE={$cacheDir}"); + + // Try to open browser + $os = PHP_OS_FAMILY; + if ($os === 'Darwin') { + exec("open {$url} &"); + } elseif ($os === 'Linux') { + exec("xdg-open {$url} > /dev/null 2>&1 &"); + } elseif ($os === 'Windows') { + exec("start {$url}"); + } + + $cmd = sprintf( + 'VISU_WORLDS_DIR=%s VISU_EDITOR_DIST=%s VISU_PATH_RESOURCES=%s VISU_PATH_CACHE=%s php -S %s:%d %s', + escapeshellarg($worldsDir), + escapeshellarg($distDir), + escapeshellarg($resourcesDir), + escapeshellarg($cacheDir), + $host, + $port, + escapeshellarg($routerFile) + ); + + passthru($cmd); + + // Cleanup WebSocket process + if ($wsProcess !== null && $wsProcess !== false) { + pclose($wsProcess); + } + } +} diff --git a/src/Component/BehaviourTreeComponent.php b/src/Component/BehaviourTreeComponent.php new file mode 100644 index 0000000..78b41c7 --- /dev/null +++ b/src/Component/BehaviourTreeComponent.php @@ -0,0 +1,23 @@ + Blackboard data persisted across ticks + */ + public array $blackboard = []; + + public BTStatus $lastStatus = BTStatus::Success; + + public bool $enabled = true; + + public function __construct( + public ?BTNode $root = null, + ) { + } +} diff --git a/src/Component/BoxCollider2D.php b/src/Component/BoxCollider2D.php new file mode 100644 index 0000000..37b9c50 --- /dev/null +++ b/src/Component/BoxCollider2D.php @@ -0,0 +1,33 @@ +halfExtents = $halfExtents ?? new Vec3(0.5, 0.5, 0.5); + $this->offset = $offset ?? new Vec3(0.0, 0.0, 0.0); + } +} diff --git a/src/Component/Camera3DComponent.php b/src/Component/Camera3DComponent.php new file mode 100644 index 0000000..d87bb01 --- /dev/null +++ b/src/Component/Camera3DComponent.php @@ -0,0 +1,117 @@ +orbitTarget = new Vec3(0.0, 0.0, 0.0); + } +} diff --git a/src/Component/Camera3DMode.php b/src/Component/Camera3DMode.php new file mode 100644 index 0000000..ed1da1e --- /dev/null +++ b/src/Component/Camera3DMode.php @@ -0,0 +1,10 @@ +radius = $radius; + $this->halfHeight = $halfHeight; + $this->offset = $offset ?? new Vec3(0.0, 0.0, 0.0); + } +} diff --git a/src/Component/CircleCollider2D.php b/src/Component/CircleCollider2D.php new file mode 100644 index 0000000..155c3ea --- /dev/null +++ b/src/Component/CircleCollider2D.php @@ -0,0 +1,32 @@ +modelIdentifier = $modelIdentifier; + } +} diff --git a/src/Component/NameComponent.php b/src/Component/NameComponent.php new file mode 100644 index 0000000..6e6fc03 --- /dev/null +++ b/src/Component/NameComponent.php @@ -0,0 +1,11 @@ +direction = new Vec3(0.0, 1.0, 0.0); + $this->boxHalfExtents = new Vec3(1.0, 1.0, 1.0); + $this->startColor = new Vec4(1.0, 1.0, 1.0, 1.0); + $this->endColor = new Vec4(1.0, 1.0, 1.0, 0.0); + } +} diff --git a/src/Component/ParticleEmitterShape.php b/src/Component/ParticleEmitterShape.php new file mode 100644 index 0000000..20fcece --- /dev/null +++ b/src/Component/ParticleEmitterShape.php @@ -0,0 +1,11 @@ +color = $color ?? new Vec3(1.0, 1.0, 1.0); + $this->intensity = $intensity; + $this->range = $range; + } + + /** + * Sets physically-based attenuation from the range value + */ + public function setAttenuationFromRange(): void + { + $this->constantAttenuation = 1.0; + $this->linearAttenuation = 4.5 / $this->range; + $this->quadraticAttenuation = 75.0 / ($this->range * $this->range); + } +} diff --git a/src/Component/RigidBody3D.php b/src/Component/RigidBody3D.php new file mode 100644 index 0000000..e260fa2 --- /dev/null +++ b/src/Component/RigidBody3D.php @@ -0,0 +1,116 @@ +mass = $mass; + $this->velocity = new Vec3(0.0, 0.0, 0.0); + $this->angularVelocity = new Vec3(0.0, 0.0, 0.0); + $this->force = new Vec3(0.0, 0.0, 0.0); + } + + /** + * Returns the inverse mass (0 for static/kinematic bodies) + */ + public function inverseMass(): float + { + if ($this->mass <= 0.0 || $this->isKinematic) { + return 0.0; + } + return 1.0 / $this->mass; + } + + /** + * Adds a force (in Newtons) to be applied during the next integration step. + */ + public function addForce(Vec3 $f): void + { + $force = $this->force; + $force->x = $force->x + $f->x; + $force->y = $force->y + $f->y; + $force->z = $force->z + $f->z; + } + + /** + * Adds an instantaneous impulse (mass * velocity change). + */ + public function addImpulse(Vec3 $impulse): void + { + $invMass = $this->inverseMass(); + if ($invMass <= 0.0) return; + + $vel = $this->velocity; + $vel->x = $vel->x + $impulse->x * $invMass; + $vel->y = $vel->y + $impulse->y * $invMass; + $vel->z = $vel->z + $impulse->z * $invMass; + } +} diff --git a/src/Component/SkeletalAnimationComponent.php b/src/Component/SkeletalAnimationComponent.php new file mode 100644 index 0000000..ca51962 --- /dev/null +++ b/src/Component/SkeletalAnimationComponent.php @@ -0,0 +1,92 @@ + + */ + public array $clips = []; + + /** + * Currently playing animation clip name (null = no animation) + */ + public ?string $currentClip = null; + + /** + * Current playback time in seconds + */ + public float $time = 0.0; + + /** + * Playback speed multiplier + */ + public float $speed = 1.0; + + /** + * Whether the animation loops + */ + public bool $looping = true; + + /** + * Whether the animation is playing + */ + public bool $playing = true; + + /** + * Computed bone matrices for the current frame (model-space). + * These are uploaded to the shader as uniform array. + * @var array + */ + public array $boneMatrices = []; + + /** + * Maximum number of bones supported by the shader + */ + public const MAX_BONES = 128; + + /** + * Adds an animation clip + */ + public function addClip(AnimationClip $clip): void + { + $this->clips[$clip->name] = $clip; + } + + /** + * Starts playing an animation by name + */ + public function play(string $clipName, bool $restart = true): void + { + if (!isset($this->clips[$clipName])) { + return; + } + $this->currentClip = $clipName; + $this->playing = true; + if ($restart) { + $this->time = 0.0; + } + } + + /** + * Returns the currently active clip, or null + */ + public function getActiveClip(): ?AnimationClip + { + if ($this->currentClip === null) { + return null; + } + return $this->clips[$this->currentClip] ?? null; + } +} diff --git a/src/Component/SphereCollider3D.php b/src/Component/SphereCollider3D.php new file mode 100644 index 0000000..edab050 --- /dev/null +++ b/src/Component/SphereCollider3D.php @@ -0,0 +1,41 @@ +radius = $radius; + $this->offset = $offset ?? new Vec3(0.0, 0.0, 0.0); + } +} diff --git a/src/Component/SpotLightComponent.php b/src/Component/SpotLightComponent.php new file mode 100644 index 0000000..5ada438 --- /dev/null +++ b/src/Component/SpotLightComponent.php @@ -0,0 +1,84 @@ +color = $color ?? new Vec3(1.0, 1.0, 1.0); + $this->intensity = $intensity; + $this->range = $range; + $this->direction = $direction ?? new Vec3(0.0, -1.0, 0.0); + $this->innerAngle = $innerAngle; + $this->outerAngle = $outerAngle; + } + + /** + * Sets physically-based attenuation from the range value + */ + public function setAttenuationFromRange(): void + { + $this->constantAttenuation = 1.0; + $this->linearAttenuation = 4.5 / $this->range; + $this->quadraticAttenuation = 75.0 / ($this->range * $this->range); + } +} diff --git a/src/Component/SpriteAnimator.php b/src/Component/SpriteAnimator.php new file mode 100644 index 0000000..636459c --- /dev/null +++ b/src/Component/SpriteAnimator.php @@ -0,0 +1,55 @@ + config. + * Each config: ['frames' => [[u, v, w, h], ...], 'fps' => int, 'loop' => bool] + * + * @var array, fps: int, loop: bool}> + */ + public array $animations = []; + + /** + * Current frame index in the active animation. + */ + public int $currentFrame = 0; + + /** + * Accumulated time since last frame change (seconds). + */ + public float $elapsed = 0.0; + + /** + * Whether the current animation is playing. + */ + public bool $playing = true; + + /** + * Whether the animation has finished (only relevant for non-looping). + */ + public bool $finished = false; + + /** + * Plays an animation by name, resetting frame state. + */ + public function play(string $name): void + { + if ($this->currentAnimation === $name && $this->playing && !$this->finished) { + return; + } + + $this->currentAnimation = $name; + $this->currentFrame = 0; + $this->elapsed = 0.0; + $this->playing = true; + $this->finished = false; + } +} diff --git a/src/Component/SpriteRenderer.php b/src/Component/SpriteRenderer.php new file mode 100644 index 0000000..bf1f082 --- /dev/null +++ b/src/Component/SpriteRenderer.php @@ -0,0 +1,61 @@ + Blackboard data persisted across ticks + */ + public array $blackboard = []; + + public bool $enabled = true; + + public function __construct( + public ?StateMachine $stateMachine = null, + ) { + } +} diff --git a/src/Component/TerrainComponent.php b/src/Component/TerrainComponent.php new file mode 100644 index 0000000..f51749f --- /dev/null +++ b/src/Component/TerrainComponent.php @@ -0,0 +1,36 @@ + + */ + public array $layerTextures = [null, null, null, null]; + + /** + * UV tiling scale for each layer (how many times textures repeat across terrain) + * @var array + */ + public array $layerTiling = [10.0, 10.0, 10.0, 10.0]; + + /** + * Whether this terrain casts shadows + */ + public bool $castsShadows = true; +} diff --git a/src/Component/Tilemap.php b/src/Component/Tilemap.php new file mode 100644 index 0000000..aacb527 --- /dev/null +++ b/src/Component/Tilemap.php @@ -0,0 +1,175 @@ + + */ + public array $tiles = []; + + /** + * Gets a tile at grid position. + */ + public function getTile(int $x, int $y): int + { + if ($x < 0 || $x >= $this->width || $y < 0 || $y >= $this->height) { + return 0; + } + + return $this->tiles[$y * $this->width + $x] ?? 0; + } + + /** + * Sets a tile at grid position. + */ + public function setTile(int $x, int $y, int $tileId): void + { + if ($x < 0 || $x >= $this->width || $y < 0 || $y >= $this->height) { + return; + } + + $this->tiles[$y * $this->width + $x] = $tileId; + } + + /** + * Whether auto-tiling is enabled for this tilemap. + * When enabled, tile IDs represent terrain types and the actual tileset index + * is computed from the 4-bit bitmask of matching neighbors. + */ + public bool $autoTile = false; + + /** + * Auto-tile mapping: terrain type => bitmask => tileset index. + * Bitmask bits: 1=top, 2=right, 4=bottom, 8=left. + * This gives 16 possible combinations (0-15) per terrain type. + * + * Example: [1 => [0 => 1, 1 => 2, 2 => 3, ...]] + * means terrain type 1 with no neighbors uses tileset index 1, etc. + * + * @var array> + */ + public array $autoTileMap = []; + + /** + * Computes the UV rect for a tile ID in the tileset. + * + * @return array{float, float, float, float} [u, v, w, h] in normalized coordinates + */ + public function getTileUV(int $tileId, int $tilesetWidth, int $tilesetHeight): array + { + if ($tileId <= 0 || $this->tilesetColumns <= 0) { + return [0, 0, 0, 0]; + } + + $index = $tileId - 1; // tile IDs are 1-based + $col = $index % $this->tilesetColumns; + $row = (int)($index / $this->tilesetColumns); + + $tileW = $this->tileSize / $tilesetWidth; + $tileH = $this->tileSize / $tilesetHeight; + + return [ + $col * $tileW, + $row * $tileH, + $tileW, + $tileH, + ]; + } + + /** + * Computes a 4-bit neighbor bitmask for auto-tiling. + * Bits: 1=top, 2=right, 4=bottom, 8=left. + * A bit is set if the neighbor has the same terrain type. + */ + public function getAutoTileBitmask(int $x, int $y): int + { + $terrainType = $this->getTile($x, $y); + if ($terrainType <= 0) { + return 0; + } + + $mask = 0; + if ($this->getTile($x, $y - 1) === $terrainType) $mask |= 1; // top + if ($this->getTile($x + 1, $y) === $terrainType) $mask |= 2; // right + if ($this->getTile($x, $y + 1) === $terrainType) $mask |= 4; // bottom + if ($this->getTile($x - 1, $y) === $terrainType) $mask |= 8; // left + + return $mask; + } + + /** + * Resolves the actual tileset index for an auto-tiled position. + * Returns the mapped tileset index or falls back to the raw tile ID. + */ + public function resolveAutoTile(int $x, int $y): int + { + $terrainType = $this->getTile($x, $y); + if ($terrainType <= 0) { + return 0; + } + + if (!$this->autoTile || !isset($this->autoTileMap[$terrainType])) { + return $terrainType; + } + + $mask = $this->getAutoTileBitmask($x, $y); + return $this->autoTileMap[$terrainType][$mask] ?? $terrainType; + } + + /** + * Recalculates all auto-tile mappings and returns a resolved tile grid. + * Useful for baking the auto-tile results into a flat array for fast rendering. + * + * @return array Resolved tileset indices in the same flat layout as $tiles. + */ + public function bakeAutoTiles(): array + { + if (!$this->autoTile) { + return $this->tiles; + } + + $result = []; + for ($y = 0; $y < $this->height; $y++) { + for ($x = 0; $x < $this->width; $x++) { + $result[$y * $this->width + $x] = $this->resolveAutoTile($x, $y); + } + } + + return $result; + } +} diff --git a/src/Dialogue/DialogueAction.php b/src/Dialogue/DialogueAction.php new file mode 100644 index 0000000..5b3fc94 --- /dev/null +++ b/src/Dialogue/DialogueAction.php @@ -0,0 +1,18 @@ + $actions Actions to execute when chosen + */ + public function __construct( + public readonly string $text, + public readonly string $next, + public readonly ?string $condition = null, + public readonly array $actions = [], + ) { + } +} diff --git a/src/Dialogue/DialogueManager.php b/src/Dialogue/DialogueManager.php new file mode 100644 index 0000000..29dc5a9 --- /dev/null +++ b/src/Dialogue/DialogueManager.php @@ -0,0 +1,253 @@ + Loaded dialogue trees by ID + */ + private array $trees = []; + + /** + * @var array Variables for condition evaluation and text interpolation + */ + private array $variables = []; + + private ?DialogueTree $activeTree = null; + private ?DialogueNode $activeNode = null; + + public function registerTree(DialogueTree $tree): void + { + $this->trees[$tree->id] = $tree; + } + + /** + * Load and register a dialogue tree from a JSON file. + */ + public function loadFromFile(string $path): DialogueTree + { + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException("Failed to read dialogue file: {$path}"); + } + + /** @var array $data */ + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid dialogue JSON in: {$path}"); + } + + $tree = DialogueTree::fromArray($data); + $this->registerTree($tree); + return $tree; + } + + public function setVariable(string $key, mixed $value): void + { + $this->variables[$key] = $value; + } + + public function getVariable(string $key, mixed $default = null): mixed + { + return $this->variables[$key] ?? $default; + } + + /** + * @return array + */ + public function getVariables(): array + { + return $this->variables; + } + + public function startDialogue(string $treeId): ?DialogueNode + { + if (!isset($this->trees[$treeId])) { + return null; + } + + $this->activeTree = $this->trees[$treeId]; + $node = $this->activeTree->getNode($this->activeTree->startNodeId); + + if ($node === null) { + $this->activeTree = null; + return null; + } + + $this->activeNode = $node; + $this->executeActions($node->actions); + + return $node; + } + + public function isActive(): bool + { + return $this->activeNode !== null; + } + + public function getActiveNode(): ?DialogueNode + { + return $this->activeNode; + } + + /** + * Advance to the next node (for nodes without choices). + */ + public function advance(): ?DialogueNode + { + if ($this->activeNode === null || $this->activeTree === null) { + return null; + } + + if ($this->activeNode->next === null) { + $this->endDialogue(); + return null; + } + + $nextNode = $this->activeTree->getNode($this->activeNode->next); + if ($nextNode === null) { + $this->endDialogue(); + return null; + } + + $this->activeNode = $nextNode; + $this->executeActions($nextNode->actions); + return $nextNode; + } + + /** + * Select a choice by index. + */ + public function selectChoice(int $index): ?DialogueNode + { + if ($this->activeNode === null || $this->activeTree === null) { + return null; + } + + $availableChoices = $this->getAvailableChoices(); + + if ($index < 0 || $index >= count($availableChoices)) { + return null; + } + + $choice = $availableChoices[$index]; + $this->executeActions($choice->actions); + + assert($this->activeTree !== null); + $nextNode = $this->activeTree->getNode($choice->next); + if ($nextNode === null) { + $this->endDialogue(); + return null; + } + + $this->activeNode = $nextNode; + $this->executeActions($nextNode->actions); + return $nextNode; + } + + /** + * Get choices available for the current node (filtered by conditions). + * + * @return array + */ + public function getAvailableChoices(): array + { + if ($this->activeNode === null) { + return []; + } + + $available = []; + foreach ($this->activeNode->choices as $choice) { + if ($choice->condition !== null && !$this->evaluateCondition($choice->condition)) { + continue; + } + $available[] = $choice; + } + + return $available; + } + + /** + * Interpolate variables in dialogue text: {variable.name} -> value + */ + public function interpolateText(string $text): string + { + return (string)preg_replace_callback('/\{([a-zA-Z0-9_.]+)\}/', function (array $matches): string { + return (string)($this->variables[$matches[1]] ?? $matches[0]); + }, $text); + } + + public function endDialogue(): void + { + $this->activeTree = null; + $this->activeNode = null; + } + + /** + * Evaluate a simple condition expression. + * Supports: "variable", "variable == value", "variable != value", + * "variable > value", "variable >= value", "variable < value", "variable <= value" + */ + public function evaluateCondition(string $condition): bool + { + $condition = trim($condition); + + // comparison operators + if (preg_match('/^([a-zA-Z0-9_.]+)\s*(==|!=|>=|<=|>|<)\s*(.+)$/', $condition, $matches)) { + $varValue = $this->variables[$matches[1]] ?? null; + $operator = $matches[2]; + $compareValue = $this->parseValue(trim($matches[3])); + + return match ($operator) { + '==' => $varValue == $compareValue, + '!=' => $varValue != $compareValue, + '>' => $varValue > $compareValue, + '>=' => $varValue >= $compareValue, + '<' => $varValue < $compareValue, + '<=' => $varValue <= $compareValue, + }; + } + + // negation: !variable + if (str_starts_with($condition, '!')) { + $key = substr($condition, 1); + return empty($this->variables[$key]); + } + + // truthy check: variable + return !empty($this->variables[$condition]); + } + + private function parseValue(string $raw): mixed + { + if ($raw === 'true') return true; + if ($raw === 'false') return false; + if ($raw === 'null') return null; + if (is_numeric($raw)) { + return str_contains($raw, '.') ? (float)$raw : (int)$raw; + } + // strip quotes + if ((str_starts_with($raw, '"') && str_ends_with($raw, '"')) + || (str_starts_with($raw, "'") && str_ends_with($raw, "'"))) { + return substr($raw, 1, -1); + } + return $raw; + } + + /** + * Execute dialogue actions (set variables, emit signals, etc.) + * + * @param array $actions + */ + private function executeActions(array $actions): void + { + foreach ($actions as $action) { + match ($action->type) { + 'set' => $this->variables[$action->target] = $action->value, + 'add' => $this->variables[$action->target] = ($this->variables[$action->target] ?? 0) + $action->value, + default => null, + }; + } + } +} diff --git a/src/Dialogue/DialogueNode.php b/src/Dialogue/DialogueNode.php new file mode 100644 index 0000000..49af615 --- /dev/null +++ b/src/Dialogue/DialogueNode.php @@ -0,0 +1,26 @@ + $choices Player choices (empty = auto-advance) + * @param ?string $next Next node ID for auto-advance (null = end of dialogue) + * @param ?string $condition Optional condition expression to check if node is reachable + * @param array $actions Actions to execute when node is entered + */ + public function __construct( + public readonly string $id, + public readonly string $speaker = '', + public readonly string $text = '', + public readonly array $choices = [], + public readonly ?string $next = null, + public readonly ?string $condition = null, + public readonly array $actions = [], + ) { + } +} diff --git a/src/Dialogue/DialogueTree.php b/src/Dialogue/DialogueTree.php new file mode 100644 index 0000000..9c971f1 --- /dev/null +++ b/src/Dialogue/DialogueTree.php @@ -0,0 +1,111 @@ + + */ + private array $nodes = []; + + public function __construct( + public readonly string $id, + public readonly string $startNodeId, + ) { + } + + public function addNode(DialogueNode $node): void + { + $this->nodes[$node->id] = $node; + } + + public function getNode(string $id): ?DialogueNode + { + return $this->nodes[$id] ?? null; + } + + public function hasNode(string $id): bool + { + return isset($this->nodes[$id]); + } + + /** + * @return array + */ + public function getNodes(): array + { + return $this->nodes; + } + + /** + * Parse a dialogue tree from a JSON array structure. + * + * @param array $data + */ + public static function fromArray(array $data): self + { + $treeId = (string)($data['id'] ?? 'unnamed'); + $startNode = (string)($data['start'] ?? ''); + + $tree = new self($treeId, $startNode); + + /** @var array> $nodes */ + $nodes = $data['nodes'] ?? []; + foreach ($nodes as $nodeData) { + $tree->addNode(self::parseNode($nodeData)); + } + + return $tree; + } + + /** + * @param array $nodeData + */ + private static function parseNode(array $nodeData): DialogueNode + { + $choices = []; + /** @var array> $choicesData */ + $choicesData = $nodeData['choices'] ?? []; + foreach ($choicesData as $choiceData) { + $choiceActions = []; + /** @var array> $choiceActionsData */ + $choiceActionsData = $choiceData['actions'] ?? []; + foreach ($choiceActionsData as $actionData) { + $choiceActions[] = new DialogueAction( + (string)($actionData['type'] ?? ''), + (string)($actionData['target'] ?? ''), + $actionData['value'] ?? null, + ); + } + + $choices[] = new DialogueChoice( + (string)($choiceData['text'] ?? ''), + (string)($choiceData['next'] ?? ''), + isset($choiceData['condition']) ? (string)$choiceData['condition'] : null, + $choiceActions, + ); + } + + $actions = []; + /** @var array> $actionsData */ + $actionsData = $nodeData['actions'] ?? []; + foreach ($actionsData as $actionData) { + $actions[] = new DialogueAction( + (string)($actionData['type'] ?? ''), + (string)($actionData['target'] ?? ''), + $actionData['value'] ?? null, + ); + } + + return new DialogueNode( + id: (string)($nodeData['id'] ?? ''), + speaker: (string)($nodeData['speaker'] ?? ''), + text: (string)($nodeData['text'] ?? ''), + choices: $choices, + next: isset($nodeData['next']) ? (string)$nodeData['next'] : null, + condition: isset($nodeData['condition']) ? (string)$nodeData['condition'] : null, + actions: $actions, + ); + } +} diff --git a/src/ECS/ComponentRegistry.php b/src/ECS/ComponentRegistry.php new file mode 100644 index 0000000..2fa1ad9 --- /dev/null +++ b/src/ECS/ComponentRegistry.php @@ -0,0 +1,102 @@ + "VISU\Component\SpriteRenderer" + * + * @var array + */ + private array $typeMap = []; + + /** + * Registers a component class with a short type name. + * + * @param class-string $className + */ + public function register(string $typeName, string $className): void + { + if (!class_exists($className)) { + throw new ECSException("Component class does not exist: {$className}"); + } + + $this->typeMap[$typeName] = $className; + } + + /** + * Resolves a short type name to a fully qualified class name. + * + * @return class-string + */ + public function resolve(string $typeName): string + { + if (!isset($this->typeMap[$typeName])) { + throw new ECSException("Unknown component type: '{$typeName}'. Did you forget to register it?"); + } + + return $this->typeMap[$typeName]; + } + + /** + * Returns whether a type name is registered. + */ + public function has(string $typeName): bool + { + return isset($this->typeMap[$typeName]); + } + + /** + * Creates a component instance from a type name and optional property array. + * + * @param array $properties + */ + public function create(string $typeName, array $properties = []): object + { + $className = $this->resolve($typeName); + $component = new $className(); + + foreach ($properties as $key => $value) { + if (property_exists($component, $key)) { + $component->$key = $value; + } + } + + return $component; + } + + /** + * Returns the reverse lookup: class name to type name. + * + * @param class-string $className + */ + public function getTypeName(string $className): ?string + { + $flipped = array_flip($this->typeMap); + return $flipped[$className] ?? null; + } + + /** + * Returns all registered type names. + * + * @return array + */ + public function getRegisteredTypes(): array + { + return array_keys($this->typeMap); + } + + /** + * Returns the full type map. + * + * @return array + */ + public function getTypeMap(): array + { + return $this->typeMap; + } +} diff --git a/src/ECS/ComponentRegistryInterface.php b/src/ECS/ComponentRegistryInterface.php new file mode 100644 index 0000000..3f2864a --- /dev/null +++ b/src/ECS/ComponentRegistryInterface.php @@ -0,0 +1,53 @@ + $properties + */ + public function create(string $typeName, array $properties = []): object; + + /** + * Returns the reverse lookup: class name to type name. + * + * @param class-string $className + */ + public function getTypeName(string $className): ?string; + + /** + * Returns all registered type names. + * + * @return array + */ + public function getRegisteredTypes(): array; + + /** + * Returns the full type map. + * + * @return array + */ + public function getTypeMap(): array; +} diff --git a/src/FlyUI/FUIButton.php b/src/FlyUI/FUIButton.php index 978435d..6400cfd 100644 --- a/src/FlyUI/FUIButton.php +++ b/src/FlyUI/FUIButton.php @@ -40,7 +40,7 @@ public function __construct( $this->style = $buttonStyle ?? FlyUI::$instance->theme->primaryButton; parent::__construct($this->style->padding->copy()); - // button ID by default just the text, this means that if + // button ID by default just the text, this means that if // you have multiple buttons with the same text, you have to assign a custom ID $this->buttonId = $buttonId ?? 'btn_' . $this->text; } @@ -94,7 +94,6 @@ public function getEstimatedSize(FUIRenderContext $ctx) : Vec2 private const BUTTON_PRESS_NONE = 0; private const BUTTON_PRESS_STARTED = 1; - private const BUTTON_PRESS_ENDED = 2; /** * Renders the current view using the provided context @@ -114,61 +113,44 @@ public function render(FUIRenderContext $ctx) : void // last press key $lpKey = $this->buttonId . '_lp'; - - static $fuiButtonPressStates = []; - if (!isset($fuiButtonPressStates[$this->buttonId])) { - $fuiButtonPressStates[$this->buttonId] = self::BUTTON_PRESS_NONE; + + // Track press state: NONE → STARTED (on press) → fire onClick (on release inside) → NONE + $pressKey = $this->buttonId . '_ps'; + $rawPressState = $ctx->getStaticValue($pressKey, self::BUTTON_PRESS_NONE); + $pressState = (int) $rawPressState; + + // Defensive: reset corrupt press state (can happen during display mode transitions) + if ($pressState !== self::BUTTON_PRESS_NONE && $pressState !== self::BUTTON_PRESS_STARTED) { + $pressState = self::BUTTON_PRESS_NONE; + $ctx->setStaticValue($pressKey, self::BUTTON_PRESS_NONE); } - - if ($isInside && $ctx->input->isMouseButtonPressed(MouseButton::LEFT)) + + $mousePressed = $ctx->input->isMouseButtonPressed(MouseButton::LEFT); + $mouseReleased = !$mousePressed; + + if ($isInside && $mousePressed && $pressState === self::BUTTON_PRESS_NONE) { - // store last press time $ctx->setStaticValue($lpKey, glfwGetTime()); - - if ($fuiButtonPressStates[$this->buttonId] === self::BUTTON_PRESS_NONE) { - $fuiButtonPressStates[$this->buttonId] = self::BUTTON_PRESS_STARTED; - } - } else if ($isInside && $fuiButtonPressStates[$this->buttonId] === self::BUTTON_PRESS_STARTED) { - $fuiButtonPressStates[$this->buttonId] = self::BUTTON_PRESS_ENDED; + $ctx->setStaticValue($pressKey, self::BUTTON_PRESS_STARTED); + } else if ($isInside && $mouseReleased && $pressState === self::BUTTON_PRESS_STARTED) { + $ctx->setStaticValue($pressKey, self::BUTTON_PRESS_NONE); if ($this->onClick) { ($this->onClick)(); } - } else { - $fuiButtonPressStates[$this->buttonId] = self::BUTTON_PRESS_NONE; + } else if ($pressState === self::BUTTON_PRESS_STARTED && $mouseReleased) { + // Released outside button bounds — cancel + $ctx->setStaticValue($pressKey, self::BUTTON_PRESS_NONE); } - // we have a little fade animation of the ring of the button - // so basically check if it has been less then 0.2 seconds since the last press - if ($ctx->getStaticValue($lpKey, -99.0) + 0.2 > glfwGetTime()) - { - $alpha = (float)(($ctx->getStaticValue($lpKey, 0.0) + 0.2 - glfwGetTime()) * 5.0); - - $ctx->vg->beginPath(); - $ctx->vg->strokeColor($this->style->backgroundColor->withAlpha($alpha)); - $ctx->vg->strokeWidth(2); - $ringDistance = 2 + (1.0 - $alpha) * 3.0; - $ctx->vg->roundedRect( - $ctx->origin->x - $ringDistance, - $ctx->origin->y - $ringDistance, - $ctx->containerSize->x + $ringDistance * 2, - $height + $ringDistance * 2, - $this->style->cornerRadius - ); - $ctx->vg->stroke(); - } - else { - // clean up the last press time - $ctx->clearStaticValue($lpKey); - } + // ring fade animation disabled — causes white line artifacts + $ctx->clearStaticValue($lpKey); // render the button background $ctx->vg->beginPath(); $ctx->vg->fillColor($this->style->backgroundColor); - if ($isInside) { $ctx->vg->fillColor($this->style->hoverBackgroundColor); } - $ctx->vg->roundedRect( $ctx->origin->x, $ctx->origin->y, @@ -202,4 +184,4 @@ public function render(FUIRenderContext $ctx) : void // no pass to parent, as this is a leaf element } -} \ No newline at end of file +} diff --git a/src/FlyUI/FUICard.php b/src/FlyUI/FUICard.php index 62a6f16..20a4b03 100644 --- a/src/FlyUI/FUICard.php +++ b/src/FlyUI/FUICard.php @@ -29,22 +29,23 @@ public function __construct() */ public function renderContent(FUIRenderContext $ctx) : void { - $finalPos = $ctx->origin; - $finalSize = $ctx->containerSize; - - // borders are always drawn inset in FlyUI, as VG draws them in the middle - // we have to adjust the position and size of the rectangle - if ($this->borderColor) { - $finalPos->x = $finalPos->x + $this->borderWidth * 0.5; - $finalPos->y = $finalPos->y + $this->borderWidth * 0.5; - $finalSize->x = $finalSize->x - $this->borderWidth; - $finalSize->y = $finalSize->y - $this->borderWidth; - } + // capture border rect before children render (copy to avoid reference mutation) + $borderX = $ctx->origin->x + $this->borderWidth * 0.5; + $borderY = $ctx->origin->y + $this->borderWidth * 0.5; + $borderW = $ctx->containerSize->x - $this->borderWidth; + $borderH = $ctx->containerSize->y - $this->borderWidth; // pass to children parent::renderContent($ctx); + // draw the border on top of children with its own path if ($this->borderColor) { + $ctx->vg->beginPath(); + if ($this->cornerRadius > 0.0) { + $ctx->vg->roundedRect($borderX, $borderY, $borderW, $borderH, $this->cornerRadius); + } else { + $ctx->vg->rect($borderX, $borderY, $borderW, $borderH); + } $ctx->vg->strokeColor($this->borderColor); $ctx->vg->strokeWidth($this->borderWidth); $ctx->vg->stroke(); diff --git a/src/FlyUI/FUIProgressBar.php b/src/FlyUI/FUIProgressBar.php new file mode 100644 index 0000000..1c6fbc1 --- /dev/null +++ b/src/FlyUI/FUIProgressBar.php @@ -0,0 +1,68 @@ +fillColor = $fillColor ?? VGColor::rgb(0.20, 0.55, 0.85); + $this->backgroundColor = $backgroundColor ?? VGColor::rgb(0.90, 0.90, 0.90); + } + + public function height(float $height): self + { + $this->barHeight = $height; + return $this; + } + + public function color(VGColor $color): self + { + $this->fillColor = $color; + return $this; + } + + public function getEstimatedSize(FUIRenderContext $ctx): Vec2 + { + return new Vec2($ctx->containerSize->x, $this->barHeight); + } + + public function render(FUIRenderContext $ctx): void + { + $width = $ctx->containerSize->x; + $height = $this->barHeight; + $ctx->containerSize->y = $height; + + $clamped = max(0.0, min(1.0, $this->value)); + + // Background track + $ctx->vg->beginPath(); + $ctx->vg->roundedRect($ctx->origin->x, $ctx->origin->y, $width, $height, $this->cornerRadius); + $ctx->vg->fillColor($this->backgroundColor); + $ctx->vg->fill(); + + // Fill bar + if ($clamped > 0.0) { + $fillWidth = $width * $clamped; + $ctx->vg->beginPath(); + $ctx->vg->roundedRect($ctx->origin->x, $ctx->origin->y, $fillWidth, $height, $this->cornerRadius); + $ctx->vg->fillColor($this->fillColor); + $ctx->vg->fill(); + } + } +} diff --git a/src/FlyUI/FUIRenderContext.php b/src/FlyUI/FUIRenderContext.php index 1b3be25..0df6b01 100644 --- a/src/FlyUI/FUIRenderContext.php +++ b/src/FlyUI/FUIRenderContext.php @@ -5,6 +5,7 @@ use GL\Math\Vec2; use GL\VectorGraphics\VGContext; use VISU\OS\Input; +use VISU\OS\InputInterface; class FUIRenderContext { @@ -192,7 +193,7 @@ public function clearAllStaticValues() : void */ public function __construct( public VGContext $vg, - public Input $input, + public Input|InputInterface $input, public FUITheme $theme ) { diff --git a/src/FlyUI/FlyUI.php b/src/FlyUI/FlyUI.php index 59e04d1..5e7d84c 100644 --- a/src/FlyUI/FlyUI.php +++ b/src/FlyUI/FlyUI.php @@ -10,6 +10,7 @@ use VISU\FlyUI\Exception\FlyUiInitException; use VISU\Instrument\Clock; use VISU\OS\Input; +use VISU\OS\InputInterface; use VISU\OS\Key; use VISU\Signal\Dispatcher; @@ -31,7 +32,7 @@ class FlyUI /** * Initializes the global FlyUI instance */ - public static function initailize(VGContext $vgContext, Dispatcher $dispatcher, Input $input) : void { + public static function initailize(VGContext $vgContext, Dispatcher $dispatcher, Input|InputInterface $input) : void { self::$instance = new FlyUI($vgContext, $dispatcher, $input); if ($vgContext->createFont('inter-regular', VISU_PATH_FRAMEWORK_RESOURCES_FONT . '/inter/Inter-Regular.ttf') === -1) { @@ -246,9 +247,19 @@ public static function select( return $view; } + /** + * Creates a progress bar element + */ + public static function progressBar(float $value, ?VGColor $fillColor = null): FUIProgressBar + { + $view = new FUIProgressBar($value, $fillColor); + self::$instance->addChildView($view); + return $view; + } + /** * Instance Functions/Properties - * + * * ------------------------------------------------------------------------ */ @@ -297,6 +308,20 @@ public static function select( */ public bool $performanceTracingEnabled = false; + /** + * Viewport offset for letterboxing support. + * When set, FlyUI will offset its rendering and mouse hit-testing + * by this amount, allowing the game to render in a centered sub-area. + */ + public ?Vec2 $viewportOffset = null; + + /** + * Viewport size override for letterboxing support. + * When set, FlyUI uses this as the root container size instead of + * the full window resolution passed to beginFrame. + */ + public ?Vec2 $viewportSize = null; + /** * Performance tracer instance * @@ -314,9 +339,9 @@ public static function select( */ public function __construct( private VGContext $vgContext, - /** @phpstan-ignore-next-line */ + /** @phpstan-ignore-next-line */ private Dispatcher $dispatcher, - private Input $input, + private Input|InputInterface $input, ?FUITheme $theme = null ) { @@ -412,16 +437,31 @@ private function internalBeginFrame(Vec2 $resolution, float $contentScale = 1.0) private function internalEndFrame() : void { $ctx = new FUIRenderContext($this->vgContext, $this->input, $this->theme); - $ctx->containerSize = $this->currentResolution; + $ctx->containerSize = $this->viewportSize ?? $this->currentResolution; $ctx->contentScale = $this->currentContentScale; - // toggle performance tracing overlay on f6 - if ($this->input->hasKeyBeenPressedThisFrame(Key::F6)) { + // Apply viewport offset for letterboxing (shift mouse coordinates) + if ($this->viewportOffset !== null) { + $ctx->mousePos = new Vec2( + $ctx->mousePos->x - $this->viewportOffset->x, + $ctx->mousePos->y - $this->viewportOffset->y, + ); + } + + // toggle performance tracing overlay on f6 (cross-check with polling + // to filter phantom key events from macOS fullscreen transitions) + if ($this->input->hasKeyBeenPressedThisFrame(Key::F6) + && $this->input->getKeyState(Key::F6) === GLFW_PRESS) { $this->performanceTracingEnabled = !$this->performanceTracingEnabled; } $this->vgContext->reset(); + // Apply viewport offset for letterboxing (shift VG rendering) + if ($this->viewportOffset !== null) { + $this->vgContext->translate($this->viewportOffset->x, $this->viewportOffset->y); + } + // set the default font face $ctx->ensureRegularFontFace(); diff --git a/src/Geo/Raycast2D.php b/src/Geo/Raycast2D.php new file mode 100644 index 0000000..f5594bd --- /dev/null +++ b/src/Geo/Raycast2D.php @@ -0,0 +1,255 @@ + Entity IDs containing the point. + */ + public static function pointQuery( + EntitiesInterface $entities, + float $px, + float $py, + int $layerMask = 0xFFFF, + ): array { + $results = []; + + foreach ($entities->view(BoxCollider2D::class) as $entityId => $box) { + if (($box->layer & $layerMask) === 0) { + continue; + } + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + $worldPos = self::getWorldPos($entities, $entityId, $transform); + $cx = $worldPos[0] + $box->offsetX; + $cy = $worldPos[1] + $box->offsetY; + + if ($px >= $cx - $box->halfWidth && $px <= $cx + $box->halfWidth + && $py >= $cy - $box->halfHeight && $py <= $cy + $box->halfHeight) { + $results[] = $entityId; + } + } + + foreach ($entities->view(CircleCollider2D::class) as $entityId => $circle) { + if (($circle->layer & $layerMask) === 0) { + continue; + } + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + $worldPos = self::getWorldPos($entities, $entityId, $transform); + $cx = $worldPos[0] + $circle->offsetX; + $cy = $worldPos[1] + $circle->offsetY; + + $dx = $px - $cx; + $dy = $py - $cy; + if ($dx * $dx + $dy * $dy <= $circle->radius * $circle->radius) { + $results[] = $entityId; + } + } + + return $results; + } + + /** + * Casts a ray and returns the closest hit, or null. + * + * @param float $originX Ray start X + * @param float $originY Ray start Y + * @param float $dirX Ray direction X (should be normalized) + * @param float $dirY Ray direction Y (should be normalized) + * @param float $maxDistance Maximum distance to test + */ + public static function cast( + EntitiesInterface $entities, + float $originX, + float $originY, + float $dirX, + float $dirY, + float $maxDistance = 1000.0, + int $layerMask = 0xFFFF, + ): ?Raycast2DResult { + $closest = null; + $closestDist = $maxDistance; + + foreach ($entities->view(BoxCollider2D::class) as $entityId => $box) { + if (($box->layer & $layerMask) === 0) { + continue; + } + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + $worldPos = self::getWorldPos($entities, $entityId, $transform); + $cx = $worldPos[0] + $box->offsetX; + $cy = $worldPos[1] + $box->offsetY; + + $t = self::rayBoxIntersect( + $originX, $originY, $dirX, $dirY, + $cx - $box->halfWidth, $cy - $box->halfHeight, + $cx + $box->halfWidth, $cy + $box->halfHeight, + ); + + if ($t !== null && $t >= 0 && $t < $closestDist) { + $closestDist = $t; + $closest = new Raycast2DResult( + $entityId, + $t, + $originX + $dirX * $t, + $originY + $dirY * $t, + ); + } + } + + foreach ($entities->view(CircleCollider2D::class) as $entityId => $circle) { + if (($circle->layer & $layerMask) === 0) { + continue; + } + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + $worldPos = self::getWorldPos($entities, $entityId, $transform); + $cx = $worldPos[0] + $circle->offsetX; + $cy = $worldPos[1] + $circle->offsetY; + + $t = self::rayCircleIntersect($originX, $originY, $dirX, $dirY, $cx, $cy, $circle->radius); + + if ($t !== null && $t >= 0 && $t < $closestDist) { + $closestDist = $t; + $closest = new Raycast2DResult( + $entityId, + $t, + $originX + $dirX * $t, + $originY + $dirY * $t, + ); + } + } + + return $closest; + } + + /** + * @return array{float, float} + */ + private static function getWorldPos(EntitiesInterface $entities, int $entityId, Transform $transform): array + { + $x = $transform->position->x; + $y = $transform->position->y; + $parent = $transform->parent; + while ($parent !== null) { + $pt = $entities->tryGet($parent, Transform::class); + if ($pt === null) { + break; + } + $x += $pt->position->x; + $y += $pt->position->y; + $parent = $pt->parent; + } + return [$x, $y]; + } + + /** + * Ray-AABB intersection. Returns t (distance along ray) or null. + */ + private static function rayBoxIntersect( + float $ox, float $oy, float $dx, float $dy, + float $minX, float $minY, float $maxX, float $maxY, + ): ?float { + if ($dx === 0.0 && $dy === 0.0) { + return null; + } + + // For axis-aligned zero direction: check if origin is within slab + if ($dx === 0.0) { + if ($ox < $minX || $ox > $maxX) { + return null; + } + $tMinX = -INF; + $tMaxX = INF; + } else { + $tMinX = ($minX - $ox) / $dx; + $tMaxX = ($maxX - $ox) / $dx; + if ($tMinX > $tMaxX) { $tmp = $tMinX; $tMinX = $tMaxX; $tMaxX = $tmp; } + } + + if ($dy === 0.0) { + if ($oy < $minY || $oy > $maxY) { + return null; + } + $tMinY = -INF; + $tMaxY = INF; + } else { + $tMinY = ($minY - $oy) / $dy; + $tMaxY = ($maxY - $oy) / $dy; + if ($tMinY > $tMaxY) { $tmp = $tMinY; $tMinY = $tMaxY; $tMaxY = $tmp; } + } + + $tEnter = max($tMinX, $tMinY); + $tExit = min($tMaxX, $tMaxY); + + if ($tEnter > $tExit || $tExit < 0) { + return null; + } + + return $tEnter >= 0 ? $tEnter : $tExit; + } + + /** + * Ray-Circle intersection. Returns t (distance along ray) or null. + */ + private static function rayCircleIntersect( + float $ox, float $oy, float $dx, float $dy, + float $cx, float $cy, float $r, + ): ?float { + $fx = $ox - $cx; + $fy = $oy - $cy; + + $a = $dx * $dx + $dy * $dy; + if ($a === 0.0) { + return null; + } + $b = 2.0 * ($fx * $dx + $fy * $dy); + $c = $fx * $fx + $fy * $fy - $r * $r; + + $discriminant = $b * $b - 4.0 * $a * $c; + if ($discriminant < 0) { + return null; + } + + $sqrtD = sqrt($discriminant); + $t1 = (-$b - $sqrtD) / (2.0 * $a); + $t2 = (-$b + $sqrtD) / (2.0 * $a); + + if ($t1 >= 0) { + return $t1; + } + if ($t2 >= 0) { + return $t2; + } + return null; + } +} diff --git a/src/Geo/Raycast2DResult.php b/src/Geo/Raycast2DResult.php new file mode 100644 index 0000000..e1db9e1 --- /dev/null +++ b/src/Geo/Raycast2DResult.php @@ -0,0 +1,14 @@ + Entity IDs containing the point. + */ + public static function pointQuery( + EntitiesInterface $entities, + Vec3 $point, + int $layerMask = 0xFFFF, + ): array { + $results = []; + + foreach ($entities->view(BoxCollider3D::class) as $entityId => $box) { + if (($box->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $cx = $worldPos->x + $box->offset->x; + $cy = $worldPos->y + $box->offset->y; + $cz = $worldPos->z + $box->offset->z; + + if ($point->x >= $cx - $box->halfExtents->x && $point->x <= $cx + $box->halfExtents->x + && $point->y >= $cy - $box->halfExtents->y && $point->y <= $cy + $box->halfExtents->y + && $point->z >= $cz - $box->halfExtents->z && $point->z <= $cz + $box->halfExtents->z) { + $results[] = $entityId; + } + } + + foreach ($entities->view(SphereCollider3D::class) as $entityId => $sphere) { + if (($sphere->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $dx = $point->x - ($worldPos->x + $sphere->offset->x); + $dy = $point->y - ($worldPos->y + $sphere->offset->y); + $dz = $point->z - ($worldPos->z + $sphere->offset->z); + + if ($dx * $dx + $dy * $dy + $dz * $dz <= $sphere->radius * $sphere->radius) { + $results[] = $entityId; + } + } + + foreach ($entities->view(CapsuleCollider3D::class) as $entityId => $capsule) { + if (($capsule->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $capsule->offset->x, + $worldPos->y + $capsule->offset->y, + $worldPos->z + $capsule->offset->z, + ); + + $distSq = self::pointCapsuleDistSq($point, $center, $capsule->halfHeight, $capsule->radius); + if ($distSq <= $capsule->radius * $capsule->radius) { + $results[] = $entityId; + } + } + + return $results; + } + + /** + * Casts a ray and returns the closest hit, or null. + */ + public static function cast( + EntitiesInterface $entities, + Vec3 $origin, + Vec3 $direction, + float $maxDistance = 1000.0, + int $layerMask = 0xFFFF, + ): ?Raycast3DResult { + $closest = null; + $closestDist = $maxDistance; + + foreach ($entities->view(BoxCollider3D::class) as $entityId => $box) { + if (($box->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $box->offset->x, + $worldPos->y + $box->offset->y, + $worldPos->z + $box->offset->z, + ); + + $aabb = new AABB( + new Vec3($center->x - $box->halfExtents->x, $center->y - $box->halfExtents->y, $center->z - $box->halfExtents->z), + new Vec3($center->x + $box->halfExtents->x, $center->y + $box->halfExtents->y, $center->z + $box->halfExtents->z), + ); + + $ray = new Ray($origin, $direction); + $t = $aabb->intersectRayDistance($ray); + if ($t !== null && $t >= 0 && $t < $closestDist) { + $hitPoint = $ray->pointAt($t); + $closestDist = $t; + $closest = new Raycast3DResult($entityId, $t, $hitPoint, self::aabbNormal($hitPoint, $aabb)); + } + } + + foreach ($entities->view(SphereCollider3D::class) as $entityId => $sphere) { + if (($sphere->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $sphere->offset->x, + $worldPos->y + $sphere->offset->y, + $worldPos->z + $sphere->offset->z, + ); + + $t = self::raySphereIntersect($origin, $direction, $center, $sphere->radius); + if ($t !== null && $t >= 0 && $t < $closestDist) { + $hitPoint = new Vec3( + $origin->x + $direction->x * $t, + $origin->y + $direction->y * $t, + $origin->z + $direction->z * $t, + ); + $normal = Vec3::normalized(new Vec3( + $hitPoint->x - $center->x, + $hitPoint->y - $center->y, + $hitPoint->z - $center->z, + )); + $closestDist = $t; + $closest = new Raycast3DResult($entityId, $t, $hitPoint, $normal); + } + } + + foreach ($entities->view(CapsuleCollider3D::class) as $entityId => $capsule) { + if (($capsule->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $capsule->offset->x, + $worldPos->y + $capsule->offset->y, + $worldPos->z + $capsule->offset->z, + ); + + $t = self::rayCapsuleIntersect($origin, $direction, $center, $capsule->halfHeight, $capsule->radius); + if ($t !== null && $t >= 0 && $t < $closestDist) { + $hitPoint = new Vec3( + $origin->x + $direction->x * $t, + $origin->y + $direction->y * $t, + $origin->z + $direction->z * $t, + ); + $normal = self::capsuleNormal($hitPoint, $center, $capsule->halfHeight); + $closestDist = $t; + $closest = new Raycast3DResult($entityId, $t, $hitPoint, $normal); + } + } + + return $closest; + } + + /** + * Casts a ray and returns ALL hits (sorted by distance), not just the closest. + * + * @return array + */ + public static function castAll( + EntitiesInterface $entities, + Vec3 $origin, + Vec3 $direction, + float $maxDistance = 1000.0, + int $layerMask = 0xFFFF, + ): array { + $results = []; + + foreach ($entities->view(BoxCollider3D::class) as $entityId => $box) { + if (($box->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $box->offset->x, + $worldPos->y + $box->offset->y, + $worldPos->z + $box->offset->z, + ); + + $aabb = new AABB( + new Vec3($center->x - $box->halfExtents->x, $center->y - $box->halfExtents->y, $center->z - $box->halfExtents->z), + new Vec3($center->x + $box->halfExtents->x, $center->y + $box->halfExtents->y, $center->z + $box->halfExtents->z), + ); + + $ray = new Ray($origin, $direction); + $t = $aabb->intersectRayDistance($ray); + if ($t !== null && $t >= 0 && $t <= $maxDistance) { + $hitPoint = $ray->pointAt($t); + $results[] = new Raycast3DResult($entityId, $t, $hitPoint, self::aabbNormal($hitPoint, $aabb)); + } + } + + foreach ($entities->view(SphereCollider3D::class) as $entityId => $sphere) { + if (($sphere->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $sphere->offset->x, + $worldPos->y + $sphere->offset->y, + $worldPos->z + $sphere->offset->z, + ); + + $t = self::raySphereIntersect($origin, $direction, $center, $sphere->radius); + if ($t !== null && $t >= 0 && $t <= $maxDistance) { + $hitPoint = new Vec3( + $origin->x + $direction->x * $t, + $origin->y + $direction->y * $t, + $origin->z + $direction->z * $t, + ); + $normal = Vec3::normalized(new Vec3( + $hitPoint->x - $center->x, + $hitPoint->y - $center->y, + $hitPoint->z - $center->z, + )); + $results[] = new Raycast3DResult($entityId, $t, $hitPoint, $normal); + } + } + + foreach ($entities->view(CapsuleCollider3D::class) as $entityId => $capsule) { + if (($capsule->layer & $layerMask) === 0) continue; + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $center = new Vec3( + $worldPos->x + $capsule->offset->x, + $worldPos->y + $capsule->offset->y, + $worldPos->z + $capsule->offset->z, + ); + + $t = self::rayCapsuleIntersect($origin, $direction, $center, $capsule->halfHeight, $capsule->radius); + if ($t !== null && $t >= 0 && $t <= $maxDistance) { + $hitPoint = new Vec3( + $origin->x + $direction->x * $t, + $origin->y + $direction->y * $t, + $origin->z + $direction->z * $t, + ); + $normal = self::capsuleNormal($hitPoint, $center, $capsule->halfHeight); + $results[] = new Raycast3DResult($entityId, $t, $hitPoint, $normal); + } + } + + usort($results, fn(Raycast3DResult $a, Raycast3DResult $b) => $a->distance <=> $b->distance); + + return $results; + } + + /** + * Ray-Sphere intersection. Returns t or null. + */ + public static function raySphereIntersect(Vec3 $origin, Vec3 $dir, Vec3 $center, float $radius): ?float + { + $fx = $origin->x - $center->x; + $fy = $origin->y - $center->y; + $fz = $origin->z - $center->z; + + $a = $dir->x * $dir->x + $dir->y * $dir->y + $dir->z * $dir->z; + if ($a < 1e-12) return null; + + $b = 2.0 * ($fx * $dir->x + $fy * $dir->y + $fz * $dir->z); + $c = $fx * $fx + $fy * $fy + $fz * $fz - $radius * $radius; + + $discriminant = $b * $b - 4.0 * $a * $c; + if ($discriminant < 0) return null; + + $sqrtD = sqrt($discriminant); + $t1 = (-$b - $sqrtD) / (2.0 * $a); + $t2 = (-$b + $sqrtD) / (2.0 * $a); + + if ($t1 >= 0) return $t1; + if ($t2 >= 0) return $t2; + return null; + } + + /** + * Ray-Capsule intersection (Y-axis aligned capsule). + * A capsule is a cylinder with hemisphere caps at top (y + halfHeight) and bottom (y - halfHeight). + */ + public static function rayCapsuleIntersect(Vec3 $origin, Vec3 $dir, Vec3 $center, float $halfHeight, float $radius): ?float + { + $topCenter = new Vec3($center->x, $center->y + $halfHeight, $center->z); + $botCenter = new Vec3($center->x, $center->y - $halfHeight, $center->z); + + // test infinite cylinder (XZ plane only) + $ox = $origin->x - $center->x; + $oz = $origin->z - $center->z; + $a = $dir->x * $dir->x + $dir->z * $dir->z; + $b = 2.0 * ($ox * $dir->x + $oz * $dir->z); + $c = $ox * $ox + $oz * $oz - $radius * $radius; + + $bestT = null; + + if ($a > 1e-12) { + $disc = $b * $b - 4.0 * $a * $c; + if ($disc >= 0) { + $sqrtD = sqrt($disc); + foreach ([(-$b - $sqrtD) / (2.0 * $a), (-$b + $sqrtD) / (2.0 * $a)] as $t) { + if ($t < 0) continue; + $hitY = $origin->y + $dir->y * $t; + if ($hitY >= $botCenter->y && $hitY <= $topCenter->y) { + if ($bestT === null || $t < $bestT) $bestT = $t; + } + } + } + } + + // test top hemisphere + $t = self::raySphereIntersect($origin, $dir, $topCenter, $radius); + if ($t !== null && $t >= 0) { + $hitY = $origin->y + $dir->y * $t; + if ($hitY >= $topCenter->y && ($bestT === null || $t < $bestT)) { + $bestT = $t; + } + } + + // test bottom hemisphere + $t = self::raySphereIntersect($origin, $dir, $botCenter, $radius); + if ($t !== null && $t >= 0) { + $hitY = $origin->y + $dir->y * $t; + if ($hitY <= $botCenter->y && ($bestT === null || $t < $bestT)) { + $bestT = $t; + } + } + + return $bestT; + } + + /** + * Squared distance from a point to the nearest point on a Y-axis capsule's line segment. + */ + private static function pointCapsuleDistSq(Vec3 $point, Vec3 $center, float $halfHeight, float $radius): float + { + // clamp the point's Y projection onto the capsule's line segment + $segY = max($center->y - $halfHeight, min($center->y + $halfHeight, $point->y)); + $dx = $point->x - $center->x; + $dy = $point->y - $segY; + $dz = $point->z - $center->z; + return $dx * $dx + $dy * $dy + $dz * $dz; + } + + /** + * Compute surface normal for an AABB hit point (dominant axis). + */ + private static function aabbNormal(Vec3 $hitPoint, AABB $aabb): Vec3 + { + /** @var Vec3 $center */ + $center = $aabb->getCenter(); + $dx = $hitPoint->x - $center->x; + $dy = $hitPoint->y - $center->y; + $dz = $hitPoint->z - $center->z; + $hw = $aabb->width() * 0.5; + $hh = $aabb->height() * 0.5; + $hd = $aabb->depth() * 0.5; + + // normalize to half-extents to find dominant face + $ax = $hw > 0 ? abs($dx) / $hw : 0; + $ay = $hh > 0 ? abs($dy) / $hh : 0; + $az = $hd > 0 ? abs($dz) / $hd : 0; + + if ($ax >= $ay && $ax >= $az) { + return new Vec3($dx > 0 ? 1.0 : -1.0, 0.0, 0.0); + } + if ($ay >= $az) { + return new Vec3(0.0, $dy > 0 ? 1.0 : -1.0, 0.0); + } + return new Vec3(0.0, 0.0, $dz > 0 ? 1.0 : -1.0); + } + + /** + * Compute surface normal for a capsule hit point. + */ + private static function capsuleNormal(Vec3 $hitPoint, Vec3 $center, float $halfHeight): Vec3 + { + $segY = max($center->y - $halfHeight, min($center->y + $halfHeight, $hitPoint->y)); + $n = new Vec3( + $hitPoint->x - $center->x, + $hitPoint->y - $segY, + $hitPoint->z - $center->z, + ); + $len = sqrt($n->x * $n->x + $n->y * $n->y + $n->z * $n->z); + if ($len > 1e-8) { + $n->x /= $len; + $n->y /= $len; + $n->z /= $len; + } + return $n; + } +} diff --git a/src/Geo/Raycast3DResult.php b/src/Geo/Raycast3DResult.php new file mode 100644 index 0000000..69b9a52 --- /dev/null +++ b/src/Geo/Raycast3DResult.php @@ -0,0 +1,16 @@ + + */ + public array $times = []; + + /** + * Keyframe values (Vec3 for translation/scale, Quat for rotation) + * @var array + */ + public array $values = []; + + public function __construct(int $boneIndex, string $property) + { + $this->boneIndex = $boneIndex; + $this->property = $property; + } + + /** + * Samples the channel at the given time, returning the interpolated value. + */ + public function sample(float $time): Vec3|Quat + { + $count = count($this->times); + + if ($count === 0) { + return $this->property === 'rotation' + ? new Quat() + : new Vec3(0, 0, 0); + } + + // before first keyframe + if ($time <= $this->times[0]) { + return $this->cloneValue($this->values[0]); + } + + // after last keyframe + if ($time >= $this->times[$count - 1]) { + return $this->cloneValue($this->values[$count - 1]); + } + + // find surrounding keyframes + for ($i = 0; $i < $count - 1; $i++) { + if ($time >= $this->times[$i] && $time < $this->times[$i + 1]) { + if ($this->interpolation === AnimationInterpolation::Step) { + return $this->cloneValue($this->values[$i]); + } + + // linear interpolation + $t0 = $this->times[$i]; + $t1 = $this->times[$i + 1]; + $factor = ($time - $t0) / ($t1 - $t0); + + return $this->interpolateValues($this->values[$i], $this->values[$i + 1], $factor); + } + } + + return $this->cloneValue($this->values[$count - 1]); + } + + private function interpolateValues(Vec3|Quat $a, Vec3|Quat $b, float $t): Vec3|Quat + { + if ($a instanceof Quat && $b instanceof Quat) { + return Quat::slerp($a, $b, $t); + } + + if ($a instanceof Vec3 && $b instanceof Vec3) { + return new Vec3( + $a->x + ($b->x - $a->x) * $t, + $a->y + ($b->y - $a->y) * $t, + $a->z + ($b->z - $a->z) * $t, + ); + } + + return $this->cloneValue($a); + } + + private function cloneValue(Vec3|Quat $value): Vec3|Quat + { + if ($value instanceof Quat) { + return new Quat($value->w, $value->x, $value->y, $value->z); + } + return new Vec3($value->x, $value->y, $value->z); + } +} diff --git a/src/Graphics/Animation/AnimationClip.php b/src/Graphics/Animation/AnimationClip.php new file mode 100644 index 0000000..937ee5b --- /dev/null +++ b/src/Graphics/Animation/AnimationClip.php @@ -0,0 +1,22 @@ + + */ + public array $channels = []; + + public function __construct( + public readonly string $name, + public float $duration = 0.0, + ) { + } + + public function addChannel(AnimationChannel $channel): void + { + $this->channels[] = $channel; + } +} diff --git a/src/Graphics/Animation/AnimationInterpolation.php b/src/Graphics/Animation/AnimationInterpolation.php new file mode 100644 index 0000000..7940654 --- /dev/null +++ b/src/Graphics/Animation/AnimationInterpolation.php @@ -0,0 +1,9 @@ +inverseBindMatrix = new Mat4(); + } +} diff --git a/src/Graphics/Animation/Skeleton.php b/src/Graphics/Animation/Skeleton.php new file mode 100644 index 0000000..21c99d9 --- /dev/null +++ b/src/Graphics/Animation/Skeleton.php @@ -0,0 +1,39 @@ + + */ + public array $bones = []; + + /** + * Map bone name to bone index for fast lookup + * @var array + */ + private array $nameMap = []; + + public function addBone(Bone $bone): void + { + $this->bones[$bone->index] = $bone; + $this->nameMap[$bone->name] = $bone->index; + } + + public function getBoneByName(string $name): ?Bone + { + $index = $this->nameMap[$name] ?? null; + return $index !== null ? ($this->bones[$index] ?? null) : null; + } + + public function getBoneIndex(string $name): int + { + return $this->nameMap[$name] ?? -1; + } + + public function boneCount(): int + { + return count($this->bones); + } +} diff --git a/src/Graphics/Exception/GLValidationException.php b/src/Graphics/Exception/GLValidationException.php new file mode 100644 index 0000000..d173cd5 --- /dev/null +++ b/src/Graphics/Exception/GLValidationException.php @@ -0,0 +1,7 @@ + + */ + private array $collectedErrors = []; + + /** + * Maps GL error codes to human-readable names + */ + private static function errorName(int $error): string + { + return match ($error) { + GL_INVALID_ENUM => 'GL_INVALID_ENUM', + GL_INVALID_VALUE => 'GL_INVALID_VALUE', + GL_INVALID_OPERATION => 'GL_INVALID_OPERATION', + GL_INVALID_FRAMEBUFFER_OPERATION => 'GL_INVALID_FRAMEBUFFER_OPERATION', + GL_OUT_OF_MEMORY => 'GL_OUT_OF_MEMORY', + default => 'GL_UNKNOWN_ERROR', + }; + } + + /** + * Drains all pending GL errors and returns them. + * + * @return array + */ + public static function drainErrors(): array + { + $errors = []; + while (($e = glGetError()) !== GL_NO_ERROR) { + $errors[] = [ + 'error' => $e, + 'name' => self::errorName($e), + 'hex' => '0x' . dechex($e), + ]; + } + return $errors; + } + + /** + * Checks for GL errors and throws if any are found. + * + * @throws GLValidationException + */ + public static function check(string $context = ''): void + { + $errors = self::drainErrors(); + if (!empty($errors)) { + $names = array_map(fn($e) => $e['name'] . ' (' . $e['hex'] . ')', $errors); + $msg = implode(', ', $names); + throw new GLValidationException( + $context ? "{$context}: {$msg}" : $msg + ); + } + } + + /** + * Collects GL errors without throwing, storing them for later inspection. + */ + public function collect(string $context = ''): void + { + $errors = self::drainErrors(); + foreach ($errors as $error) { + $this->collectedErrors[] = [ + 'error' => $error['error'], + 'hex' => $error['hex'], + 'context' => $context . ': ' . $error['name'], + ]; + } + } + + /** + * Returns all collected errors. + * + * @return array + */ + public function getCollectedErrors(): array + { + return $this->collectedErrors; + } + + /** + * Returns true if errors have been collected. + */ + public function hasErrors(): bool + { + return !empty($this->collectedErrors); + } + + /** + * Clears collected errors. + */ + public function clear(): void + { + $this->collectedErrors = []; + } + + /** + * Formats all collected errors as a readable string. + */ + public function formatErrors(): string + { + if (empty($this->collectedErrors)) { + return 'No GL errors'; + } + + $lines = []; + foreach ($this->collectedErrors as $err) { + $lines[] = " [{$err['hex']}] {$err['context']}"; + } + return "GL errors:\n" . implode("\n", $lines); + } +} diff --git a/src/Graphics/Loader/GltfLoader.php b/src/Graphics/Loader/GltfLoader.php new file mode 100644 index 0000000..bb190a0 --- /dev/null +++ b/src/Graphics/Loader/GltfLoader.php @@ -0,0 +1,492 @@ +loadGlb($path); + } + + return $this->loadGltf($path); + } + + /** + * Loads a .gltf (JSON + external binary) file + */ + private function loadGltf(string $path): Model3D + { + $contents = file_get_contents($path); + if ($contents === false) { + throw new VISUException("Failed to read glTF file: {$path}"); + } + $json = json_decode($contents, true); + if (!is_array($json)) { + throw new VISUException("Failed to parse glTF JSON: {$path}"); + } + + $baseDir = dirname($path); + + // load external buffers + $buffers = []; + foreach ($json['buffers'] ?? [] as $bufferDef) { + if (isset($bufferDef['uri'])) { + if (str_starts_with($bufferDef['uri'], 'data:')) { + $buffers[] = $this->decodeDataUri($bufferDef['uri']); + } else { + $bufferPath = $baseDir . '/' . $bufferDef['uri']; + if (!file_exists($bufferPath)) { + throw new VISUException("glTF buffer file not found: {$bufferPath}"); + } + $data = file_get_contents($bufferPath); + if ($data === false) { + throw new VISUException("Failed to read glTF buffer: {$bufferPath}"); + } + $buffers[] = $data; + } + } + } + + return $this->buildModel($json, $buffers, $baseDir, basename($path)); + } + + /** + * Loads a .glb (binary glTF) file + */ + private function loadGlb(string $path): Model3D + { + $data = file_get_contents($path); + if ($data === false) { + throw new VISUException("Failed to read GLB file: {$path}"); + } + $offset = 0; + + if (strlen($data) < 12) { + throw new VISUException("Invalid GLB file (too short): {$path}"); + } + + // header: magic(4) + version(4) + length(4) = 12 bytes + /** @var array{magic: int, version: int, length: int} $header */ + $header = unpack('Vmagic/Vversion/Vlength', $data, $offset); + $offset += 12; + + if ($header['magic'] !== 0x46546C67) { + throw new VISUException("Invalid GLB magic number in: {$path}"); + } + + if ($header['version'] !== 2) { + throw new VISUException("Unsupported GLB version {$header['version']}, expected 2"); + } + + $json = null; + $buffers = []; + + while ($offset < $header['length']) { + /** @var array{chunkLength: int, chunkType: int} $chunkHeader */ + $chunkHeader = unpack('VchunkLength/VchunkType', $data, $offset); + $offset += 8; + $chunkData = substr($data, $offset, $chunkHeader['chunkLength']); + $offset += $chunkHeader['chunkLength']; + + if ($chunkHeader['chunkType'] === 0x4E4F534A) { + // JSON chunk + $json = json_decode($chunkData, true); + } elseif ($chunkHeader['chunkType'] === 0x004E4942) { + // BIN chunk + $buffers[] = $chunkData; + } + } + + if ($json === null) { + throw new VISUException("No JSON chunk found in GLB: {$path}"); + } + + return $this->buildModel($json, $buffers, dirname($path), basename($path)); + } + + /** + * Builds a Model3D from parsed glTF JSON and binary buffers + * + * @param array $json + * @param array $buffers + */ + private function buildModel(array $json, array $buffers, string $baseDir, string $name): Model3D + { + $model = new Model3D($name); + + // parse materials + $materials = $this->parseMaterials($json, $buffers, $baseDir); + + // process all meshes from the default scene + $sceneIndex = $json['scene'] ?? 0; + $scene = $json['scenes'][$sceneIndex] ?? null; + + if ($scene === null) { + throw new VISUException("No scene found in glTF"); + } + + foreach ($scene['nodes'] ?? [] as $nodeIndex) { + $this->processNode($json, $buffers, $nodeIndex, $materials, $model); + } + + $model->recalculateAABB(); + return $model; + } + + /** + * Recursively processes a glTF node and its children + * + * @param array $json + * @param array $buffers + * @param array $materials + */ + private function processNode(array $json, array $buffers, int $nodeIndex, array $materials, Model3D $model): void + { + $node = $json['nodes'][$nodeIndex] ?? null; + if ($node === null) return; + + // process mesh if present + if (isset($node['mesh'])) { + $meshDef = $json['meshes'][$node['mesh']]; + foreach ($meshDef['primitives'] ?? [] as $primitive) { + $mesh = $this->buildMesh($json, $buffers, $primitive, $materials); + if ($mesh !== null) { + $model->addMesh($mesh); + } + } + } + + // recurse children + foreach ($node['children'] ?? [] as $childIndex) { + $this->processNode($json, $buffers, $childIndex, $materials, $model); + } + } + + /** + * Builds a Mesh3D from a glTF mesh primitive + * + * @param array $json + * @param array $buffers + * @param array $primitive + * @param array $materials + */ + private function buildMesh(array $json, array $buffers, array $primitive, array $materials): ?Mesh3D + { + $attributes = $primitive['attributes'] ?? []; + + // position is required + if (!isset($attributes['POSITION'])) return null; + + $positions = $this->readAccessor($json, $buffers, $attributes['POSITION']); + $normals = isset($attributes['NORMAL']) + ? $this->readAccessor($json, $buffers, $attributes['NORMAL']) + : null; + $uvs = isset($attributes['TEXCOORD_0']) + ? $this->readAccessor($json, $buffers, $attributes['TEXCOORD_0']) + : null; + $tangents = isset($attributes['TANGENT']) + ? $this->readAccessor($json, $buffers, $attributes['TANGENT']) + : null; + + // material + $materialIndex = $primitive['material'] ?? -1; + $material = $materials[$materialIndex] ?? new Material('default'); + + // compute AABB from positions + $accessor = $json['accessors'][$attributes['POSITION']]; + $aabb = new AABB( + new Vec3( + $accessor['min'][0] ?? -1.0, + $accessor['min'][1] ?? -1.0, + $accessor['min'][2] ?? -1.0, + ), + new Vec3( + $accessor['max'][0] ?? 1.0, + $accessor['max'][1] ?? 1.0, + $accessor['max'][2] ?? 1.0, + ) + ); + + // build interleaved vertex buffer: pos(3) + normal(3) + uv(2) + tangent(4) = 12 floats + $vertexCount = count($positions) / 3; + $vertexData = new FloatBuffer(); + + for ($i = 0; $i < $vertexCount; $i++) { + // position + $vertexData->push($positions[$i * 3 + 0]); + $vertexData->push($positions[$i * 3 + 1]); + $vertexData->push($positions[$i * 3 + 2]); + + // normal + $vertexData->push($normals !== null ? $normals[$i * 3 + 0] : 0.0); + $vertexData->push($normals !== null ? $normals[$i * 3 + 1] : 1.0); + $vertexData->push($normals !== null ? $normals[$i * 3 + 2] : 0.0); + + // uv + $vertexData->push($uvs !== null ? $uvs[$i * 2 + 0] : 0.0); + $vertexData->push($uvs !== null ? $uvs[$i * 2 + 1] : 0.0); + + // tangent + $vertexData->push($tangents !== null ? $tangents[$i * 4 + 0] : 1.0); + $vertexData->push($tangents !== null ? $tangents[$i * 4 + 1] : 0.0); + $vertexData->push($tangents !== null ? $tangents[$i * 4 + 2] : 0.0); + $vertexData->push($tangents !== null ? $tangents[$i * 4 + 3] : 1.0); + } + + $mesh = new Mesh3D($this->gl, $material, $aabb); + $mesh->uploadVertices($vertexData); + + // indices + if (isset($primitive['indices'])) { + $indexData = $this->readAccessor($json, $buffers, $primitive['indices']); + $indexBuffer = new UIntBuffer(); + foreach ($indexData as $idx) { + $indexBuffer->push((int)$idx); + } + $mesh->uploadIndices($indexBuffer); + } + + return $mesh; + } + + /** + * Reads data from a glTF accessor + * + * @param array $json + * @param array $buffers + * @return array + */ + private function readAccessor(array $json, array $buffers, int $accessorIndex): array + { + $accessor = $json['accessors'][$accessorIndex]; + $bufferViewIndex = $accessor['bufferView'] ?? null; + $byteOffset = $accessor['byteOffset'] ?? 0; + $count = $accessor['count']; + $componentType = $accessor['componentType']; + $type = $accessor['type']; + + $componentCount = match ($type) { + 'SCALAR' => 1, + 'VEC2' => 2, + 'VEC3' => 3, + 'VEC4' => 4, + 'MAT2' => 4, + 'MAT3' => 9, + 'MAT4' => 16, + default => throw new VISUException("Unknown accessor type: {$type}"), + }; + + if ($bufferViewIndex === null) { + return array_fill(0, $count * $componentCount, 0); + } + + $bufferView = $json['bufferViews'][$bufferViewIndex]; + $bufferIndex = $bufferView['buffer']; + $viewByteOffset = ($bufferView['byteOffset'] ?? 0) + $byteOffset; + $byteStride = $bufferView['byteStride'] ?? 0; + + $buffer = $buffers[$bufferIndex]; + $result = []; + + $componentSize = match ($componentType) { + 5120 => 1, // BYTE + 5121 => 1, // UNSIGNED_BYTE + 5122 => 2, // SHORT + 5123 => 2, // UNSIGNED_SHORT + 5125 => 4, // UNSIGNED_INT + 5126 => 4, // FLOAT + default => throw new VISUException("Unknown component type: {$componentType}"), + }; + + $elementSize = $componentSize * $componentCount; + $stride = $byteStride > 0 ? $byteStride : $elementSize; + + for ($i = 0; $i < $count; $i++) { + $elementOffset = $viewByteOffset + $i * $stride; + + for ($j = 0; $j < $componentCount; $j++) { + $compOffset = $elementOffset + $j * $componentSize; + /** @var array $unpacked */ + $unpacked = match ($componentType) { + 5120 => unpack('c', $buffer, $compOffset), + 5121 => unpack('C', $buffer, $compOffset), + 5122 => unpack('v', $buffer, $compOffset), + 5123 => unpack('v', $buffer, $compOffset), + 5125 => unpack('V', $buffer, $compOffset), + 5126 => unpack('g', $buffer, $compOffset), // little-endian float + default => [1 => 0], + }; + $result[] = $unpacked[1]; + } + } + + return $result; + } + + /** + * Parses glTF materials + * + * @param array $json + * @param array $buffers + * @return array + */ + private function parseMaterials(array $json, array $buffers, string $baseDir): array + { + $materials = []; + + foreach ($json['materials'] ?? [] as $index => $matDef) { + $name = $matDef['name'] ?? "material_{$index}"; + $pbr = $matDef['pbrMetallicRoughness'] ?? []; + + $baseColorFactor = $pbr['baseColorFactor'] ?? [1, 1, 1, 1]; + $material = new Material( + name: $name, + albedoColor: new Vec4($baseColorFactor[0], $baseColorFactor[1], $baseColorFactor[2], $baseColorFactor[3]), + metallic: $pbr['metallicFactor'] ?? 1.0, + roughness: $pbr['roughnessFactor'] ?? 1.0, + ); + + // load textures + if (isset($pbr['baseColorTexture'])) { + $material->albedoTexture = $this->loadGltfTexture($json, $buffers, $baseDir, $pbr['baseColorTexture']['index'], true); + } + if (isset($pbr['metallicRoughnessTexture'])) { + $material->metallicRoughnessTexture = $this->loadGltfTexture($json, $buffers, $baseDir, $pbr['metallicRoughnessTexture']['index'], false); + } + if (isset($matDef['normalTexture'])) { + $material->normalTexture = $this->loadGltfTexture($json, $buffers, $baseDir, $matDef['normalTexture']['index'], false); + } + if (isset($matDef['occlusionTexture'])) { + $material->aoTexture = $this->loadGltfTexture($json, $buffers, $baseDir, $matDef['occlusionTexture']['index'], false); + } + if (isset($matDef['emissiveTexture'])) { + $material->emissiveTexture = $this->loadGltfTexture($json, $buffers, $baseDir, $matDef['emissiveTexture']['index'], true); + } + + $emissiveFactor = $matDef['emissiveFactor'] ?? [0, 0, 0]; + $material->emissiveColor = new Vec3($emissiveFactor[0], $emissiveFactor[1], $emissiveFactor[2]); + + $material->alphaMode = $matDef['alphaMode'] ?? 'OPAQUE'; + $material->alphaCutoff = $matDef['alphaCutoff'] ?? 0.5; + $material->doubleSided = $matDef['doubleSided'] ?? false; + + $materials[$index] = $material; + } + + return $materials; + } + + /** + * Loads a texture referenced by a glTF texture index + * + * @param array $json + * @param array $buffers + */ + private function loadGltfTexture(array $json, array $buffers, string $baseDir, int $textureIndex, bool $srgb): ?Texture + { + $textureDef = $json['textures'][$textureIndex] ?? null; + if ($textureDef === null) return null; + + $imageIndex = $textureDef['source'] ?? null; + if ($imageIndex === null) return null; + + $imageDef = $json['images'][$imageIndex] ?? null; + if ($imageDef === null) return null; + + $options = new TextureOptions(); + $options->isSRGB = $srgb; + + // apply sampler settings if present + if (isset($textureDef['sampler'])) { + $sampler = $json['samplers'][$textureDef['sampler']] ?? []; + if (isset($sampler['magFilter'])) $options->magFilter = $sampler['magFilter']; + if (isset($sampler['minFilter'])) $options->minFilter = $sampler['minFilter']; + if (isset($sampler['wrapS'])) $options->wrapS = $this->convertGltfWrap($sampler['wrapS']); + if (isset($sampler['wrapT'])) $options->wrapT = $this->convertGltfWrap($sampler['wrapT']); + } + + $texture = new Texture($this->gl, "gltf_{$textureIndex}"); + + if (isset($imageDef['uri'])) { + if (str_starts_with($imageDef['uri'], 'data:')) { + // embedded base64 image — not supported yet, skip + return null; + } + $imagePath = $baseDir . '/' . $imageDef['uri']; + $texture->loadFromFile($imagePath, $options); + } elseif (isset($imageDef['bufferView'])) { + // image embedded in binary buffer — write to temp file and load + $bufferView = $json['bufferViews'][$imageDef['bufferView']]; + $bufferData = $buffers[$bufferView['buffer']]; + $imageData = substr($bufferData, $bufferView['byteOffset'] ?? 0, $bufferView['byteLength']); + + $tmpFile = tempnam(sys_get_temp_dir(), 'gltf_img_'); + $ext = match ($imageDef['mimeType'] ?? '') { + 'image/png' => '.png', + 'image/jpeg' => '.jpg', + default => '.png', + }; + $tmpFile .= $ext; + file_put_contents($tmpFile, $imageData); + $texture->loadFromFile($tmpFile, $options); + unlink($tmpFile); + } else { + return null; + } + + return $texture; + } + + /** + * Converts glTF wrap mode constants to OpenGL constants + */ + private function convertGltfWrap(int $gltfWrap): int + { + return match ($gltfWrap) { + 33071 => GL_CLAMP_TO_EDGE, + 33648 => GL_MIRRORED_REPEAT, + 10497 => GL_REPEAT, + default => GL_REPEAT, + }; + } + + /** + * Decodes a data URI (data:application/octet-stream;base64,...) + */ + private function decodeDataUri(string $uri): string + { + $commaPos = strpos($uri, ','); + if ($commaPos === false) { + throw new VISUException("Invalid data URI"); + } + return base64_decode(substr($uri, $commaPos + 1)); + } +} diff --git a/src/Graphics/Material.php b/src/Graphics/Material.php new file mode 100644 index 0000000..c440985 --- /dev/null +++ b/src/Graphics/Material.php @@ -0,0 +1,116 @@ +name = $name; + $this->albedoColor = $albedoColor ?? new Vec4(1.0, 1.0, 1.0, 1.0); + $this->metallic = $metallic; + $this->roughness = $roughness; + $this->emissiveColor = new Vec3(0.0, 0.0, 0.0); + } + + /** + * Returns true if the material uses any textures + */ + public function hasTextures(): bool + { + return $this->albedoTexture !== null + || $this->normalTexture !== null + || $this->metallicRoughnessTexture !== null + || $this->aoTexture !== null + || $this->emissiveTexture !== null; + } + + /** + * Returns a bitmask of which textures are bound, for shader variant selection + */ + public function getTextureFlags(): int + { + $flags = 0; + if ($this->albedoTexture !== null) $flags |= self::FLAG_ALBEDO_MAP; + if ($this->normalTexture !== null) $flags |= self::FLAG_NORMAL_MAP; + if ($this->metallicRoughnessTexture !== null) $flags |= self::FLAG_METALLIC_ROUGHNESS_MAP; + if ($this->aoTexture !== null) $flags |= self::FLAG_AO_MAP; + if ($this->emissiveTexture !== null) $flags |= self::FLAG_EMISSIVE_MAP; + return $flags; + } + + const FLAG_ALBEDO_MAP = 1; + const FLAG_NORMAL_MAP = 2; + const FLAG_METALLIC_ROUGHNESS_MAP = 4; + const FLAG_AO_MAP = 8; + const FLAG_EMISSIVE_MAP = 16; +} diff --git a/src/Graphics/Mesh3D.php b/src/Graphics/Mesh3D.php new file mode 100644 index 0000000..b61295a --- /dev/null +++ b/src/Graphics/Mesh3D.php @@ -0,0 +1,127 @@ +material = $material; + $this->aabb = $aabb; + + glGenVertexArrays(1, $this->vertexArray); + glGenBuffers(1, $this->vertexBuffer); + + $this->gl->bindVertexArray($this->vertexArray); + $this->gl->bindVertexArrayBuffer($this->vertexBuffer); + + // position (vec3) + glVertexAttribPointer(self::ATTRIB_POSITION, 3, GL_FLOAT, false, self::STRIDE_BYTES, 0); + glEnableVertexAttribArray(self::ATTRIB_POSITION); + + // normal (vec3) + glVertexAttribPointer(self::ATTRIB_NORMAL, 3, GL_FLOAT, false, self::STRIDE_BYTES, 3 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_NORMAL); + + // uv (vec2) + glVertexAttribPointer(self::ATTRIB_UV, 2, GL_FLOAT, false, self::STRIDE_BYTES, 6 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_UV); + + // tangent (vec4 — xyz = tangent direction, w = handedness) + glVertexAttribPointer(self::ATTRIB_TANGENT, 4, GL_FLOAT, false, self::STRIDE_BYTES, 8 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_TANGENT); + } + + /** + * Uploads vertex data to the GPU + */ + public function uploadVertices(FloatBuffer $vertices): void + { + $this->vertexCount = (int)($vertices->size() / self::STRIDE); + + $this->gl->bindVertexArray($this->vertexArray); + $this->gl->bindVertexArrayBuffer($this->vertexBuffer); + glBufferData(GL_ARRAY_BUFFER, $vertices, GL_STATIC_DRAW); + } + + /** + * Uploads index data to the GPU (optional, for indexed rendering) + */ + public function uploadIndices(UIntBuffer $indices): void + { + $this->indexCount = $indices->size(); + + if ($this->indexBuffer === 0) { + glGenBuffers(1, $this->indexBuffer); + } + + $this->gl->bindVertexArray($this->vertexArray); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, $this->indexBuffer); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, $indices, GL_STATIC_DRAW); + } + + /** + * Binds the mesh VAO for rendering + */ + public function bind(): void + { + $this->gl->bindVertexArray($this->vertexArray); + } + + /** + * Draws the mesh + */ + public function draw(): void + { + $this->gl->bindVertexArray($this->vertexArray); + + if ($this->indexCount > 0) { + glDrawElements(GL_TRIANGLES, $this->indexCount, GL_UNSIGNED_INT, 0); + } else { + glDrawArrays(GL_TRIANGLES, 0, $this->vertexCount); + } + } + + public function getVertexCount(): int + { + return $this->vertexCount; + } + + public function getIndexCount(): int + { + return $this->indexCount; + } + + public function isIndexed(): bool + { + return $this->indexCount > 0; + } +} diff --git a/src/Graphics/MeshFactory.php b/src/Graphics/MeshFactory.php new file mode 100644 index 0000000..9361bcf --- /dev/null +++ b/src/Graphics/MeshFactory.php @@ -0,0 +1,532 @@ +defaultMaterial === null) { + $this->defaultMaterial = new Material( + name: 'primitive_default', + albedoColor: new Vec4(0.8, 0.8, 0.8, 1.0), + metallic: 0.0, + roughness: 0.5, + ); + } + + return $this->defaultMaterial; + } + + /** + * Creates a Model3D for the given primitive shape (requires GL context) + */ + public function createPrimitive(PrimitiveShape $shape, ?Material $material = null): Model3D + { + $material ??= $this->getDefaultMaterial(); + + return match ($shape) { + PrimitiveShape::cube => $this->createCube($material), + PrimitiveShape::sphere => $this->createSphere($material), + PrimitiveShape::plane => $this->createPlane($material), + PrimitiveShape::cylinder => $this->createCylinder($material), + PrimitiveShape::capsule => $this->createCapsule($material), + PrimitiveShape::quad => $this->createQuad($material), + PrimitiveShape::cone => $this->createCone($material), + PrimitiveShape::torus => $this->createTorus($material), + }; + } + + /** + * Creates all primitives and registers them in the given ModelCollection + */ + public function registerAll(ModelCollection $collection, ?Material $material = null): void + { + foreach (PrimitiveShape::cases() as $shape) { + if (!$collection->has($shape->modelId())) { + $model = $this->createPrimitive($shape, $material); + $model->name = $shape->modelId(); + $collection->add($model); + } + } + } + + // ----------------------------------------------------------------------- + // Create methods (geometry + GPU upload -> Model3D) + // ----------------------------------------------------------------------- + + public function createCube(Material $material, float $size = 1.0): Model3D + { + return $this->uploadGeometry('cube', $material, self::generateCube($size)); + } + + public function createSphere(Material $material, float $radius = 0.5, int $segments = 32, int $rings = 16): Model3D + { + return $this->uploadGeometry('sphere', $material, self::generateSphere($radius, $segments, $rings)); + } + + public function createPlane(Material $material, float $size = 1.0): Model3D + { + return $this->uploadGeometry('plane', $material, self::generatePlane($size)); + } + + public function createQuad(Material $material, float $size = 1.0): Model3D + { + return $this->uploadGeometry('quad', $material, self::generateQuad($size)); + } + + public function createCylinder(Material $material, float $radius = 0.5, float $height = 1.0, int $segments = 32): Model3D + { + return $this->uploadGeometry('cylinder', $material, self::generateCylinder($radius, $height, $segments)); + } + + public function createCone(Material $material, float $radius = 0.5, float $height = 1.0, int $segments = 32): Model3D + { + return $this->uploadGeometry('cone', $material, self::generateCone($radius, $height, $segments)); + } + + public function createCapsule(Material $material, float $radius = 0.25, float $height = 1.0, int $segments = 32, int $rings = 8): Model3D + { + return $this->uploadGeometry('capsule', $material, self::generateCapsule($radius, $height, $segments, $rings)); + } + + public function createTorus(Material $material, float $majorRadius = 0.35, float $minorRadius = 0.15, int $majorSegments = 32, int $minorSegments = 16): Model3D + { + return $this->uploadGeometry('torus', $material, self::generateTorus($majorRadius, $minorRadius, $majorSegments, $minorSegments)); + } + + // ----------------------------------------------------------------------- + // Static geometry generators (no GL required) + // ----------------------------------------------------------------------- + + /** + * Unit cube centered at origin + */ + public static function generateCube(float $size = 1.0): MeshGeometry + { + $h = $size * 0.5; + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + // 6 faces: [nx, ny, nz, tx, ty, tz, [[px, py, pz, u, v], ...]] + $faces = [ + [0, 0, 1, 1, 0, 0, [[-$h, -$h, $h, 0, 0], [$h, -$h, $h, 1, 0], [$h, $h, $h, 1, 1], [-$h, $h, $h, 0, 1]]], + [0, 0, -1, -1, 0, 0, [[$h, -$h, -$h, 0, 0], [-$h, -$h, -$h, 1, 0], [-$h, $h, -$h, 1, 1], [$h, $h, -$h, 0, 1]]], + [1, 0, 0, 0, 0, 1, [[$h, -$h, $h, 0, 0], [$h, -$h, -$h, 1, 0], [$h, $h, -$h, 1, 1], [$h, $h, $h, 0, 1]]], + [-1, 0, 0, 0, 0, -1, [[-$h, -$h, -$h, 0, 0], [-$h, -$h, $h, 1, 0], [-$h, $h, $h, 1, 1], [-$h, $h, -$h, 0, 1]]], + [0, 1, 0, 1, 0, 0, [[-$h, $h, $h, 0, 0], [$h, $h, $h, 1, 0], [$h, $h, -$h, 1, 1], [-$h, $h, -$h, 0, 1]]], + [0, -1, 0, 1, 0, 0, [[-$h, -$h, -$h, 0, 0], [$h, -$h, -$h, 1, 0], [$h, -$h, $h, 1, 1], [-$h, -$h, $h, 0, 1]]], + ]; + + $idx = 0; + foreach ($faces as [$nx, $ny, $nz, $tx, $ty, $tz, $verts]) { + foreach ($verts as [$px, $py, $pz, $u, $v]) { + self::pushVertex($vertices, $px, $py, $pz, $nx, $ny, $nz, $u, $v, $tx, $ty, $tz, 1.0); + } + $indices->push($idx); + $indices->push($idx + 1); + $indices->push($idx + 2); + $indices->push($idx); + $indices->push($idx + 2); + $indices->push($idx + 3); + $idx += 4; + } + + return new MeshGeometry($vertices, $indices, new AABB(new Vec3(-$h, -$h, -$h), new Vec3($h, $h, $h))); + } + + /** + * UV sphere centered at origin + */ + public static function generateSphere(float $radius = 0.5, int $segments = 32, int $rings = 16): MeshGeometry + { + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + for ($j = 0; $j <= $rings; $j++) { + $theta = $j * M_PI / $rings; + $sinTheta = sin($theta); + $cosTheta = cos($theta); + + for ($i = 0; $i <= $segments; $i++) { + $phi = $i * 2.0 * M_PI / $segments; + $sinPhi = sin($phi); + $cosPhi = cos($phi); + + $nx = $sinTheta * $cosPhi; + $ny = $cosTheta; + $nz = $sinTheta * $sinPhi; + + self::pushVertex( + $vertices, + $nx * $radius, $ny * $radius, $nz * $radius, + $nx, $ny, $nz, + $i / $segments, $j / $rings, + -$sinPhi, 0.0, $cosPhi, 1.0 + ); + } + } + + for ($j = 0; $j < $rings; $j++) { + for ($i = 0; $i < $segments; $i++) { + $a = $j * ($segments + 1) + $i; + $b = $a + $segments + 1; + $indices->push($a); + $indices->push($b); + $indices->push($a + 1); + $indices->push($b); + $indices->push($b + 1); + $indices->push($a + 1); + } + } + + return new MeshGeometry( + $vertices, $indices, + new AABB(new Vec3(-$radius, -$radius, -$radius), new Vec3($radius, $radius, $radius)) + ); + } + + /** + * Horizontal plane on Y=0 (XZ plane) + */ + public static function generatePlane(float $size = 1.0): MeshGeometry + { + $h = $size * 0.5; + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + self::pushVertex($vertices, -$h, 0, -$h, 0, 1, 0, 0, 0, 1, 0, 0, 1.0); + self::pushVertex($vertices, $h, 0, -$h, 0, 1, 0, 1, 0, 1, 0, 0, 1.0); + self::pushVertex($vertices, $h, 0, $h, 0, 1, 0, 1, 1, 1, 0, 0, 1.0); + self::pushVertex($vertices, -$h, 0, $h, 0, 1, 0, 0, 1, 1, 0, 0, 1.0); + + $indices->push(0); $indices->push(1); $indices->push(2); + $indices->push(0); $indices->push(2); $indices->push(3); + + return new MeshGeometry($vertices, $indices, new AABB(new Vec3(-$h, 0, -$h), new Vec3($h, 0, $h))); + } + + /** + * Vertical quad facing +Z (XY plane) + */ + public static function generateQuad(float $size = 1.0): MeshGeometry + { + $h = $size * 0.5; + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + self::pushVertex($vertices, -$h, -$h, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1.0); + self::pushVertex($vertices, $h, -$h, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1.0); + self::pushVertex($vertices, $h, $h, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1.0); + self::pushVertex($vertices, -$h, $h, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1.0); + + $indices->push(0); $indices->push(1); $indices->push(2); + $indices->push(0); $indices->push(2); $indices->push(3); + + return new MeshGeometry($vertices, $indices, new AABB(new Vec3(-$h, -$h, 0), new Vec3($h, $h, 0))); + } + + /** + * Cylinder along Y axis, centered at origin + */ + public static function generateCylinder(float $radius = 0.5, float $height = 1.0, int $segments = 32): MeshGeometry + { + $hh = $height * 0.5; + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + // Side + for ($i = 0; $i <= $segments; $i++) { + $angle = $i * 2.0 * M_PI / $segments; + $cos = cos($angle); + $sin = sin($angle); + $u = $i / $segments; + self::pushVertex($vertices, $cos * $radius, -$hh, $sin * $radius, $cos, 0, $sin, $u, 0, -$sin, 0, $cos, 1.0); + self::pushVertex($vertices, $cos * $radius, $hh, $sin * $radius, $cos, 0, $sin, $u, 1, -$sin, 0, $cos, 1.0); + } + + for ($i = 0; $i < $segments; $i++) { + $b = $i * 2; + $indices->push($b); $indices->push($b + 2); $indices->push($b + 1); + $indices->push($b + 1); $indices->push($b + 2); $indices->push($b + 3); + } + + $idx = ($segments + 1) * 2; + + // Top cap + $topCenter = $idx; + self::pushVertex($vertices, 0, $hh, 0, 0, 1, 0, 0.5, 0.5, 1, 0, 0, 1.0); + $idx++; + for ($i = 0; $i <= $segments; $i++) { + $angle = $i * 2.0 * M_PI / $segments; + $cos = cos($angle); + $sin = sin($angle); + self::pushVertex($vertices, $cos * $radius, $hh, $sin * $radius, 0, 1, 0, $cos * 0.5 + 0.5, $sin * 0.5 + 0.5, 1, 0, 0, 1.0); + $idx++; + } + for ($i = 0; $i < $segments; $i++) { + $indices->push($topCenter); $indices->push($topCenter + 1 + $i); $indices->push($topCenter + 2 + $i); + } + + // Bottom cap + $botCenter = $idx; + self::pushVertex($vertices, 0, -$hh, 0, 0, -1, 0, 0.5, 0.5, 1, 0, 0, 1.0); + $idx++; + for ($i = 0; $i <= $segments; $i++) { + $angle = $i * 2.0 * M_PI / $segments; + $cos = cos($angle); + $sin = sin($angle); + self::pushVertex($vertices, $cos * $radius, -$hh, $sin * $radius, 0, -1, 0, $cos * 0.5 + 0.5, $sin * 0.5 + 0.5, 1, 0, 0, 1.0); + $idx++; + } + for ($i = 0; $i < $segments; $i++) { + $indices->push($botCenter); $indices->push($botCenter + 2 + $i); $indices->push($botCenter + 1 + $i); + } + + return new MeshGeometry( + $vertices, $indices, + new AABB(new Vec3(-$radius, -$hh, -$radius), new Vec3($radius, $hh, $radius)) + ); + } + + /** + * Cone along Y axis, base at -height/2, tip at +height/2 + */ + public static function generateCone(float $radius = 0.5, float $height = 1.0, int $segments = 32): MeshGeometry + { + $hh = $height * 0.5; + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + $slope = $radius / $height; + $nLen = sqrt(1.0 + $slope * $slope); + + // Side + for ($i = 0; $i <= $segments; $i++) { + $angle = $i * 2.0 * M_PI / $segments; + $cos = cos($angle); + $sin = sin($angle); + $u = $i / $segments; + + $nx = $cos / $nLen; + $ny = $slope / $nLen; + $nz = $sin / $nLen; + + self::pushVertex($vertices, $cos * $radius, -$hh, $sin * $radius, $nx, $ny, $nz, $u, 0, -$sin, 0, $cos, 1.0); + self::pushVertex($vertices, 0, $hh, 0, $nx, $ny, $nz, $u, 1, -$sin, 0, $cos, 1.0); + } + + for ($i = 0; $i < $segments; $i++) { + $b = $i * 2; + $indices->push($b); $indices->push($b + 2); $indices->push($b + 1); + } + + $idx = ($segments + 1) * 2; + + // Bottom cap + $botCenter = $idx; + self::pushVertex($vertices, 0, -$hh, 0, 0, -1, 0, 0.5, 0.5, 1, 0, 0, 1.0); + $idx++; + for ($i = 0; $i <= $segments; $i++) { + $angle = $i * 2.0 * M_PI / $segments; + $cos = cos($angle); + $sin = sin($angle); + self::pushVertex($vertices, $cos * $radius, -$hh, $sin * $radius, 0, -1, 0, $cos * 0.5 + 0.5, $sin * 0.5 + 0.5, 1, 0, 0, 1.0); + $idx++; + } + for ($i = 0; $i < $segments; $i++) { + $indices->push($botCenter); $indices->push($botCenter + 2 + $i); $indices->push($botCenter + 1 + $i); + } + + return new MeshGeometry( + $vertices, $indices, + new AABB(new Vec3(-$radius, -$hh, -$radius), new Vec3($radius, $hh, $radius)) + ); + } + + /** + * Capsule along Y axis (cylinder + two hemispheres) + */ + public static function generateCapsule(float $radius = 0.25, float $height = 1.0, int $segments = 32, int $rings = 8): MeshGeometry + { + $cylinderHalf = max(0.0, ($height - $radius * 2.0) * 0.5); + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + // Top hemisphere + for ($j = 0; $j <= $rings; $j++) { + $theta = $j * (M_PI * 0.5) / $rings; + $sinTheta = sin($theta); + $cosTheta = cos($theta); + + for ($i = 0; $i <= $segments; $i++) { + $phi = $i * 2.0 * M_PI / $segments; + $sinPhi = sin($phi); + $cosPhi = cos($phi); + + $nx = $sinTheta * $cosPhi; + $ny = $cosTheta; + $nz = $sinTheta * $sinPhi; + + self::pushVertex( + $vertices, + $nx * $radius, $ny * $radius + $cylinderHalf, $nz * $radius, + $nx, $ny, $nz, + $i / $segments, 0.5 - ($j / ($rings * 2)), + -$sinPhi, 0, $cosPhi, 1.0 + ); + } + } + + // Cylinder section (2 rings) + for ($row = 0; $row <= 1; $row++) { + $y = $row === 0 ? $cylinderHalf : -$cylinderHalf; + for ($i = 0; $i <= $segments; $i++) { + $phi = $i * 2.0 * M_PI / $segments; + $cos = cos($phi); + $sin = sin($phi); + self::pushVertex($vertices, $cos * $radius, $y, $sin * $radius, $cos, 0, $sin, $i / $segments, 0.5, -$sin, 0, $cos, 1.0); + } + } + + // Bottom hemisphere + for ($j = 0; $j <= $rings; $j++) { + $theta = (M_PI * 0.5) + $j * (M_PI * 0.5) / $rings; + $sinTheta = sin($theta); + $cosTheta = cos($theta); + + for ($i = 0; $i <= $segments; $i++) { + $phi = $i * 2.0 * M_PI / $segments; + $sinPhi = sin($phi); + $cosPhi = cos($phi); + + $nx = $sinTheta * $cosPhi; + $ny = $cosTheta; + $nz = $sinTheta * $sinPhi; + + self::pushVertex( + $vertices, + $nx * $radius, $ny * $radius - $cylinderHalf, $nz * $radius, + $nx, $ny, $nz, + $i / $segments, 0.5 + ($j / ($rings * 2)), + -$sinPhi, 0, $cosPhi, 1.0 + ); + } + } + + // Index all ring strips + $totalRings = ($rings + 1) + 2 + ($rings + 1); + $stride = $segments + 1; + + for ($j = 0; $j < $totalRings - 1; $j++) { + for ($i = 0; $i < $segments; $i++) { + $a = $j * $stride + $i; + $b = $a + $stride; + $indices->push($a); $indices->push($b); $indices->push($a + 1); + $indices->push($b); $indices->push($b + 1); $indices->push($a + 1); + } + } + + $totalHalf = $height * 0.5; + return new MeshGeometry( + $vertices, $indices, + new AABB(new Vec3(-$radius, -$totalHalf, -$radius), new Vec3($radius, $totalHalf, $radius)) + ); + } + + /** + * Torus on XZ plane centered at origin + */ + public static function generateTorus(float $majorRadius = 0.35, float $minorRadius = 0.15, int $majorSegments = 32, int $minorSegments = 16): MeshGeometry + { + $vertices = new FloatBuffer(); + $indices = new UIntBuffer(); + + for ($j = 0; $j <= $majorSegments; $j++) { + $theta = $j * 2.0 * M_PI / $majorSegments; + $cosTheta = cos($theta); + $sinTheta = sin($theta); + + for ($i = 0; $i <= $minorSegments; $i++) { + $phi = $i * 2.0 * M_PI / $minorSegments; + $cosPhi = cos($phi); + $sinPhi = sin($phi); + + self::pushVertex( + $vertices, + ($majorRadius + $minorRadius * $cosPhi) * $cosTheta, + $minorRadius * $sinPhi, + ($majorRadius + $minorRadius * $cosPhi) * $sinTheta, + $cosPhi * $cosTheta, $sinPhi, $cosPhi * $sinTheta, + $j / $majorSegments, $i / $minorSegments, + -$sinTheta, 0.0, $cosTheta, 1.0 + ); + } + } + + $stride = $minorSegments + 1; + for ($j = 0; $j < $majorSegments; $j++) { + for ($i = 0; $i < $minorSegments; $i++) { + $a = $j * $stride + $i; + $b = $a + $stride; + $indices->push($a); $indices->push($b); $indices->push($a + 1); + $indices->push($b); $indices->push($b + 1); $indices->push($a + 1); + } + } + + $outerR = $majorRadius + $minorRadius; + return new MeshGeometry( + $vertices, $indices, + new AABB(new Vec3(-$outerR, -$minorRadius, -$outerR), new Vec3($outerR, $minorRadius, $outerR)) + ); + } + + // ----------------------------------------------------------------------- + // Internals + // ----------------------------------------------------------------------- + + private static function pushVertex( + FloatBuffer $buffer, + float $px, float $py, float $pz, + float $nx, float $ny, float $nz, + float $u, float $v, + float $tx, float $ty, float $tz, float $tw, + ): void { + $buffer->push($px); $buffer->push($py); $buffer->push($pz); + $buffer->push($nx); $buffer->push($ny); $buffer->push($nz); + $buffer->push($u); $buffer->push($v); + $buffer->push($tx); $buffer->push($ty); $buffer->push($tz); $buffer->push($tw); + } + + /** + * Uploads MeshGeometry to GPU and wraps in Model3D + */ + private function uploadGeometry(string $name, Material $material, MeshGeometry $geometry): Model3D + { + $mesh = new Mesh3D($this->gl, $material, $geometry->aabb); + $mesh->uploadVertices($geometry->vertices); + $mesh->uploadIndices($geometry->indices); + + $model = new Model3D($name); + $model->addMesh($mesh); + $model->recalculateAABB(); + + return $model; + } +} diff --git a/src/Graphics/MeshGeometry.php b/src/Graphics/MeshGeometry.php new file mode 100644 index 0000000..6560ae4 --- /dev/null +++ b/src/Graphics/MeshGeometry.php @@ -0,0 +1,59 @@ +vertices->size() / Mesh3D::STRIDE); + } + + /** + * Returns the number of indices + */ + public function getIndexCount(): int + { + return $this->indices->size(); + } + + /** + * Returns the number of triangles + */ + public function getTriangleCount(): int + { + return (int) ($this->indices->size() / 3); + } + + /** + * Validates that all indices are within vertex bounds + */ + public function validate(): bool + { + $vertexCount = $this->getVertexCount(); + for ($i = 0; $i < $this->indices->size(); $i++) { + if ($this->indices[$i] >= $vertexCount) { + return false; + } + } + return $this->indices->size() % 3 === 0 && $this->vertices->size() % Mesh3D::STRIDE === 0; + } +} diff --git a/src/Graphics/Model3D.php b/src/Graphics/Model3D.php new file mode 100644 index 0000000..17110cc --- /dev/null +++ b/src/Graphics/Model3D.php @@ -0,0 +1,52 @@ + + */ + public array $meshes = []; + + public AABB $aabb; + + public function __construct(string $name) + { + $this->name = $name; + $this->aabb = new AABB(new Vec3(0, 0, 0), new Vec3(0, 0, 0)); + } + + /** + * Adds a mesh to the model + */ + public function addMesh(Mesh3D $mesh): void + { + $this->meshes[] = $mesh; + } + + /** + * Recalculates the AABB from all meshes + */ + public function recalculateAABB(): void + { + if (empty($this->meshes)) { + $this->aabb = new AABB(new Vec3(0, 0, 0), new Vec3(0, 0, 0)); + return; + } + + $this->aabb = new AABB( + new Vec3($this->meshes[0]->aabb->min->x, $this->meshes[0]->aabb->min->y, $this->meshes[0]->aabb->min->z), + new Vec3($this->meshes[0]->aabb->max->x, $this->meshes[0]->aabb->max->y, $this->meshes[0]->aabb->max->z), + ); + + for ($i = 1; $i < count($this->meshes); $i++) { + $this->aabb->extend($this->meshes[$i]->aabb); + } + } +} diff --git a/src/Graphics/ModelCollection.php b/src/Graphics/ModelCollection.php new file mode 100644 index 0000000..7a6dd93 --- /dev/null +++ b/src/Graphics/ModelCollection.php @@ -0,0 +1,34 @@ + + */ + public array $models = []; + + public function add(Model3D $model): void + { + if (isset($this->models[$model->name])) { + throw new VISUException("Model '{$model->name}' already exists in collection"); + } + $this->models[$model->name] = $model; + } + + public function has(string $name): bool + { + return isset($this->models[$name]); + } + + public function get(string $name): Model3D + { + if (!isset($this->models[$name])) { + throw new VISUException("Model '{$name}' not found in collection"); + } + return $this->models[$name]; + } +} diff --git a/src/Graphics/Particles/ParticlePool.php b/src/Graphics/Particles/ParticlePool.php new file mode 100644 index 0000000..45aaa47 --- /dev/null +++ b/src/Graphics/Particles/ParticlePool.php @@ -0,0 +1,219 @@ + */ + public array $posX = []; + /** @var array */ + public array $posY = []; + /** @var array */ + public array $posZ = []; + /** @var array */ + public array $velX = []; + /** @var array */ + public array $velY = []; + /** @var array */ + public array $velZ = []; + /** @var array start color R */ + public array $scR = []; + /** @var array start color G */ + public array $scG = []; + /** @var array start color B */ + public array $scB = []; + /** @var array start color A */ + public array $scA = []; + /** @var array end color R */ + public array $ecR = []; + /** @var array end color G */ + public array $ecG = []; + /** @var array end color B */ + public array $ecB = []; + /** @var array end color A */ + public array $ecA = []; + /** @var array */ + public array $startSize = []; + /** @var array */ + public array $endSize = []; + /** @var array current age */ + public array $age = []; + /** @var array total lifetime */ + public array $lifetime = []; + + /** + * Number of alive particles + */ + public int $aliveCount = 0; + + /** + * Maximum particle capacity + */ + public readonly int $maxParticles; + + /** + * Instance buffer for GPU upload. + * Layout per particle: posX, posY, posZ, R, G, B, A, size (8 floats) + */ + private ?FloatBuffer $instanceBuffer = null; + + public function __construct(int $maxParticles) + { + $this->maxParticles = $maxParticles; + } + + /** + * Emits a single particle at the given index. + * Returns true if a particle slot was available. + */ + public function emit( + float $px, + float $py, + float $pz, + float $vx, + float $vy, + float $vz, + float $scR, + float $scG, + float $scB, + float $scA, + float $ecR, + float $ecG, + float $ecB, + float $ecA, + float $startSize, + float $endSize, + float $lifetime, + ): bool { + if ($this->aliveCount >= $this->maxParticles) { + return false; + } + + $i = $this->aliveCount; + $this->posX[$i] = $px; + $this->posY[$i] = $py; + $this->posZ[$i] = $pz; + $this->velX[$i] = $vx; + $this->velY[$i] = $vy; + $this->velZ[$i] = $vz; + $this->scR[$i] = $scR; + $this->scG[$i] = $scG; + $this->scB[$i] = $scB; + $this->scA[$i] = $scA; + $this->ecR[$i] = $ecR; + $this->ecG[$i] = $ecG; + $this->ecB[$i] = $ecB; + $this->ecA[$i] = $ecA; + $this->startSize[$i] = $startSize; + $this->endSize[$i] = $endSize; + $this->age[$i] = 0.0; + $this->lifetime[$i] = $lifetime; + + $this->aliveCount++; + return true; + } + + /** + * Simulates all particles: integrates velocity, applies gravity and drag, + * ages particles, and removes dead ones via swap-and-pop. + */ + public function simulate(float $deltaTime, float $gravityModifier, float $drag): void + { + $gravity = -9.81 * $gravityModifier * $deltaTime; + $dragFactor = max(0.0, 1.0 - $drag * $deltaTime); + + $i = 0; + while ($i < $this->aliveCount) { + $this->age[$i] += $deltaTime; + + // kill dead particles + if ($this->age[$i] >= $this->lifetime[$i]) { + $this->swapAndPop($i); + continue; + } + + // integrate velocity + $this->velY[$i] += $gravity; + + // apply drag + $this->velX[$i] *= $dragFactor; + $this->velY[$i] *= $dragFactor; + $this->velZ[$i] *= $dragFactor; + + // integrate position + $this->posX[$i] += $this->velX[$i] * $deltaTime; + $this->posY[$i] += $this->velY[$i] * $deltaTime; + $this->posZ[$i] += $this->velZ[$i] * $deltaTime; + + $i++; + } + } + + /** + * Builds the instance buffer for GPU upload. + * Layout: posX, posY, posZ, R, G, B, A, size (8 floats per particle) + */ + public function buildInstanceBuffer(): FloatBuffer + { + $count = $this->aliveCount; + $data = []; + + for ($i = 0; $i < $count; $i++) { + $t = $this->lifetime[$i] > 0.0 ? $this->age[$i] / $this->lifetime[$i] : 1.0; + + // interpolate color + $r = $this->scR[$i] + ($this->ecR[$i] - $this->scR[$i]) * $t; + $g = $this->scG[$i] + ($this->ecG[$i] - $this->scG[$i]) * $t; + $b = $this->scB[$i] + ($this->ecB[$i] - $this->scB[$i]) * $t; + $a = $this->scA[$i] + ($this->ecA[$i] - $this->scA[$i]) * $t; + + // interpolate size + $size = $this->startSize[$i] + ($this->endSize[$i] - $this->startSize[$i]) * $t; + + $data[] = $this->posX[$i]; + $data[] = $this->posY[$i]; + $data[] = $this->posZ[$i]; + $data[] = $r; + $data[] = $g; + $data[] = $b; + $data[] = $a; + $data[] = $size; + } + + $this->instanceBuffer = new FloatBuffer($data); + return $this->instanceBuffer; + } + + /** + * Swaps particle at index with the last alive particle, then decrements alive count. + */ + private function swapAndPop(int $index): void + { + $last = $this->aliveCount - 1; + if ($index !== $last) { + $this->posX[$index] = $this->posX[$last]; + $this->posY[$index] = $this->posY[$last]; + $this->posZ[$index] = $this->posZ[$last]; + $this->velX[$index] = $this->velX[$last]; + $this->velY[$index] = $this->velY[$last]; + $this->velZ[$index] = $this->velZ[$last]; + $this->scR[$index] = $this->scR[$last]; + $this->scG[$index] = $this->scG[$last]; + $this->scB[$index] = $this->scB[$last]; + $this->scA[$index] = $this->scA[$last]; + $this->ecR[$index] = $this->ecR[$last]; + $this->ecG[$index] = $this->ecG[$last]; + $this->ecB[$index] = $this->ecB[$last]; + $this->ecA[$index] = $this->ecA[$last]; + $this->startSize[$index] = $this->startSize[$last]; + $this->endSize[$index] = $this->endSize[$last]; + $this->age[$index] = $this->age[$last]; + $this->lifetime[$index] = $this->lifetime[$last]; + } + $this->aliveCount--; + } +} diff --git a/src/Graphics/PrimitiveShape.php b/src/Graphics/PrimitiveShape.php new file mode 100644 index 0000000..83c7f3c --- /dev/null +++ b/src/Graphics/PrimitiveShape.php @@ -0,0 +1,23 @@ +value; + } +} diff --git a/src/Graphics/Rendering/Pass/BloomPass.php b/src/Graphics/Rendering/Pass/BloomPass.php new file mode 100644 index 0000000..78db7f4 --- /dev/null +++ b/src/Graphics/Rendering/Pass/BloomPass.php @@ -0,0 +1,144 @@ +reads($this, $this->inputTexture); + + $postData = $data->create(PostProcessData::class); + + $gbufferData = $data->get(GBufferPassData::class); + $w = (int)($gbufferData->renderTarget->width * $this->downscale); + $h = (int)($gbufferData->renderTarget->height * $this->downscale); + + $hdrOptions = new TextureOptions(); + $hdrOptions->internalFormat = GL_RGBA16F; + $hdrOptions->dataFormat = GL_RGBA; + $hdrOptions->dataType = GL_FLOAT; + $hdrOptions->minFilter = GL_LINEAR; + $hdrOptions->magFilter = GL_LINEAR; + $hdrOptions->wrapS = GL_CLAMP_TO_EDGE; + $hdrOptions->wrapT = GL_CLAMP_TO_EDGE; + + // bright extract + $this->extractTarget = $pipeline->createRenderTarget('bloom_extract', $w, $h); + $this->extractTexture = $pipeline->createColorAttachment($this->extractTarget, 'bloom_bright', $hdrOptions); + + // blur H + $this->blurHTarget = $pipeline->createRenderTarget('bloom_blur_h', $w, $h); + $this->blurHTexture = $pipeline->createColorAttachment($this->blurHTarget, 'bloom_blur_h', $hdrOptions); + + // blur V + $this->blurVTarget = $pipeline->createRenderTarget('bloom_blur_v', $w, $h); + $this->blurVTexture = $pipeline->createColorAttachment($this->blurVTarget, 'bloom_blur_v', $hdrOptions); + + // composite output (full resolution) + $fullW = $gbufferData->renderTarget->width; + $fullH = $gbufferData->renderTarget->height; + $this->compositeTarget = $pipeline->createRenderTarget('bloom_composite', $fullW, $fullH); + $this->compositeTexture = $pipeline->createColorAttachment($this->compositeTarget, 'bloom_output', $hdrOptions); + + $postData->renderTarget = $this->compositeTarget; + $postData->output = $this->compositeTexture; + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + /** @var QuadVertexArray */ + $quadVA = $resources->cacheStaticResource('quadva', function (GLState $gl) { + return new QuadVertexArray($gl); + }); + + glDisable(GL_DEPTH_TEST); + + // 1. Extract bright pixels + $resources->activateRenderTarget($this->extractTarget); + $this->extractShader->use(); + $this->extractShader->setUniform1f('u_threshold', $this->threshold); + $this->extractShader->setUniform1f('u_soft_threshold', $this->softThreshold); + + $inputTex = $resources->getTexture($this->inputTexture); + $inputTex->bind(GL_TEXTURE0); + $this->extractShader->setUniform1i('u_texture', 0); + $quadVA->draw(); + + // 2. Ping-pong Gaussian blur + $w = $this->extractTarget->width; + $h = $this->extractTarget->height; + + for ($i = 0; $i < $this->blurPasses; $i++) { + // horizontal blur + $resources->activateRenderTarget($this->blurHTarget); + $this->blurShader->use(); + $this->blurShader->setUniform2f('u_direction', 1.0 / $w, 0.0); + $this->blurShader->setUniform1i('u_texture', 0); + + if ($i === 0) { + $resources->getTexture($this->extractTexture)->bind(GL_TEXTURE0); + } else { + $resources->getTexture($this->blurVTexture)->bind(GL_TEXTURE0); + } + $quadVA->draw(); + + // vertical blur + $resources->activateRenderTarget($this->blurVTarget); + $this->blurShader->use(); + $this->blurShader->setUniform2f('u_direction', 0.0, 1.0 / $h); + $this->blurShader->setUniform1i('u_texture', 0); + $resources->getTexture($this->blurHTexture)->bind(GL_TEXTURE0); + $quadVA->draw(); + } + + // 3. Composite bloom with original scene + $resources->activateRenderTarget($this->compositeTarget); + $this->compositeShader->use(); + + $inputTex->bind(GL_TEXTURE0); + $this->compositeShader->setUniform1i('u_scene', 0); + + $resources->getTexture($this->blurVTexture)->bind(GL_TEXTURE1); + $this->compositeShader->setUniform1i('u_bloom', 1); + + $this->compositeShader->setUniform1f('u_bloom_intensity', $this->intensity); + $quadVA->draw(); + } +} diff --git a/src/Graphics/Rendering/Pass/Camera2DData.php b/src/Graphics/Rendering/Pass/Camera2DData.php new file mode 100644 index 0000000..b6f5b4d --- /dev/null +++ b/src/Graphics/Rendering/Pass/Camera2DData.php @@ -0,0 +1,13 @@ +reads($this, $this->inputTexture); + $pipeline->reads($this, $this->depthTexture); + + $gbufferData = $data->get(GBufferPassData::class); + $w = (int)($gbufferData->renderTarget->width * $this->downscale); + $h = (int)($gbufferData->renderTarget->height * $this->downscale); + $fullW = $gbufferData->renderTarget->width; + $fullH = $gbufferData->renderTarget->height; + + $hdrOptions = new TextureOptions(); + $hdrOptions->internalFormat = GL_RGBA16F; + $hdrOptions->dataFormat = GL_RGBA; + $hdrOptions->dataType = GL_FLOAT; + $hdrOptions->minFilter = GL_LINEAR; + $hdrOptions->magFilter = GL_LINEAR; + $hdrOptions->wrapS = GL_CLAMP_TO_EDGE; + $hdrOptions->wrapT = GL_CLAMP_TO_EDGE; + + // blur passes at reduced resolution + $this->blurHTarget = $pipeline->createRenderTarget('dof_blur_h', $w, $h); + $this->blurHTexture = $pipeline->createColorAttachment($this->blurHTarget, 'dof_blur_h', $hdrOptions); + + $this->blurVTarget = $pipeline->createRenderTarget('dof_blur_v', $w, $h); + $this->blurVTexture = $pipeline->createColorAttachment($this->blurVTarget, 'dof_blur_v', $hdrOptions); + + // final DoF composite at full resolution + $this->dofTarget = $pipeline->createRenderTarget('dof_composite', $fullW, $fullH); + $this->dofTexture = $pipeline->createColorAttachment($this->dofTarget, 'dof_output', $hdrOptions); + + $postData = $data->create(PostProcessData::class); + $postData->renderTarget = $this->dofTarget; + $postData->output = $this->dofTexture; + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + /** @var QuadVertexArray */ + $quadVA = $resources->cacheStaticResource('quadva', function (GLState $gl) { + return new QuadVertexArray($gl); + }); + + glDisable(GL_DEPTH_TEST); + + $w = $this->blurHTarget->width; + $h = $this->blurHTarget->height; + + // horizontal blur at reduced resolution + $resources->activateRenderTarget($this->blurHTarget); + $this->blurShader->use(); + $this->blurShader->setUniform2f('u_direction', 1.0 / $w, 0.0); + $this->blurShader->setUniform1i('u_texture', 0); + $resources->getTexture($this->inputTexture)->bind(GL_TEXTURE0); + $quadVA->draw(); + + // vertical blur + $resources->activateRenderTarget($this->blurVTarget); + $this->blurShader->use(); + $this->blurShader->setUniform2f('u_direction', 0.0, 1.0 / $h); + $this->blurShader->setUniform1i('u_texture', 0); + $resources->getTexture($this->blurHTexture)->bind(GL_TEXTURE0); + $quadVA->draw(); + + // DoF composite: mix sharp and blurred based on depth + $resources->activateRenderTarget($this->dofTarget); + $this->dofShader->use(); + + $resources->getTexture($this->inputTexture)->bind(GL_TEXTURE0); + $this->dofShader->setUniform1i('u_scene', 0); + + $resources->getTexture($this->depthTexture)->bind(GL_TEXTURE1); + $this->dofShader->setUniform1i('u_depth', 1); + + $resources->getTexture($this->blurVTexture)->bind(GL_TEXTURE2); + $this->dofShader->setUniform1i('u_blurred', 2); + + $this->dofShader->setUniform1f('u_focus_distance', $this->focusDistance); + $this->dofShader->setUniform1f('u_focus_range', $this->focusRange); + $this->dofShader->setUniform1f('u_near_plane', $this->nearPlane); + $this->dofShader->setUniform1f('u_far_plane', $this->farPlane); + $this->dofShader->setUniform1f('u_max_blur', $this->maxBlur); + + $quadVA->draw(); + } +} diff --git a/src/Graphics/Rendering/Pass/MotionBlurPass.php b/src/Graphics/Rendering/Pass/MotionBlurPass.php new file mode 100644 index 0000000..fad73ef --- /dev/null +++ b/src/Graphics/Rendering/Pass/MotionBlurPass.php @@ -0,0 +1,99 @@ +reads($this, $this->inputTexture); + $pipeline->reads($this, $this->depthTexture); + + $gbufferData = $data->get(GBufferPassData::class); + + $hdrOptions = new TextureOptions(); + $hdrOptions->internalFormat = GL_RGBA16F; + $hdrOptions->dataFormat = GL_RGBA; + $hdrOptions->dataType = GL_FLOAT; + $hdrOptions->minFilter = GL_LINEAR; + $hdrOptions->magFilter = GL_LINEAR; + $hdrOptions->wrapS = GL_CLAMP_TO_EDGE; + $hdrOptions->wrapT = GL_CLAMP_TO_EDGE; + + $this->outputTarget = $pipeline->createRenderTarget( + 'motion_blur', + $gbufferData->renderTarget->width, + $gbufferData->renderTarget->height + ); + $this->outputTexture = $pipeline->createColorAttachment($this->outputTarget, 'motion_blur_output', $hdrOptions); + + $postData = $data->create(PostProcessData::class); + $postData->renderTarget = $this->outputTarget; + $postData->output = $this->outputTexture; + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + $cameraData = $data->get(CameraData::class); + + /** @var Mat4 $currentVP */ + $currentVP = $cameraData->projection * $cameraData->view; + + // on first frame, use current VP as previous (no motion blur) + $prevVP = $this->previousViewProjection ?? $currentVP; + + $currentVPInverse = $currentVP->copy(); + $currentVPInverse->inverse(); + + /** @var QuadVertexArray */ + $quadVA = $resources->cacheStaticResource('quadva', function (GLState $gl) { + return new QuadVertexArray($gl); + }); + + glDisable(GL_DEPTH_TEST); + + $resources->activateRenderTarget($this->outputTarget); + $this->motionBlurShader->use(); + + $resources->getTexture($this->inputTexture)->bind(GL_TEXTURE0); + $this->motionBlurShader->setUniform1i('u_scene', 0); + + $resources->getTexture($this->depthTexture)->bind(GL_TEXTURE1); + $this->motionBlurShader->setUniform1i('u_depth', 1); + + $this->motionBlurShader->setUniformMatrix4f('u_current_vp_inverse', false, $currentVPInverse); + $this->motionBlurShader->setUniformMatrix4f('u_previous_vp', false, $prevVP); + $this->motionBlurShader->setUniform1f('u_blur_strength', $this->blurStrength); + $this->motionBlurShader->setUniform1i('u_num_samples', $this->numSamples); + + $quadVA->draw(); + } +} diff --git a/src/Graphics/Rendering/Pass/PBRDeferredLightPass.php b/src/Graphics/Rendering/Pass/PBRDeferredLightPass.php new file mode 100644 index 0000000..02a0650 --- /dev/null +++ b/src/Graphics/Rendering/Pass/PBRDeferredLightPass.php @@ -0,0 +1,255 @@ +get(GBufferPassData::class); + $pbrGbufferData = $data->get(PBRGBufferData::class); + $lightpassData = $data->create(DeferredLightPassData::class); + + $pipeline->reads($this, $gbufferData->albedoTexture); + $pipeline->reads($this, $gbufferData->normalTexture); + $pipeline->reads($this, $gbufferData->worldSpacePositionTexture); + $pipeline->reads($this, $pbrGbufferData->metallicRoughnessTexture); + $pipeline->reads($this, $pbrGbufferData->emissiveTexture); + + $lightpassData->renderTarget = $pipeline->createRenderTarget( + 'lightpass', + $gbufferData->renderTarget->width, + $gbufferData->renderTarget->height + ); + $lightpassData->output = $pipeline->createColorAttachment( + $lightpassData->renderTarget, 'lightpass_output' + ); + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + $gbufferData = $data->get(GBufferPassData::class); + $pbrGbufferData = $data->get(PBRGBufferData::class); + $cameraData = $data->get(CameraData::class); + $lightpassData = $data->get(DeferredLightPassData::class); + $ssaoData = $data->get(SSAOData::class); + + $resources->activateRenderTarget($lightpassData->renderTarget); + + /** @var QuadVertexArray */ + $quadVA = $resources->cacheStaticResource('quadva', function(GLState $gl) { + return new QuadVertexArray($gl); + }); + + $this->lightingShader->use(); + $this->lightingShader->setUniformVec3('camera_position', $cameraData->renderCamera->transform->position); + $this->lightingShader->setUniform2f('camera_resolution', $cameraData->resolutionX, $cameraData->resolutionY); + + // directional light (sun) + $this->lightingShader->setUniformVec3('sun_direction', $this->sun->direction); + $this->lightingShader->setUniformVec3('sun_color', $this->sun->color); + $this->lightingShader->setUniform1f('sun_intensity', $this->sun->intensity); + + // view matrix for cascade depth calculation + $this->lightingShader->setUniformMatrix4f('u_view_matrix', false, $cameraData->view); + + // point lights — also track which are shadow-casting for cubemap binding + $lightIndex = 0; + /** @var array Maps shadow light index => point_lights[] array index */ + $shadowLightMapping = []; + foreach ($this->entities->view(PointLightComponent::class) as $entity => $light) { + if ($lightIndex >= self::MAX_POINT_LIGHTS) break; + + $transform = $this->entities->get($entity, Transform::class); + $worldPos = $transform->getWorldPosition($this->entities); + $prefix = "point_lights[{$lightIndex}]"; + + $this->lightingShader->setUniformVec3("{$prefix}.position", $worldPos); + $this->lightingShader->setUniformVec3("{$prefix}.color", $light->color); + $this->lightingShader->setUniform1f("{$prefix}.intensity", $light->intensity); + $this->lightingShader->setUniform1f("{$prefix}.range", $light->range); + $this->lightingShader->setUniform1f("{$prefix}.constant", $light->constantAttenuation); + $this->lightingShader->setUniform1f("{$prefix}.linear", $light->linearAttenuation); + $this->lightingShader->setUniform1f("{$prefix}.quadratic", $light->quadraticAttenuation); + + if ($light->castsShadows && count($shadowLightMapping) < PointLightShadowPass::MAX_SHADOW_POINT_LIGHTS) { + $shadowLightMapping[count($shadowLightMapping)] = $lightIndex; + } + + $lightIndex++; + } + $this->lightingShader->setUniform1i('num_point_lights', $lightIndex); + + // spot lights + $spotIndex = 0; + foreach ($this->entities->view(SpotLightComponent::class) as $entity => $spot) { + if ($spotIndex >= self::MAX_SPOT_LIGHTS) break; + + $transform = $this->entities->get($entity, Transform::class); + $worldPos = $transform->getWorldPosition($this->entities); + + // transform local direction by entity orientation + $worldDir = Quat::multiplyVec3( + $transform->getWorldOrientation($this->entities), + $spot->direction, + ); + + $prefix = "spot_lights[{$spotIndex}]"; + + $this->lightingShader->setUniformVec3("{$prefix}.position", $worldPos); + $this->lightingShader->setUniformVec3("{$prefix}.direction", $worldDir); + $this->lightingShader->setUniformVec3("{$prefix}.color", $spot->color); + $this->lightingShader->setUniform1f("{$prefix}.intensity", $spot->intensity); + $this->lightingShader->setUniform1f("{$prefix}.range", $spot->range); + $this->lightingShader->setUniform1f("{$prefix}.constant", $spot->constantAttenuation); + $this->lightingShader->setUniform1f("{$prefix}.linear", $spot->linearAttenuation); + $this->lightingShader->setUniform1f("{$prefix}.quadratic", $spot->quadraticAttenuation); + $this->lightingShader->setUniform1f("{$prefix}.innerCutoff", cos(GLM::radians($spot->innerAngle))); + $this->lightingShader->setUniform1f("{$prefix}.outerCutoff", cos(GLM::radians($spot->outerAngle))); + $spotIndex++; + } + $this->lightingShader->setUniform1i('num_spot_lights', $spotIndex); + + // bind GBuffer textures + $texUnit = 0; + $textureBindings = [ + [$gbufferData->worldSpacePositionTexture, 'gbuffer_position'], + [$gbufferData->normalTexture, 'gbuffer_normal'], + [$gbufferData->depthTexture, 'gbuffer_depth'], + [$gbufferData->albedoTexture, 'gbuffer_albedo'], + [$ssaoData->blurTexture, 'gbuffer_ao'], + [$pbrGbufferData->metallicRoughnessTexture, 'gbuffer_metallic_roughness'], + [$pbrGbufferData->emissiveTexture, 'gbuffer_emissive'], + ]; + + foreach ($textureBindings as [$texture, $name]) { + $glTexture = $resources->getTexture($texture); + $glTexture->bind(GL_TEXTURE0 + $texUnit); + $this->lightingShader->setUniform1i($name, $texUnit); + $texUnit++; + } + + // bind shadow maps and set cascade uniforms + $hasShadows = $data->has(ShadowMapData::class); + if ($hasShadows) { + $shadowData = $data->get(ShadowMapData::class); + } + if ($hasShadows && $shadowData->cascadeCount > 0) { + $this->lightingShader->setUniform1i('num_shadow_cascades', $shadowData->cascadeCount); + + for ($i = 0; $i < $shadowData->cascadeCount; $i++) { + $shadowTex = $resources->getTexture($shadowData->depthTextures[$i]); + $shadowTex->bind(GL_TEXTURE0 + $texUnit); + $this->lightingShader->setUniform1i("shadow_map_{$i}", $texUnit); + $texUnit++; + + $this->lightingShader->setUniformMatrix4f("light_space_matrices[{$i}]", false, $shadowData->lightSpaceMatrices[$i]); + $this->lightingShader->setUniform1f("cascade_splits[{$i}]", $shadowData->cascadeSplits[$i]); + } + } else { + $this->lightingShader->setUniform1i('num_shadow_cascades', 0); + } + + // ensure all shadow map samplers are bound to valid textures (macOS requires this) + /** @var \VISU\Graphics\Texture $dummyTex2D */ + $dummyTex2D = $resources->cacheStaticResource('pbr_dummy_shadow_tex', function(GLState $gl) { + $tex = new \VISU\Graphics\Texture($gl, 'dummy_shadow'); + $opts = new \VISU\Graphics\TextureOptions; + $opts->internalFormat = GL_DEPTH_COMPONENT; + $opts->dataFormat = GL_DEPTH_COMPONENT; + $opts->dataType = GL_FLOAT; + $opts->minFilter = GL_NEAREST; + $opts->magFilter = GL_NEAREST; + $opts->generateMipmaps = false; + $tex->allocateEmpty(1, 1, $opts); + return $tex; + }); + $maxCascades = ($hasShadows && $shadowData->cascadeCount > 0) ? $shadowData->cascadeCount : 0; + for ($i = $maxCascades; $i < 4; $i++) { + $dummyTex2D->bind(GL_TEXTURE0 + $texUnit); + $this->lightingShader->setUniform1i("shadow_map_{$i}", $texUnit); + $texUnit++; + } + + // bind point light cubemap shadows + $hasPointShadows = $data->has(PointLightShadowData::class); + if ($hasPointShadows) { + $pointShadowData = $data->get(PointLightShadowData::class); + } + if ($hasPointShadows && $pointShadowData->shadowLightCount > 0) { + $this->lightingShader->setUniform1i('num_point_shadow_lights', $pointShadowData->shadowLightCount); + + for ($i = 0; $i < $pointShadowData->shadowLightCount; $i++) { + // bind cubemap texture + glActiveTexture(GL_TEXTURE0 + $texUnit); + glBindTexture(GL_TEXTURE_CUBE_MAP, $pointShadowData->cubemapTextureIds[$i]); + $this->lightingShader->setUniform1i("point_shadow_map_{$i}", $texUnit); + $texUnit++; + + $this->lightingShader->setUniformVec3("point_shadow_positions[{$i}]", $pointShadowData->lightPositions[$i]); + $this->lightingShader->setUniform1f("point_shadow_far_planes[{$i}]", $pointShadowData->farPlanes[$i]); + $this->lightingShader->setUniform1i("point_shadow_light_indices[{$i}]", $shadowLightMapping[$i] ?? -1); + } + } else { + $this->lightingShader->setUniform1i('num_point_shadow_lights', 0); + } + + // ensure all point shadow cubemap samplers are bound (macOS requires complete textures) + /** @var int $dummyCubemapId */ + $dummyCubemapId = $resources->cacheStaticResource('pbr_dummy_cubemap_id', function(GLState $gl) { + $id = 0; + glGenTextures(1, $id); + glBindTexture(GL_TEXTURE_CUBE_MAP, $id); + for ($face = 0; $face < 6; $face++) { + glTexImage2D( + GL_TEXTURE_CUBE_MAP_POSITIVE_X + $face, + 0, GL_DEPTH_COMPONENT, 1, 1, 0, + GL_DEPTH_COMPONENT, GL_FLOAT, null + ); + } + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + return $id; + }); + $maxPointShadows = ($hasPointShadows && $pointShadowData->shadowLightCount > 0) ? $pointShadowData->shadowLightCount : 0; + for ($i = $maxPointShadows; $i < 4; $i++) { + glActiveTexture(GL_TEXTURE0 + $texUnit); + glBindTexture(GL_TEXTURE_CUBE_MAP, $dummyCubemapId); + $this->lightingShader->setUniform1i("point_shadow_map_{$i}", $texUnit); + $texUnit++; + } + + glDisable(GL_DEPTH_TEST); + glEnable(GL_CULL_FACE); + + $quadVA->bind(); + $quadVA->draw(); + } +} diff --git a/src/Graphics/Rendering/Pass/PBRGBufferData.php b/src/Graphics/Rendering/Pass/PBRGBufferData.php new file mode 100644 index 0000000..e5f179e --- /dev/null +++ b/src/Graphics/Rendering/Pass/PBRGBufferData.php @@ -0,0 +1,22 @@ +get(CameraData::class); + $gbufferData = $data->create(GBufferPassData::class); + + $gbufferData->renderTarget = $pipeline->createRenderTarget( + 'gbuffer', $cameraData->resolutionX, $cameraData->resolutionY + ); + + // depth + $gbufferData->depthTexture = $pipeline->createDepthAttachment($gbufferData->renderTarget); + + // world-space position (RGB32F) + $spaceTextureOptions = new TextureOptions; + $spaceTextureOptions->internalFormat = GL_RGB32F; + $spaceTextureOptions->generateMipmaps = false; + $gbufferData->worldSpacePositionTexture = $pipeline->createColorAttachment( + $gbufferData->renderTarget, 'position', $spaceTextureOptions + ); + + // view-space position (RGB32F) + $gbufferData->viewSpacePositionTexture = $pipeline->createColorAttachment( + $gbufferData->renderTarget, 'view_position', $spaceTextureOptions + ); + + // normals (RGB16F) + $normalTextureOptions = new TextureOptions; + $normalTextureOptions->internalFormat = GL_RGB16F; + $normalTextureOptions->dataFormat = GL_RGB; + $normalTextureOptions->dataType = GL_FLOAT; + $normalTextureOptions->generateMipmaps = false; + $gbufferData->normalTexture = $pipeline->createColorAttachment( + $gbufferData->renderTarget, 'normal', $normalTextureOptions + ); + + // albedo (RGBA8 — must be color-renderable, NOT GL_SRGB) + $albedoTextureOptions = new TextureOptions; + $albedoTextureOptions->internalFormat = GL_RGBA8; + $albedoTextureOptions->generateMipmaps = false; + $gbufferData->albedoTexture = $pipeline->createColorAttachment( + $gbufferData->renderTarget, 'albedo', $albedoTextureOptions + ); + + // PBR-specific attachments + $pbrData = $data->create(PBRGBufferData::class); + + // metallic (R) + roughness (G) + $mrOptions = new TextureOptions; + $mrOptions->internalFormat = GL_RG16F; + $mrOptions->dataFormat = GL_RG; + $mrOptions->dataType = GL_FLOAT; + $mrOptions->generateMipmaps = false; + $pbrData->metallicRoughnessTexture = $pipeline->createColorAttachment( + $gbufferData->renderTarget, 'metallic_roughness', $mrOptions + ); + + // emissive (RGB16F for HDR bloom support) + $emissiveOptions = new TextureOptions; + $emissiveOptions->internalFormat = GL_RGB16F; + $emissiveOptions->dataFormat = GL_RGB; + $emissiveOptions->dataType = GL_FLOAT; + $emissiveOptions->generateMipmaps = false; + $pbrData->emissiveTexture = $pipeline->createColorAttachment( + $gbufferData->renderTarget, 'emissive', $emissiveOptions + ); + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + // parent clears color + depth + parent::execute($data, $resources); + } +} diff --git a/src/Graphics/Rendering/Pass/PointLightShadowData.php b/src/Graphics/Rendering/Pass/PointLightShadowData.php new file mode 100644 index 0000000..358fec6 --- /dev/null +++ b/src/Graphics/Rendering/Pass/PointLightShadowData.php @@ -0,0 +1,36 @@ + + */ + public array $cubemapTextureIds = []; + + /** + * Far plane (= range) for each shadow-casting light + * @var array + */ + public array $farPlanes = []; + + /** + * World positions of each shadow-casting light + * @var array + */ + public array $lightPositions = []; +} diff --git a/src/Graphics/Rendering/Pass/PointLightShadowPass.php b/src/Graphics/Rendering/Pass/PointLightShadowPass.php new file mode 100644 index 0000000..cfb2c2e --- /dev/null +++ b/src/Graphics/Rendering/Pass/PointLightShadowPass.php @@ -0,0 +1,208 @@ +|null + */ + private ?array $faceDirections = null; + + public function __construct( + private ShaderProgram $depthShader, + private EntitiesInterface $entities, + private ModelCollection $modelCollection, + private int $resolution = self::DEFAULT_RESOLUTION, + ) { + } + + /** + * @return array + */ + private function getFaceDirections(): array + { + if ($this->faceDirections === null) { + $this->faceDirections = [ + [new Vec3(1, 0, 0), new Vec3(0, -1, 0)], // +X + [new Vec3(-1, 0, 0), new Vec3(0, -1, 0)], // -X + [new Vec3(0, 1, 0), new Vec3(0, 0, 1)], // +Y + [new Vec3(0, -1, 0), new Vec3(0, 0, -1)], // -Y + [new Vec3(0, 0, 1), new Vec3(0, -1, 0)], // +Z + [new Vec3(0, 0, -1), new Vec3(0, -1, 0)], // -Z + ]; + } + return $this->faceDirections; + } + + public function setup(RenderPipeline $pipeline, PipelineContainer $data): void + { + $data->create(PointLightShadowData::class); + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + $shadowData = $data->get(PointLightShadowData::class); + $shadowData->resolution = $this->resolution; + + // collect shadow-casting point lights + /** @var array */ + $shadowLights = []; + foreach ($this->entities->view(PointLightComponent::class) as $entity => $light) { + if (!$light->castsShadows) { + continue; + } + if (count($shadowLights) >= self::MAX_SHADOW_POINT_LIGHTS) { + break; + } + + $transform = $this->entities->get($entity, Transform::class); + $shadowLights[] = [ + 'position' => $transform->getWorldPosition($this->entities), + 'range' => $light->range, + ]; + } + + $shadowData->shadowLightCount = count($shadowLights); + if ($shadowData->shadowLightCount === 0) { + return; + } + + // lazily create GL resources (FBO + cubemap textures) + /** @var \stdClass */ + $glRes = $resources->cacheStaticResource('point_shadow_gl', function () { + glGenFramebuffers(1, $fbo); + $res = new \stdClass(); + $res->fbo = $fbo; + $res->cubemaps = []; + $res->resolution = 0; + $res->count = 0; + return $res; + }); + + // recreate cubemaps if resolution changed or we need more + if ($glRes->count < self::MAX_SHADOW_POINT_LIGHTS || $glRes->resolution !== $this->resolution) { + // delete old cubemaps + foreach ($glRes->cubemaps as $texId) { + glDeleteTextures(1, $texId); + } + $glRes->cubemaps = []; + + for ($i = 0; $i < self::MAX_SHADOW_POINT_LIGHTS; $i++) { + glGenTextures(1, $texId); + glBindTexture(GL_TEXTURE_CUBE_MAP, $texId); + for ($face = 0; $face < 6; $face++) { + glTexImage2D( + GL_TEXTURE_CUBE_MAP_POSITIVE_X + $face, + 0, + GL_DEPTH_COMPONENT, + $this->resolution, + $this->resolution, + 0, + GL_DEPTH_COMPONENT, + GL_FLOAT, + null + ); + } + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + $glRes->cubemaps[] = $texId; + } + $glRes->count = self::MAX_SHADOW_POINT_LIGHTS; + $glRes->resolution = $this->resolution; + } + + $faceDirections = $this->getFaceDirections(); + + $this->depthShader->use(); + glEnable(GL_DEPTH_TEST); + glCullFace(GL_FRONT); // peter panning prevention + glViewport(0, 0, $this->resolution, $this->resolution); + + for ($li = 0; $li < $shadowData->shadowLightCount; $li++) { + $lightPos = $shadowLights[$li]['position']; + $farPlane = $shadowLights[$li]['range']; + + $shadowData->lightPositions[$li] = $lightPos; + $shadowData->farPlanes[$li] = $farPlane; + $shadowData->cubemapTextureIds[$li] = $glRes->cubemaps[$li]; + + $projection = new Mat4(); + $projection->perspective(GLM::radians(90.0), 1.0, 0.1, $farPlane); + + $this->depthShader->setUniformVec3('u_light_pos', $lightPos); + $this->depthShader->setUniform1f('u_far_plane', $farPlane); + + for ($face = 0; $face < 6; $face++) { + $target = new Vec3( + $lightPos->x + $faceDirections[$face][0]->x, + $lightPos->y + $faceDirections[$face][0]->y, + $lightPos->z + $faceDirections[$face][0]->z, + ); + $view = new Mat4(); + $view->lookAt($lightPos, $target, $faceDirections[$face][1]); + + /** @var Mat4 $lightSpace */ + $lightSpace = $projection * $view; + + // attach cubemap face to FBO + glBindFramebuffer(GL_FRAMEBUFFER, $glRes->fbo); + glFramebufferTexture2D( + GL_FRAMEBUFFER, + GL_DEPTH_ATTACHMENT, + GL_TEXTURE_CUBE_MAP_POSITIVE_X + $face, + $glRes->cubemaps[$li], + 0 + ); + glDrawBuffer(GL_NONE); + glReadBuffer(GL_NONE); + glClear(GL_DEPTH_BUFFER_BIT); + + $this->depthShader->setUniformMatrix4f('u_light_space', false, $lightSpace); + + // render all shadow-casting meshes + foreach ($this->entities->view(MeshRendererComponent::class) as $entity => $renderer) { + if (!$renderer->castsShadows) { + continue; + } + if (!$this->modelCollection->has($renderer->modelIdentifier)) { + continue; + } + + $transform = $this->entities->get($entity, Transform::class); + $this->depthShader->setUniformMatrix4f('model', false, $transform->getWorldMatrix($this->entities)); + + $model = $this->modelCollection->get($renderer->modelIdentifier); + foreach ($model->meshes as $mesh) { + $mesh->draw(); + } + } + } + } + + glCullFace(GL_BACK); + + // invalidate GLState so next pass re-binds its framebuffer + $resources->gl->currentReadFramebuffer = -1; + $resources->gl->currentDrawFramebuffer = -1; + } +} diff --git a/src/Graphics/Rendering/Pass/PostProcessData.php b/src/Graphics/Rendering/Pass/PostProcessData.php new file mode 100644 index 0000000..3a415b2 --- /dev/null +++ b/src/Graphics/Rendering/Pass/PostProcessData.php @@ -0,0 +1,12 @@ + + */ + public array $renderTargets = []; + + /** + * Depth textures for each cascade + * @var array + */ + public array $depthTextures = []; + + /** + * Light-space matrices for each cascade (projection * view from light) + * @var array + */ + public array $lightSpaceMatrices = []; + + /** + * Cascade split distances (in view space, positive values) + * @var array + */ + public array $cascadeSplits = []; +} diff --git a/src/Graphics/Rendering/Pass/ShadowMapPass.php b/src/Graphics/Rendering/Pass/ShadowMapPass.php new file mode 100644 index 0000000..209abdd --- /dev/null +++ b/src/Graphics/Rendering/Pass/ShadowMapPass.php @@ -0,0 +1,219 @@ +create(ShadowMapData::class); + $shadowData->cascadeCount = $this->cascadeCount; + $shadowData->resolution = $this->resolution; + + for ($i = 0; $i < $this->cascadeCount; $i++) { + $rt = $pipeline->createRenderTarget( + "shadow_cascade_{$i}", + $this->resolution, + $this->resolution, + ); + + $depthOptions = new TextureOptions(); + $depthOptions->internalFormat = GL_DEPTH_COMPONENT; + $depthOptions->dataFormat = GL_DEPTH_COMPONENT; + $depthOptions->dataType = GL_FLOAT; + $depthOptions->minFilter = GL_LINEAR; + $depthOptions->magFilter = GL_LINEAR; + $depthOptions->wrapS = GL_CLAMP_TO_EDGE; + $depthOptions->wrapT = GL_CLAMP_TO_EDGE; + + $shadowData->renderTargets[] = $rt; + $shadowData->depthTextures[] = $pipeline->createDepthAttachment($rt, $depthOptions); + } + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + $shadowData = $data->get(ShadowMapData::class); + $cameraData = $data->get(CameraData::class); + + $camera = $cameraData->frameCamera; + $near = $camera->nearPlane; + $far = min($camera->farPlane, 200.0); + + $splits = $this->computeCascadeSplits($near, $far, $this->cascadeCount); + $shadowData->cascadeSplits = $splits; + + $lightDir = new Vec3( + $this->sun->direction->x, + $this->sun->direction->y, + $this->sun->direction->z, + ); + $lightDir->normalize(); + + $this->depthShader->use(); + + for ($i = 0; $i < $this->cascadeCount; $i++) { + $cascadeNear = $i === 0 ? $near : $splits[$i - 1]; + $cascadeFar = $splits[$i]; + + $lightSpaceMatrix = $this->computeLightSpaceMatrix( + $cameraData, $lightDir, $cascadeNear, $cascadeFar + ); + $shadowData->lightSpaceMatrices[$i] = $lightSpaceMatrix; + + $target = $resources->activateRenderTarget($shadowData->renderTargets[$i]); + $target->framebuffer()->clear(GL_DEPTH_BUFFER_BIT); + + glEnable(GL_DEPTH_TEST); + glCullFace(GL_FRONT); // peter panning prevention + + $this->depthShader->setUniformMatrix4f('u_light_space', false, $lightSpaceMatrix); + + foreach ($this->entities->view(MeshRendererComponent::class) as $entity => $renderer) { + if (!$renderer->castsShadows) continue; + if (!$this->modelCollection->has($renderer->modelIdentifier)) continue; + + $transform = $this->entities->get($entity, Transform::class); + $this->depthShader->setUniformMatrix4f('model', false, $transform->getWorldMatrix($this->entities)); + + $model = $this->modelCollection->get($renderer->modelIdentifier); + foreach ($model->meshes as $mesh) { + $mesh->draw(); + } + } + + glCullFace(GL_BACK); + } + } + + /** + * @return array + */ + private function computeCascadeSplits(float $near, float $far, int $cascadeCount): array + { + $splits = []; + for ($i = 1; $i <= $cascadeCount; $i++) { + $p = $i / $cascadeCount; + $log = $near * pow($far / $near, $p); + $uniform = $near + ($far - $near) * $p; + $splits[] = $this->cascadeLambda * $log + (1.0 - $this->cascadeLambda) * $uniform; + } + return $splits; + } + + private function computeLightSpaceMatrix( + CameraData $cameraData, + Vec3 $lightDir, + float $cascadeNear, + float $cascadeFar, + ): Mat4 { + $camera = $cameraData->frameCamera; + $aspect = $cameraData->resolutionX / max(1, $cameraData->resolutionY); + + // build cascade-specific projection matrix + $cascadeProj = new Mat4(); + $cascadeProj->perspective($camera->fieldOfView, $aspect, $cascadeNear, $cascadeFar); + + // cascade projection-view matrix, then invert to get frustum corners in world space + /** @var Mat4 */ + $cascadePV = $cascadeProj * $cameraData->view; + $invPV = Mat4::inverted($cascadePV); + + // 8 corners of the cascade frustum in world space + $corners = []; + for ($x = 0; $x < 2; $x++) { + for ($y = 0; $y < 2; $y++) { + for ($z = 0; $z < 2; $z++) { + $pt = new Vec4( + 2.0 * $x - 1.0, + 2.0 * $y - 1.0, + 2.0 * $z - 1.0, + 1.0, + ); + /** @var Vec4 $pt */ + $pt = $invPV * $pt; + $corners[] = new Vec3($pt->x / $pt->w, $pt->y / $pt->w, $pt->z / $pt->w); + } + } + } + + // frustum center + $cx = 0.0; $cy = 0.0; $cz = 0.0; + foreach ($corners as $c) { + $cx += $c->x; + $cy += $c->y; + $cz += $c->z; + } + $center = new Vec3($cx / 8.0, $cy / 8.0, $cz / 8.0); + + // light view matrix + $eye = new Vec3( + $center->x - $lightDir->x * 50.0, + $center->y - $lightDir->y * 50.0, + $center->z - $lightDir->z * 50.0, + ); + $lightView = new Mat4(); + $lightView->lookAt($eye, $center, new Vec3(0, 1, 0)); + + // bounding box in light space + $minX = PHP_FLOAT_MAX; + $maxX = -PHP_FLOAT_MAX; + $minY = PHP_FLOAT_MAX; + $maxY = -PHP_FLOAT_MAX; + $minZ = PHP_FLOAT_MAX; + $maxZ = -PHP_FLOAT_MAX; + + foreach ($corners as $corner) { + $v = new Vec4($corner->x, $corner->y, $corner->z, 1.0); + /** @var Vec4 $v */ + $v = $lightView * $v; + $minX = min($minX, $v->x); + $maxX = max($maxX, $v->x); + $minY = min($minY, $v->y); + $maxY = max($maxY, $v->y); + $minZ = min($minZ, $v->z); + $maxZ = max($maxZ, $v->z); + } + + // extend Z to capture shadow casters behind the frustum + $zExtend = ($maxZ - $minZ) * 2.0; + $minZ -= $zExtend; + + $lightProj = new Mat4(); + $lightProj->ortho($minX, $maxX, $minY, $maxY, $minZ, $maxZ); + + /** @var Mat4 */ + return $lightProj * $lightView; + } +} diff --git a/src/Graphics/Rendering/Pass/SpriteBatchPass.php b/src/Graphics/Rendering/Pass/SpriteBatchPass.php new file mode 100644 index 0000000..5ce9077 --- /dev/null +++ b/src/Graphics/Rendering/Pass/SpriteBatchPass.php @@ -0,0 +1,208 @@ + NanoVG image ID. + * + * @var array + */ + private array $nvgImages = []; + + public function __construct( + private RenderTargetResource $renderTargetRes, + private EntitiesInterface $entities, + private AssetManager $assetManager, + private SortingLayer $sortingLayer, + private Camera2DData $cameraData, + ) { + } + + public function setup(RenderPipeline $pipeline, PipelineContainer $data): void + { + $pipeline->writes($this, $this->renderTargetRes); + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + $renderTarget = $resources->getRenderTarget($this->renderTargetRes); + $resources->activateRenderTarget($this->renderTargetRes); + + $vg = \GL\VectorGraphics\VGContext::getInstance(); + if ($vg === null) { + return; + } + + $width = $renderTarget->width(); + $height = $renderTarget->height(); + $dpi = $renderTarget->contentScaleX; + + // Collect sprites with sort keys + $sprites = []; + foreach ($this->entities->view(SpriteRenderer::class) as $entityId => $sprite) { + $transform = $this->entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + + $sortKey = $this->sortingLayer->getSortKey( + $sprite->sortingLayer, + $sprite->orderInLayer, + $transform->position->y + ); + + $sprites[] = [ + 'sortKey' => $sortKey, + 'sprite' => $sprite, + 'transform' => $transform, + ]; + } + + // Sort by key (lower = rendered first = behind) + usort($sprites, fn($a, $b) => $a['sortKey'] <=> $b['sortKey']); + + // Get camera offset for world-to-screen transformation + $camX = $this->cameraData->x; + $camY = $this->cameraData->y; + $camZoom = $this->cameraData->zoom; + + // Screen center + $cx = $width / (2 * $dpi); + $cy = $height / (2 * $dpi); + + $vg->beginFrame((int)($width / $dpi), (int)($height / $dpi), $dpi); + + foreach ($sprites as $entry) { + /** @var SpriteRenderer $sprite */ + $sprite = $entry['sprite']; + /** @var Transform $transform */ + $transform = $entry['transform']; + + if ($sprite->sprite === '') { + continue; + } + + // Load texture via AssetManager + $texture = $this->assetManager->getTexture($sprite->sprite); + if ($texture === null) { + try { + $texture = $this->assetManager->loadTexture($sprite->sprite); + } catch (\Throwable) { + continue; + } + } + + // Get or create NanoVG image + $nvgImage = $this->getNvgImage($vg, $sprite->sprite, $texture); + if ($nvgImage === 0) { + continue; + } + + // Determine sprite dimensions + $spriteW = $sprite->width > 0 ? $sprite->width : $texture->width(); + $spriteH = $sprite->height > 0 ? $sprite->height : $texture->height(); + + // Apply UV rect for sprite sheets + if ($sprite->uvRect !== null) { + $spriteW = (int)($texture->width() * $sprite->uvRect[2]); + $spriteH = (int)($texture->height() * $sprite->uvRect[3]); + } + + // Apply scale + $scaleX = $transform->scale->x * ($sprite->flipX ? -1 : 1); + $scaleY = $transform->scale->y * ($sprite->flipY ? -1 : 1); + $drawW = $spriteW * abs($scaleX) * $camZoom; + $drawH = $spriteH * abs($scaleY) * $camZoom; + + // World position to screen + $screenX = $cx + ($transform->position->x - $camX) * $camZoom; + $screenY = $cy + ($transform->position->y - $camY) * $camZoom; + + // Draw centered + $drawX = $screenX - $drawW / 2; + $drawY = $screenY - $drawH / 2; + + $vg->save(); + $vg->globalAlpha($sprite->opacity * $sprite->color[3]); + + // Rotation + $euler = $transform->getLocalEulerAngles(); + if ($euler->z != 0.0) { + $vg->translate($screenX, $screenY); + $vg->rotate($euler->z); + $vg->translate(-$screenX, -$screenY); + } + + // Flip via negative scale + if ($scaleX < 0 || $scaleY < 0) { + $vg->translate($screenX, $screenY); + $vg->scale($scaleX < 0 ? -1 : 1, $scaleY < 0 ? -1 : 1); + $vg->translate(-$screenX, -$screenY); + } + + // Create image paint + if ($sprite->uvRect !== null) { + $uvX = $sprite->uvRect[0] * $texture->width(); + $uvY = $sprite->uvRect[1] * $texture->height(); + $paint = $vg->imagePattern( + $drawX - $uvX * $camZoom, + $drawY - $uvY * $camZoom, + $texture->width() * $camZoom, + $texture->height() * $camZoom, + 0, + $nvgImage, + 1.0 + ); + } else { + $paint = $vg->imagePattern( + $drawX, + $drawY, + $drawW, + $drawH, + 0, + $nvgImage, + 1.0 + ); + } + + $vg->beginPath(); + $vg->rect($drawX, $drawY, $drawW, $drawH); + $vg->fillPaint($paint); + $vg->fill(); + + $vg->restore(); + } + + $vg->endFrame(); + $resources->gl->reset(); + } + + /** + * Gets or creates a NanoVG image handle for a texture. + */ + private function getNvgImage(\GL\VectorGraphics\VGContext $vg, string $path, Texture $texture): int + { + if (isset($this->nvgImages[$path])) { + return $this->nvgImages[$path]; + } + + // Create NanoVG image from GL texture + $imgId = $vg->createImageFromHandle($texture->id, $texture->width(), $texture->height(), 0); + $this->nvgImages[$path] = $imgId; + return $imgId; + } +} diff --git a/src/Graphics/Rendering/Pass/TilemapPass.php b/src/Graphics/Rendering/Pass/TilemapPass.php new file mode 100644 index 0000000..9fb9af2 --- /dev/null +++ b/src/Graphics/Rendering/Pass/TilemapPass.php @@ -0,0 +1,161 @@ + + */ + private array $nvgImages = []; + + public function __construct( + private RenderTargetResource $renderTargetRes, + private EntitiesInterface $entities, + private AssetManager $assetManager, + private Camera2DData $cameraData, + ) { + } + + public function setup(RenderPipeline $pipeline, PipelineContainer $data): void + { + $pipeline->writes($this, $this->renderTargetRes); + } + + public function execute(PipelineContainer $data, PipelineResources $resources): void + { + $renderTarget = $resources->getRenderTarget($this->renderTargetRes); + $resources->activateRenderTarget($this->renderTargetRes); + + $vg = \GL\VectorGraphics\VGContext::getInstance(); + if ($vg === null) { + return; + } + + $width = $renderTarget->width(); + $height = $renderTarget->height(); + $dpi = $renderTarget->contentScaleX; + + $screenW = (int)($width / $dpi); + $screenH = (int)($height / $dpi); + + $camX = $this->cameraData->x; + $camY = $this->cameraData->y; + $camZoom = $this->cameraData->zoom; + + $cx = $screenW / 2.0; + $cy = $screenH / 2.0; + + $vg->beginFrame($screenW, $screenH, $dpi); + + foreach ($this->entities->view(Tilemap::class) as $entityId => $tilemap) { + if ($tilemap->tileset === '' || $tilemap->width === 0 || $tilemap->height === 0) { + continue; + } + + $texture = $this->assetManager->getTexture($tilemap->tileset); + if ($texture === null) { + try { + $texture = $this->assetManager->loadTexture($tilemap->tileset); + } catch (\Throwable) { + continue; + } + } + + $nvgImage = $this->getNvgImage($vg, $tilemap->tileset, $texture); + if ($nvgImage === 0) { + continue; + } + + // Transform offset + $transform = $this->entities->tryGet($entityId, Transform::class); + $offsetX = $transform ? $transform->position->x : 0.0; + $offsetY = $transform ? $transform->position->y : 0.0; + + $tileSize = $tilemap->tileSize; + $drawTileSize = $tileSize * $camZoom; + + // Calculate visible tile range for culling + $worldLeft = $camX - $cx / $camZoom; + $worldTop = $camY - $cy / $camZoom; + $worldRight = $camX + $cx / $camZoom; + $worldBottom = $camY + $cy / $camZoom; + + $startX = max(0, (int)(($worldLeft - $offsetX) / $tileSize) - 1); + $startY = max(0, (int)(($worldTop - $offsetY) / $tileSize) - 1); + $endX = min($tilemap->width - 1, (int)(($worldRight - $offsetX) / $tileSize) + 1); + $endY = min($tilemap->height - 1, (int)(($worldBottom - $offsetY) / $tileSize) + 1); + + $texW = $texture->width(); + $texH = $texture->height(); + + for ($ty = $startY; $ty <= $endY; $ty++) { + for ($tx = $startX; $tx <= $endX; $tx++) { + // Resolve auto-tiling if enabled, otherwise use raw tile ID + $tileId = $tilemap->autoTile + ? $tilemap->resolveAutoTile($tx, $ty) + : $tilemap->getTile($tx, $ty); + if ($tileId <= 0) { + continue; + } + + $uv = $tilemap->getTileUV($tileId, $texW, $texH); + + // World position of this tile + $worldTileX = $offsetX + $tx * $tileSize; + $worldTileY = $offsetY + $ty * $tileSize; + + // Screen position + $screenTileX = $cx + ($worldTileX - $camX) * $camZoom; + $screenTileY = $cy + ($worldTileY - $camY) * $camZoom; + + // Image pattern: offset so the UV rect maps correctly + $patternX = $screenTileX - $uv[0] * $texW * $camZoom; + $patternY = $screenTileY - $uv[1] * $texH * $camZoom; + + $paint = $vg->imagePattern( + $patternX, + $patternY, + $texW * $camZoom, + $texH * $camZoom, + 0, + $nvgImage, + 1.0 + ); + + $vg->beginPath(); + $vg->rect($screenTileX, $screenTileY, $drawTileSize, $drawTileSize); + $vg->fillPaint($paint); + $vg->fill(); + } + } + } + + $vg->endFrame(); + $resources->gl->reset(); + } + + private function getNvgImage(\GL\VectorGraphics\VGContext $vg, string $path, Texture $texture): int + { + if (isset($this->nvgImages[$path])) { + return $this->nvgImages[$path]; + } + + $imgId = $vg->createImageFromHandle($texture->id, $texture->width(), $texture->height(), 0); + $this->nvgImages[$path] = $imgId; + return $imgId; + } +} diff --git a/src/Graphics/Rendering/PostProcessStack.php b/src/Graphics/Rendering/PostProcessStack.php new file mode 100644 index 0000000..15f28d4 --- /dev/null +++ b/src/Graphics/Rendering/PostProcessStack.php @@ -0,0 +1,131 @@ +bloomExtractShader = $this->shaders->get('visu/postprocess/bloom_extract'); + $this->blurShader = $this->shaders->get('visu/postprocess/blur_gaussian'); + $this->bloomCompositeShader = $this->shaders->get('visu/postprocess/bloom_composite'); + $this->dofShader = $this->shaders->get('visu/postprocess/dof'); + $this->motionBlurShader = $this->shaders->get('visu/postprocess/motion_blur'); + } + + public function hasActiveEffects(): bool + { + return $this->bloomEnabled || $this->dofEnabled || $this->motionBlurEnabled; + } + + /** + * Attaches post-processing passes to the pipeline. + * Returns the final output texture resource to copy to the backbuffer. + */ + public function attachPasses( + RenderPipeline $pipeline, + PipelineContainer $data, + TextureResource $sceneTexture, + ): TextureResource { + $currentInput = $sceneTexture; + + $gbufferData = $data->get(GBufferPassData::class); + $depthTexture = $gbufferData->depthTexture; + + if ($this->bloomEnabled) { + $pipeline->addPass(new BloomPass( + $this->bloomExtractShader, + $this->blurShader, + $this->bloomCompositeShader, + $currentInput, + $this->bloomThreshold, + $this->bloomSoftThreshold, + $this->bloomIntensity, + $this->bloomBlurPasses, + $this->bloomDownscale, + )); + $postData = $data->get(PostProcessData::class); + $currentInput = $postData->output; + } + + if ($this->dofEnabled) { + $pipeline->addPass(new DepthOfFieldPass( + $this->blurShader, + $this->dofShader, + $currentInput, + $depthTexture, + $this->dofFocusDistance, + $this->dofFocusRange, + $this->dofNearPlane, + $this->dofFarPlane, + $this->dofMaxBlur, + $this->dofDownscale, + )); + $postData = $data->get(PostProcessData::class); + $currentInput = $postData->output; + } + + if ($this->motionBlurEnabled) { + $pipeline->addPass(new MotionBlurPass( + $this->motionBlurShader, + $currentInput, + $depthTexture, + $this->previousViewProjection, + $this->motionBlurStrength, + $this->motionBlurSamples, + )); + $postData = $data->get(PostProcessData::class); + $currentInput = $postData->output; + + // store current VP for next frame + $cameraData = $data->get(CameraData::class); + /** @var Mat4 $vp */ + $vp = $cameraData->projection * $cameraData->view; + $this->previousViewProjection = $vp->copy(); + } + + return $currentInput; + } +} diff --git a/src/Graphics/Rendering/RenderPipeline.php b/src/Graphics/Rendering/RenderPipeline.php index 99f5132..440d100 100644 --- a/src/Graphics/Rendering/RenderPipeline.php +++ b/src/Graphics/Rendering/RenderPipeline.php @@ -2,6 +2,7 @@ namespace VISU\Graphics\Rendering; +use VISU\Graphics\GLValidator; use VISU\Graphics\Rendering\Pass\BackbufferData; use VISU\Graphics\Rendering\Resource\RenderTargetResource; use VISU\Graphics\Rendering\Resource\TextureResource; @@ -28,11 +29,19 @@ class RenderPipeline /** * A internal counter for the next resource handle - * + * * @var int */ private int $resourceHandleIndex = 0; + /** + * When enabled, GL errors are checked after each pass execution. + * Use getGLValidator() to inspect collected errors. + */ + public bool $glValidationEnabled = false; + + private ?GLValidator $glValidator = null; + /** * Constrcutor */ @@ -237,12 +246,29 @@ public function execute(int $tickIndex, ?ProfilerInterface $profiler = null): vo { $this->resourceAllocator->setCurrentTick($tickIndex); + if ($this->glValidationEnabled) { + $this->glValidator = new GLValidator(); + GLValidator::drainErrors(); // start clean + } + foreach ($this->passes as $pass) { if ($profiler) $profiler->start($pass->name()); $pass->execute($this->data, $this->resourceAllocator); if ($profiler) $profiler->end($pass->name()); + + if ($this->glValidationEnabled && $this->glValidator !== null) { + $this->glValidator->collect('after ' . $pass->name()); + } } $this->resourceAllocator->collectGarbage(); } + + /** + * Returns the GL validator with collected errors (only available after execute with glValidationEnabled) + */ + public function getGLValidator(): ?GLValidator + { + return $this->glValidator; + } } diff --git a/src/Graphics/Rendering/Renderer/ParticleRenderer.php b/src/Graphics/Rendering/Renderer/ParticleRenderer.php new file mode 100644 index 0000000..6371b19 --- /dev/null +++ b/src/Graphics/Rendering/Renderer/ParticleRenderer.php @@ -0,0 +1,143 @@ +initialized) { + return; + } + + // quad vertices: 2D positions for a billboard quad + $quadData = new FloatBuffer([ + -0.5, -0.5, + 0.5, -0.5, + -0.5, 0.5, + 0.5, -0.5, + 0.5, 0.5, + -0.5, 0.5, + ]); + + glGenVertexArrays(1, $this->quadVAO); + glGenBuffers(1, $this->quadVBO); + glGenBuffers(1, $this->instanceVBO); + + $this->gl->bindVertexArray($this->quadVAO); + + // quad vertex buffer (location 0) + $this->gl->bindVertexArrayBuffer($this->quadVBO); + glBufferData(GL_ARRAY_BUFFER, $quadData, GL_STATIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, false, 2 * GL_SIZEOF_FLOAT, 0); + glEnableVertexAttribArray(0); + + // instance buffer (locations 1-3) + glBindBuffer(GL_ARRAY_BUFFER, $this->instanceVBO); + + // position (vec3) at location 1 + glVertexAttribPointer(1, 3, GL_FLOAT, false, self::INSTANCE_STRIDE_BYTES, 0); + glEnableVertexAttribArray(1); + glVertexAttribDivisor(1, 1); + + // color (vec4) at location 2 + glVertexAttribPointer(2, 4, GL_FLOAT, false, self::INSTANCE_STRIDE_BYTES, 3 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(2); + glVertexAttribDivisor(2, 1); + + // size (float) at location 3 + glVertexAttribPointer(3, 1, GL_FLOAT, false, self::INSTANCE_STRIDE_BYTES, 7 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(3); + glVertexAttribDivisor(3, 1); + + $this->initialized = true; + } + + /** + * Attaches a particle render pass to the pipeline. + * Renders all active particle emitters as billboarded quads. + */ + public function attachPass( + RenderPipeline $pipeline, + RenderTargetResource $renderTarget, + ParticleSystem $particleSystem, + ): void { + $pipeline->addPass(new CallbackPass( + 'ParticleRender', + function (RenderPass $pass, RenderPipeline $pipeline, PipelineContainer $data) use ($renderTarget) { + $pipeline->writes($pass, $renderTarget); + }, + function (PipelineContainer $data, PipelineResources $resources) use ($renderTarget, $particleSystem) { + $this->initGLResources(); + + $cameraData = $data->get(CameraData::class); + $resources->activateRenderTarget($renderTarget); + + $this->shader->use(); + $this->shader->setUniformMatrix4f('u_view', false, $cameraData->view); + $this->shader->setUniformMatrix4f('u_projection', false, $cameraData->projection); + + glEnable(GL_DEPTH_TEST); + glDepthMask(false); // don't write to depth buffer + glEnable(GL_BLEND); + + $this->gl->bindVertexArray($this->quadVAO); + + foreach ($particleSystem->getPools() as $entity => $pool) { + if ($pool->aliveCount === 0) { + continue; + } + + $instanceBuffer = $pool->buildInstanceBuffer(); + + // upload instance data + glBindBuffer(GL_ARRAY_BUFFER, $this->instanceVBO); + glBufferData(GL_ARRAY_BUFFER, $instanceBuffer, GL_DYNAMIC_DRAW); + + // determine blend mode from emitter (if still exists) + // default to alpha blending + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + $this->shader->setUniform1i('u_has_texture', 0); + + // instanced draw: 6 vertices per quad, N instances + glDrawArraysInstanced(GL_TRIANGLES, 0, 6, $pool->aliveCount); + } + + glDepthMask(true); + glDisable(GL_BLEND); + } + )); + } +} diff --git a/src/Graphics/SkinnedMesh3D.php b/src/Graphics/SkinnedMesh3D.php new file mode 100644 index 0000000..a91d94f --- /dev/null +++ b/src/Graphics/SkinnedMesh3D.php @@ -0,0 +1,125 @@ +material = $material; + $this->aabb = $aabb; + + glGenVertexArrays(1, $this->vertexArray); + glGenBuffers(1, $this->vertexBuffer); + + $this->gl->bindVertexArray($this->vertexArray); + $this->gl->bindVertexArrayBuffer($this->vertexBuffer); + + // position (vec3) + glVertexAttribPointer(self::ATTRIB_POSITION, 3, GL_FLOAT, false, self::STRIDE_BYTES, 0); + glEnableVertexAttribArray(self::ATTRIB_POSITION); + + // normal (vec3) + glVertexAttribPointer(self::ATTRIB_NORMAL, 3, GL_FLOAT, false, self::STRIDE_BYTES, 3 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_NORMAL); + + // uv (vec2) + glVertexAttribPointer(self::ATTRIB_UV, 2, GL_FLOAT, false, self::STRIDE_BYTES, 6 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_UV); + + // tangent (vec4) + glVertexAttribPointer(self::ATTRIB_TANGENT, 4, GL_FLOAT, false, self::STRIDE_BYTES, 8 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_TANGENT); + + // bone indices (vec4 — stored as float, cast to int in shader) + glVertexAttribPointer(self::ATTRIB_BONE_INDICES, 4, GL_FLOAT, false, self::STRIDE_BYTES, 12 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_BONE_INDICES); + + // bone weights (vec4) + glVertexAttribPointer(self::ATTRIB_BONE_WEIGHTS, 4, GL_FLOAT, false, self::STRIDE_BYTES, 16 * GL_SIZEOF_FLOAT); + glEnableVertexAttribArray(self::ATTRIB_BONE_WEIGHTS); + } + + public function uploadVertices(FloatBuffer $vertices): void + { + $this->vertexCount = (int)($vertices->size() / self::STRIDE); + + $this->gl->bindVertexArray($this->vertexArray); + $this->gl->bindVertexArrayBuffer($this->vertexBuffer); + glBufferData(GL_ARRAY_BUFFER, $vertices, GL_STATIC_DRAW); + } + + public function uploadIndices(UIntBuffer $indices): void + { + $this->indexCount = $indices->size(); + + if ($this->indexBuffer === 0) { + glGenBuffers(1, $this->indexBuffer); + } + + $this->gl->bindVertexArray($this->vertexArray); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, $this->indexBuffer); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, $indices, GL_STATIC_DRAW); + } + + public function bind(): void + { + $this->gl->bindVertexArray($this->vertexArray); + } + + public function draw(): void + { + $this->gl->bindVertexArray($this->vertexArray); + + if ($this->indexCount > 0) { + glDrawElements(GL_TRIANGLES, $this->indexCount, GL_UNSIGNED_INT, 0); + } else { + glDrawArrays(GL_TRIANGLES, 0, $this->vertexCount); + } + } + + public function getVertexCount(): int + { + return $this->vertexCount; + } + + public function getIndexCount(): int + { + return $this->indexCount; + } + + public function isIndexed(): bool + { + return $this->indexCount > 0; + } +} diff --git a/src/Graphics/SortingLayer.php b/src/Graphics/SortingLayer.php new file mode 100644 index 0000000..89b5d72 --- /dev/null +++ b/src/Graphics/SortingLayer.php @@ -0,0 +1,65 @@ + 0, + 'Default' => 100, + 'Foreground' => 200, + 'UI' => 300, + ]; + + /** + * Registered layers: name => order. + * + * @var array + */ + private array $layers; + + public function __construct() + { + $this->layers = self::DEFAULT_LAYERS; + } + + /** + * Adds or updates a sorting layer. + */ + public function add(string $name, int $order): void + { + $this->layers[$name] = $order; + } + + /** + * Returns the sort order for a given layer name. + */ + public function getOrder(string $name): int + { + return $this->layers[$name] ?? 100; + } + + /** + * Computes a combined sort key from layer + orderInLayer + Y position. + * Lower values are rendered first (behind higher values). + */ + public function getSortKey(string $layerName, int $orderInLayer, float $yPosition = 0.0): int + { + $layerOrder = $this->getOrder($layerName); + // Layer (high bits) | orderInLayer (mid bits) | inverted Y for top-down sorting (low bits) + // Y is inverted so that entities lower on screen (higher Y in screen space) render on top + $yKey = (int)((-$yPosition + 100000) * 10); + return ($layerOrder << 24) | (($orderInLayer + 32768) << 12) | ($yKey & 0xFFF); + } + + /** + * @return array + */ + public function getLayers(): array + { + return $this->layers; + } +} diff --git a/src/Graphics/Terrain/HeightmapTerrain.php b/src/Graphics/Terrain/HeightmapTerrain.php new file mode 100644 index 0000000..9789b48 --- /dev/null +++ b/src/Graphics/Terrain/HeightmapTerrain.php @@ -0,0 +1,135 @@ +data = $data; + } + + /** + * Builds the terrain mesh. Call once after construction or when data changes. + */ + public function buildMesh(?Material $material = null): Mesh3D + { + $data = $this->data; + $w = $data->width; + $d = $data->depth; + + $material = $material ?? new Material('terrain'); + + // compute AABB + $minY = PHP_FLOAT_MAX; + $maxY = -PHP_FLOAT_MAX; + for ($z = 0; $z < $d; $z++) { + for ($x = 0; $x < $w; $x++) { + $h = $data->getHeight($x, $z); + $minY = min($minY, $h); + $maxY = max($maxY, $h); + } + } + + $aabb = new AABB( + new Vec3(-$data->sizeX * 0.5, $minY, -$data->sizeZ * 0.5), + new Vec3($data->sizeX * 0.5, $maxY, $data->sizeZ * 0.5), + ); + + $this->mesh = new Mesh3D($this->gl, $material, $aabb); + + // build vertex data: pos(3) + normal(3) + uv(2) + tangent(4) = 12 floats + $vertices = new FloatBuffer(); + $stepX = $data->sizeX / ($w - 1); + $stepZ = $data->sizeZ / ($d - 1); + + for ($z = 0; $z < $d; $z++) { + for ($x = 0; $x < $w; $x++) { + // position + $px = -$data->sizeX * 0.5 + $x * $stepX; + $py = $data->getHeight($x, $z); + $pz = -$data->sizeZ * 0.5 + $z * $stepZ; + $vertices->push((float)$px); + $vertices->push($py); + $vertices->push((float)$pz); + + // normal from finite differences + $hL = $data->getHeight($x - 1, $z); + $hR = $data->getHeight($x + 1, $z); + $hD = $data->getHeight($x, $z - 1); + $hU = $data->getHeight($x, $z + 1); + $nx = $hL - $hR; + $ny = 2.0 * $stepX; + $nz = $hD - $hU; + $len = sqrt($nx * $nx + $ny * $ny + $nz * $nz); + if ($len > 0.0001) { + $nx /= $len; + $ny /= $len; + $nz /= $len; + } + $vertices->push((float)$nx); + $vertices->push((float)$ny); + $vertices->push((float)$nz); + + // uv (tiled based on world position) + $vertices->push($x / (float)($w - 1)); + $vertices->push($z / (float)($d - 1)); + + // tangent (along X axis in terrain space) + $vertices->push(1.0); + $vertices->push(0.0); + $vertices->push(0.0); + $vertices->push(1.0); // handedness + } + } + + $this->mesh->uploadVertices($vertices); + + // build index buffer (two triangles per grid cell) + $indices = new UIntBuffer(); + for ($z = 0; $z < $d - 1; $z++) { + for ($x = 0; $x < $w - 1; $x++) { + $topLeft = $z * $w + $x; + $topRight = $topLeft + 1; + $bottomLeft = ($z + 1) * $w + $x; + $bottomRight = $bottomLeft + 1; + + // triangle 1 + $indices->push($topLeft); + $indices->push($bottomLeft); + $indices->push($topRight); + + // triangle 2 + $indices->push($topRight); + $indices->push($bottomLeft); + $indices->push($bottomRight); + } + } + + $this->mesh->uploadIndices($indices); + + return $this->mesh; + } + + /** + * Returns the built mesh (null if not yet built) + */ + public function getMesh(): ?Mesh3D + { + return $this->mesh; + } +} diff --git a/src/Graphics/Terrain/TerrainData.php b/src/Graphics/Terrain/TerrainData.php new file mode 100644 index 0000000..107d10c --- /dev/null +++ b/src/Graphics/Terrain/TerrainData.php @@ -0,0 +1,167 @@ + + */ + private array $heights; + + /** + * Width in vertices (X axis) + */ + public readonly int $width; + + /** + * Depth in vertices (Z axis) + */ + public readonly int $depth; + + /** + * World-space size on X axis + */ + public readonly float $sizeX; + + /** + * World-space size on Z axis + */ + public readonly float $sizeZ; + + /** + * Maximum height scale + */ + public readonly float $heightScale; + + /** + * @param array $heights Row-major height values (depth rows of width values) + */ + public function __construct( + array $heights, + int $width, + int $depth, + float $sizeX = 100.0, + float $sizeZ = 100.0, + float $heightScale = 20.0, + ) { + $this->heights = $heights; + $this->width = $width; + $this->depth = $depth; + $this->sizeX = $sizeX; + $this->sizeZ = $sizeZ; + $this->heightScale = $heightScale; + } + + /** + * Returns the height at grid coordinates (clamped to bounds) + */ + public function getHeight(int $x, int $z): float + { + $x = max(0, min($x, $this->width - 1)); + $z = max(0, min($z, $this->depth - 1)); + return $this->heights[$z * $this->width + $x] * $this->heightScale; + } + + /** + * Returns the interpolated height at a world-space position. + * The terrain is centered at origin, spanning [-sizeX/2, sizeX/2] x [-sizeZ/2, sizeZ/2]. + */ + public function getHeightAtWorld(float $worldX, float $worldZ): float + { + // convert world position to grid-space [0, width-1] x [0, depth-1] + $gx = (($worldX + $this->sizeX * 0.5) / $this->sizeX) * ($this->width - 1); + $gz = (($worldZ + $this->sizeZ * 0.5) / $this->sizeZ) * ($this->depth - 1); + + $x0 = (int)floor($gx); + $z0 = (int)floor($gz); + $x1 = min($x0 + 1, $this->width - 1); + $z1 = min($z0 + 1, $this->depth - 1); + $x0 = max(0, $x0); + $z0 = max(0, $z0); + + $fx = $gx - $x0; + $fz = $gz - $z0; + + // bilinear interpolation + $h00 = $this->getHeight($x0, $z0); + $h10 = $this->getHeight($x1, $z0); + $h01 = $this->getHeight($x0, $z1); + $h11 = $this->getHeight($x1, $z1); + + $h0 = $h00 + ($h10 - $h00) * $fx; + $h1 = $h01 + ($h11 - $h01) * $fx; + + return $h0 + ($h1 - $h0) * $fz; + } + + /** + * Generates a flat terrain (all heights = 0) + */ + public static function flat(int $width, int $depth, float $sizeX = 100.0, float $sizeZ = 100.0): self + { + return new self(array_fill(0, $width * $depth, 0.0), $width, $depth, $sizeX, $sizeZ); + } + + /** + * Generates terrain from a grayscale image file. + * Pixel brightness (0-255) is mapped to [0, 1] height. + */ + public static function fromHeightmapFile( + string $path, + float $sizeX = 100.0, + float $sizeZ = 100.0, + float $heightScale = 20.0, + ): self { + if (!file_exists($path)) { + throw new \RuntimeException("Heightmap file not found: {$path}"); + } + + $info = getimagesize($path); + if ($info === false) { + throw new \RuntimeException("Failed to read heightmap image: {$path}"); + } + + $width = $info[0]; + $height = $info[1]; + + $image = match ($info[2]) { + IMAGETYPE_PNG => imagecreatefrompng($path), + IMAGETYPE_JPEG => imagecreatefromjpeg($path), + IMAGETYPE_BMP => imagecreatefrombmp($path), + default => throw new \RuntimeException("Unsupported heightmap format: {$path}"), + }; + + if ($image === false) { + throw new \RuntimeException("Failed to load heightmap image: {$path}"); + } + + $heights = []; + for ($z = 0; $z < $height; $z++) { + for ($x = 0; $x < $width; $x++) { + $rgb = imagecolorat($image, $x, $z); + if ($rgb === false) { + $heights[] = 0.0; + continue; + } + // use red channel (or grayscale luminance) + $r = ($rgb >> 16) & 0xFF; + $heights[] = $r / 255.0; + } + } + + imagedestroy($image); + + return new self($heights, $width, $height, $sizeX, $sizeZ, $heightScale); + } + + /** + * Returns all raw height values. + * @return array + */ + public function getRawHeights(): array + { + return $this->heights; + } +} diff --git a/src/Locale/LocaleManager.php b/src/Locale/LocaleManager.php new file mode 100644 index 0000000..7555591 --- /dev/null +++ b/src/Locale/LocaleManager.php @@ -0,0 +1,285 @@ +> + */ + private array $translations = []; + + private ?DispatcherInterface $dispatcher; + + public function __construct(?DispatcherInterface $dispatcher = null) + { + $this->dispatcher = $dispatcher; + } + + public function getCurrentLocale(): string + { + return $this->currentLocale; + } + + public function getFallbackLocale(): string + { + return $this->fallbackLocale; + } + + public function setFallbackLocale(string $locale): void + { + $this->fallbackLocale = $locale; + } + + /** + * Switches the active locale and dispatches a signal. + */ + public function setLocale(string $locale): void + { + $previous = $this->currentLocale; + $this->currentLocale = $locale; + + if ($previous !== $locale && $this->dispatcher !== null) { + $this->dispatcher->dispatch( + 'locale.changed', + new LocaleChangedSignal($previous, $locale) + ); + } + } + + /** + * Loads translations from a JSON file. + * The file may contain nested objects which are flattened to dot-notation keys. + * + * Example: {"menu": {"start": "Start Game"}} becomes "menu.start" => "Start Game" + */ + public function loadFile(string $locale, string $path): void + { + if (!file_exists($path)) { + throw new \RuntimeException("Translation file not found: {$path}"); + } + + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException("Failed to read translation file: {$path}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in translation file: {$path}"); + } + + $this->loadArray($locale, $data); + } + + /** + * Loads translations from a nested array. + * + * @param array $data + */ + public function loadArray(string $locale, array $data): void + { + if (!isset($this->translations[$locale])) { + $this->translations[$locale] = []; + } + + $this->flattenInto($data, '', $this->translations[$locale]); + } + + /** + * Loads all JSON files from a directory. Each file's basename (without .json) + * is used as the locale identifier. + * + * Example: loadDirectory('resources/locale/') loads en.json as "en", de.json as "de" + */ + public function loadDirectory(string $directory): void + { + $directory = rtrim($directory, '/'); + if (!is_dir($directory)) { + throw new \RuntimeException("Translation directory not found: {$directory}"); + } + + $files = glob($directory . '/*.json'); + if ($files === false) { + return; + } + + foreach ($files as $file) { + $locale = basename($file, '.json'); + $this->loadFile($locale, $file); + } + } + + /** + * Translates a key with optional parameter substitution. + * + * Parameters in the translation string use :name syntax: + * "items.count" => "You have :count items" + * get('items.count', ['count' => 5]) => "You have 5 items" + * + * @param array $params + */ + public function get(string $key, array $params = []): string + { + $text = $this->translations[$this->currentLocale][$key] + ?? $this->translations[$this->fallbackLocale][$key] + ?? $key; + + if ($params !== []) { + $text = $this->interpolateParams($text, $params); + } + + return $text; + } + + /** + * Translates a key with pluralization. + * + * Translation strings use pipe-separated forms: "singular|plural" + * For languages with more forms: "zero|one|few|many|other" + * + * Supports simple two-form (count == 1 ? first : second) and + * explicit count forms like {0}, {1}, [2,5]. + * + * @param array $params + */ + public function choice(string $key, int $count, array $params = []): string + { + $raw = $this->translations[$this->currentLocale][$key] + ?? $this->translations[$this->fallbackLocale][$key] + ?? $key; + + $text = $this->selectPluralForm($raw, $count); + + $params['count'] = $count; + + return $this->interpolateParams($text, $params); + } + + /** + * Checks whether a translation key exists for the current or fallback locale. + */ + public function has(string $key): bool + { + return isset($this->translations[$this->currentLocale][$key]) + || isset($this->translations[$this->fallbackLocale][$key]); + } + + /** + * Returns all available locales (those that have loaded translations). + * + * @return array + */ + public function getAvailableLocales(): array + { + return array_keys($this->translations); + } + + /** + * Resolves translation expressions in a string. + * Expressions use the format {t:key} or {t:key|param=value|param2=value2} + */ + public function resolveTranslations(string $text): string + { + return (string) preg_replace_callback('/\{t:([^}]+)\}/', function (array $matches): string { + $expr = $matches[1]; + $parts = explode('|', $expr); + $key = $parts[0]; + + $params = []; + for ($i = 1, $len = count($parts); $i < $len; $i++) { + $eqPos = strpos($parts[$i], '='); + if ($eqPos !== false) { + $paramName = substr($parts[$i], 0, $eqPos); + $paramValue = substr($parts[$i], $eqPos + 1); + $params[$paramName] = $paramValue; + } + } + + return $this->get($key, $params); + }, $text); + } + + /** + * @param array $data + * @param array $target + */ + private function flattenInto(array $data, string $prefix, array &$target): void + { + foreach ($data as $key => $value) { + $fullKey = $prefix === '' ? (string) $key : $prefix . '.' . $key; + if (is_array($value)) { + $this->flattenInto($value, $fullKey, $target); + } else { + $target[$fullKey] = (string) $value; + } + } + } + + /** + * @param array $params + */ + private function interpolateParams(string $text, array $params): string + { + foreach ($params as $name => $value) { + $text = str_replace(':' . $name, (string) $value, $text); + } + return $text; + } + + private function selectPluralForm(string $raw, int $count): string + { + $forms = explode('|', $raw); + + if (count($forms) === 1) { + return $forms[0]; + } + + // Check for explicit count matches: {0} None|{1} One|[2,*] Many + foreach ($forms as $form) { + $form = trim($form); + // Exact match: {N} + if (preg_match('/^\{(\d+)\}\s*(.+)$/', $form, $m)) { + if ((int) $m[1] === $count) { + return $m[2]; + } + continue; + } + // Range match: [min,max] or [min,*] + if (preg_match('/^\[(\d+),\s*(\d+|\*)\]\s*(.+)$/', $form, $m)) { + $min = (int) $m[1]; + $max = $m[2] === '*' ? PHP_INT_MAX : (int) $m[2]; + if ($count >= $min && $count <= $max) { + return $m[3]; + } + continue; + } + } + + // Simple two-form: singular|plural + if (count($forms) === 2) { + return $count === 1 ? trim($forms[0]) : trim($forms[1]); + } + + // Three forms: zero|one|many + if (count($forms) === 3) { + if ($count === 0) { + return trim($forms[0]); + } + return $count === 1 ? trim($forms[1]) : trim($forms[2]); + } + + // Fallback to last form + return trim(end($forms)); + } +} diff --git a/src/OS/GamepadAxis.php b/src/OS/GamepadAxis.php new file mode 100644 index 0000000..84c5227 --- /dev/null +++ b/src/OS/GamepadAxis.php @@ -0,0 +1,13 @@ + Map of SDL instance ID → SDL_Gamepad* */ + private array $openGamepads = []; + + /** @var array Map of sequential gamepad index → SDL instance ID */ + private array $indexToId = []; + + public function __construct( + private SDL $sdl, + private DispatcherInterface $dispatcher, + ) {} + + public function register(EntitiesInterface $entities): void + { + $this->refreshConnected(); + } + + public function unregister(EntitiesInterface $entities): void + { + foreach ($this->openGamepads as $gamepad) { + $this->sdl->ffi->SDL_CloseGamepad($gamepad); + } + $this->openGamepads = []; + $this->indexToId = []; + } + + public function update(EntitiesInterface $entities): void + { + while (($event = $this->sdl->pollEvent()) !== null) { + $this->handleEvent($event); + } + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + /** + * Returns the normalised axis value in the range -1.0 .. 1.0. + */ + public function getAxis(int $gamepadIndex, GamepadAxis $axis): float + { + $id = $this->indexToId[$gamepadIndex] ?? null; + $gamepad = $id !== null ? ($this->openGamepads[$id] ?? null) : null; + if ($gamepad === null) { + return 0.0; + } + $raw = $this->sdl->ffi->SDL_GetGamepadAxis($gamepad, $axis->value); + return $raw / self::AXIS_MAX; + } + + /** + * Returns whether the given button is currently pressed. + */ + public function isButtonPressed(int $gamepadIndex, GamepadButton $button): bool + { + $id = $this->indexToId[$gamepadIndex] ?? null; + $gamepad = $id !== null ? ($this->openGamepads[$id] ?? null) : null; + if ($gamepad === null) { + return false; + } + return (bool) $this->sdl->ffi->SDL_GetGamepadButton($gamepad, $button->value); + } + + public function getConnectedCount(): int + { + return count($this->openGamepads); + } + + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- + + /** + * @param array $event + */ + private function handleEvent(array $event): void + { + switch ($event['type']) { + case SDL::EVENT_GAMEPAD_ADDED: + $instanceId = $event['which']; + $this->openGamepad($instanceId); + break; + + case SDL::EVENT_GAMEPAD_REMOVED: + $instanceId = $event['which']; + $this->closeGamepad($instanceId); + break; + + case SDL::EVENT_GAMEPAD_AXIS_MOTION: + $instanceId = $event['which']; + $index = $this->getIndex($instanceId); + if ($index === null) { + break; + } + $raw = $event['value']; + $value = $raw / self::AXIS_MAX; + $axis = GamepadAxis::tryFrom($event['axis']); + if ($axis === null) { + break; + } + $this->dispatcher->dispatch( + self::EVENT_AXIS, + new GamepadAxisSignal($index, $axis, $value, $raw) + ); + break; + + case SDL::EVENT_GAMEPAD_BUTTON_DOWN: + case SDL::EVENT_GAMEPAD_BUTTON_UP: + $instanceId = $event['which']; + $index = $this->getIndex($instanceId); + if ($index === null) { + break; + } + $button = GamepadButton::tryFrom($event['button']); + if ($button === null) { + break; + } + $this->dispatcher->dispatch( + self::EVENT_BUTTON, + new GamepadButtonSignal($index, $button, (bool) $event['down']) + ); + break; + } + } + + private function openGamepad(int $instanceId): void + { + if (isset($this->openGamepads[$instanceId])) { + return; + } + $gamepad = $this->sdl->ffi->SDL_OpenGamepad($instanceId); + if ($gamepad === null) { + return; + } + $this->openGamepads[$instanceId] = $gamepad; + $this->rebuildIndex(); + + $index = $this->getIndex($instanceId) ?? 0; + $name = FFI::string($this->sdl->ffi->SDL_GetGamepadName($gamepad)); + $this->dispatcher->dispatch( + self::EVENT_CONNECTION, + new GamepadConnectionSignal($index, true, $name) + ); + } + + private function closeGamepad(int $instanceId): void + { + if (!isset($this->openGamepads[$instanceId])) { + return; + } + $index = $this->getIndex($instanceId) ?? 0; + $this->sdl->ffi->SDL_CloseGamepad($this->openGamepads[$instanceId]); + unset($this->openGamepads[$instanceId]); + $this->rebuildIndex(); + + $this->dispatcher->dispatch( + self::EVENT_CONNECTION, + new GamepadConnectionSignal($index, false, '') + ); + } + + private function refreshConnected(): void + { + $count = $this->sdl->ffi->new('int'); + $ids = $this->sdl->ffi->SDL_GetGamepads(FFI::addr($count)); + $numIds = (int) $count->cdata; + + for ($i = 0; $i < $numIds; $i++) { + $instanceId = $ids[$i]; + $this->openGamepad($instanceId); + } + + if ($numIds > 0) { + $this->sdl->ffi->SDL_free($ids); + } + } + + private function rebuildIndex(): void + { + $this->indexToId = array_values(array_keys($this->openGamepads)); + // Flip: index → instanceId + $this->indexToId = array_flip($this->indexToId); + // Now $this->indexToId[sequentialIndex] = instanceId + $this->indexToId = array_flip(array_flip($this->indexToId)); + } + + private function getIndex(int $instanceId): ?int + { + $flip = array_flip(array_keys($this->openGamepads)); + return $flip[$instanceId] ?? null; + } +} diff --git a/src/OS/Input.php b/src/OS/Input.php index 6bf94c3..0bf7733 100644 --- a/src/OS/Input.php +++ b/src/OS/Input.php @@ -23,7 +23,7 @@ * This class is responsible for handling window events, and includes a bunch of * helpers and utility methods to easly handle user input. */ -class Input implements WindowEventHandlerInterface +class Input implements WindowEventHandlerInterface, InputInterface { /** * We warp the GLFW state constants to make the syntax a bit more eye pleasing. @@ -105,6 +105,14 @@ class Input implements WindowEventHandlerInterface */ private array $mouseButtonsDidReleaseFrame = []; + /** + * Callback-tracked mouse button states. Updated by handleWindowMouseButton(). + * Used instead of glfwGetMouseButton polling which is unreliable in macOS fullscreen. + * + * @var array button => GLFW_PRESS|GLFW_RELEASE + */ + private array $mouseButtonStates = []; + /** * An array of all keys that have been pressed since the last poll * @@ -128,11 +136,31 @@ class Input implements WindowEventHandlerInterface /** * Same as "keysDidRelease" but for per frame state instead of per poll state - * + * * @var array */ private array $keysDidReleaseFrame = []; + /** + * When > 0, mouse button and key callbacks are ignored (decremented each endFrame). + * Used to suppress spurious events after display mode changes (fullscreen toggle). + */ + private int $suppressInputEventsFrames = 0; + + /** + * Time-based suppression: ignore input events until this timestamp. + * Complements frame-based suppression to catch late phantom events on macOS. + */ + private float $suppressInputUntil = 0.0; + + /** + * Post-suppression guard: after suppression ends, macOS may still deliver + * phantom PRESS events. We block PRESS events for a short window after + * suppression ends. This prevents phantom presses from corrupting state. + * Auto-expires after a few frames so real user clicks are never permanently blocked. + */ + private int $postSuppressionGuardFrames = 0; + /** * The event names the input class will dispatch on @@ -161,6 +189,7 @@ public function __construct( ) { $this->glfwWindowHandle = $window->getGLFWHandle(); $this->lastCursorPosition = new Vec2(0.0, 0.0); + $this->lastLeftMouseDownPosition = new Vec2(0.0, 0.0); } /** @@ -239,7 +268,9 @@ public function isKeyRepeated(int $key) : bool */ public function getMouseButtonState(int $button) : int { - return glfwGetMouseButton($this->glfwWindowHandle, $button); + // Use callback-tracked state which works reliably in both windowed and fullscreen. + // glfwGetMouseButton polling is unreliable in macOS fullscreen mode. + return $this->mouseButtonStates[$button] ?? GLFW_RELEASE; } /** @@ -556,6 +587,11 @@ public function getCursorMode() : CursorMode */ public function handleWindowKey(Window $window, int $key, int $scancode, int $action, int $mods): void { + // Ignore spurious events generated by display mode changes (fullscreen toggle) + if ($this->suppressInputEventsFrames > 0 || microtime(true) < $this->suppressInputUntil) { + return; + } + // record for did press and did release if ($action === GLFW_PRESS) { $this->keysDidPress[$key] = true; @@ -610,6 +646,28 @@ public function handleWindowCharMods(Window $window, int $char, int $mods): void */ public function handleWindowMouseButton(Window $window, int $button, int $action, int $mods): void { + // Ignore spurious events generated by display mode changes (fullscreen toggle) + if ($this->suppressInputEventsFrames > 0 || microtime(true) < $this->suppressInputUntil) { + return; + } + + // Post-suppression guard: block phantom PRESS for a short window after + // suppression ends. macOS delivers phantom PRESS events after fullscreen + // toggle. A RELEASE during the guard window immediately disables it, + // so the user's next real click works on first try. + if ($this->postSuppressionGuardFrames > 0) { + if ($action === GLFW_PRESS) { + return; + } + if ($action === GLFW_RELEASE) { + // Real or phantom RELEASE — clear guard immediately so next click works + $this->postSuppressionGuardFrames = 0; + } + } + + // track current button state from callbacks + $this->mouseButtonStates[$button] = $action; + // record for did press and did release if ($action === GLFW_PRESS) { $this->mouseButtonsDidPress[$button] = true; @@ -748,6 +806,65 @@ public function endFrame(): void $this->mouseButtonsDidReleaseFrame = []; $this->keysDidPressFrame = []; $this->keysDidReleaseFrame = []; + + $wasSuppressed = $this->suppressInputEventsFrames > 0 || microtime(true) < $this->suppressInputUntil; + + if ($this->suppressInputEventsFrames > 0) { + $this->suppressInputEventsFrames--; + } + + // When suppression just ended, force all mouse button states to RELEASE + // and activate a short post-suppression guard. macOS delivers phantom PRESS + // events even after the suppression window closes. The guard blocks PRESS + // events for a few frames, then auto-expires so real clicks always work. + $isSuppressed = $this->suppressInputEventsFrames > 0 || microtime(true) < $this->suppressInputUntil; + if ($wasSuppressed && !$isSuppressed) { + $this->mouseButtonStates = []; + $this->mouseButtonsDidPress = []; + $this->mouseButtonsDidRelease = []; + $this->mouseButtonsDidPressFrame = []; + $this->mouseButtonsDidReleaseFrame = []; + // Block phantom PRESS events for 10 frames (~0.17s at 60fps) + $this->postSuppressionGuardFrames = 10; + } + + // Decrement post-suppression guard + if ($this->postSuppressionGuardFrames > 0) { + $this->postSuppressionGuardFrames--; + } + } + + /** + * Returns true while input events are being suppressed (e.g. during fullscreen toggle). + */ + public function isInputSuppressed(): bool + { + return $this->suppressInputEventsFrames > 0 || microtime(true) < $this->suppressInputUntil; + } + + /** + * Suppress all input events for the given number of frames. + * Useful after display mode changes (fullscreen/windowed toggle) where + * GLFW may generate spurious callbacks on macOS. + */ + public function suppressInputEvents(int $frames = 3, float $seconds = 0.5): void + { + $this->suppressInputEventsFrames = $frames; + $this->suppressInputUntil = microtime(true) + $seconds; + // Clear all recorded events AND reset mouse button states to released. + // Keeping states intact would cause a stuck press if the user was + // holding a button when suppression started (the release callback + // arrives during suppression and gets dropped, leaving the button + // permanently "pressed" from FlyUI's perspective). + $this->mouseButtonStates = []; + $this->mouseButtonsDidPress = []; + $this->mouseButtonsDidRelease = []; + $this->mouseButtonsDidPressFrame = []; + $this->mouseButtonsDidReleaseFrame = []; + $this->keysDidPress = []; + $this->keysDidRelease = []; + $this->keysDidPressFrame = []; + $this->keysDidReleaseFrame = []; } /** diff --git a/src/OS/InputInterface.php b/src/OS/InputInterface.php new file mode 100644 index 0000000..be5a6a3 --- /dev/null +++ b/src/OS/InputInterface.php @@ -0,0 +1,152 @@ + + */ + public function getKeyPresses(): array; + + /** + * Returns key codes of all keys pressed this frame. + * + * @return array + */ + public function getKeyPressesThisFrame(): array; + + /** + * Get the current cursor position. + */ + public function getCursorPosition(): Vec2; + + /** + * Returns the normalized cursor position (-1.0 to 1.0). + */ + public function getNormalizedCursorPosition(): Vec2; + + /** + * Get the last received cursor position. + */ + public function getLastCursorPosition(): Vec2; + + /** + * Set the cursor position. + */ + public function setCursorPosition(Vec2 $position): void; + + /** + * Set the cursor mode (NORMAL, HIDDEN, DISABLED). + */ + public function setCursorMode(CursorMode $mode): void; + + /** + * Get the current cursor mode. + */ + public function getCursorMode(): CursorMode; + + /** + * Is the input context currently unclaimed? + */ + public function isContextUnclaimed(): bool; + + /** + * Claim the input context. + */ + public function claimContext(string $context): void; + + /** + * Release the input context. + */ + public function releaseContext(string $context): void; + + /** + * Get the current input context. + */ + public function getCurrentContext(): ?string; + + /** + * Returns true if the given input context is currently claimed. + */ + public function isClaimedContext(string $context): bool; +} diff --git a/src/Quickstart/QuickstartApp.php b/src/Quickstart/QuickstartApp.php index e4ef3d6..9a21f43 100644 --- a/src/Quickstart/QuickstartApp.php +++ b/src/Quickstart/QuickstartApp.php @@ -17,8 +17,11 @@ RenderPass, RenderPipeline }; +use VISU\Audio\AudioManager; +use VISU\OS\GamepadManager; use VISU\OS\Input; use VISU\OS\{Window, WindowHints}; +use VISU\SDL3\SDL; use VISU\Runtime\GameLoopDelegate; use VISU\Signal\Dispatcher; use VISU\OS\InputContextMap; @@ -110,6 +113,17 @@ class QuickstartApp implements GameLoopDelegate */ private ?ProfilerInterface $profiler = null; + /** + * Audio Manager (null when audio is not enabled). + * Backend auto-detected: SDL3 -> OpenAL. + */ + public ?AudioManager $audio = null; + + /** + * SDL3 Gamepad Manager (null when gamepad support is not enabled) + */ + public ?GamepadManager $gamepad = null; + /** * QuickstartApp constructor. * @@ -191,7 +205,7 @@ public function __construct( $this->entities = new EntityRegistry(); // create the vector graphics context - $this->vg = new VGContext(VGContext::ANTIALIAS); + $this->vg = new VGContext(VGContext::ANTIALIAS | VGContext::STENCIL_STROKES); // rest GL state after creating the VG context as it might change some state $this->gl->reset(); @@ -201,6 +215,35 @@ public function __construct( // create the fullscreen texture renderer $this->fullscreenTextureRenderer = new FullscreenTextureRenderer($this->gl); $this->dbgOverlayRenderer = new QuickstartDebugMetricsOverlay($this->container); + + // Resolve combined audio flag (new enableAudio OR legacy enableSDL3Audio) + $wantAudio = $options->enableAudio || $options->enableSDL3Audio; + + // initialize SDL3 subsystems if requested + $sdl = null; + if ($wantAudio || $options->enableGamepad) { + try { + $sdl = SDL::getInstance(); + $flags = 0; + if ($wantAudio) { + $flags |= SDL::INIT_AUDIO; + } + if ($options->enableGamepad) { + $flags |= SDL::INIT_GAMEPAD | SDL::INIT_EVENTS; + } + $sdl->init($flags); + } catch (\Throwable) { + // SDL3 not available — $sdl stays null, audio will try OpenAL fallback + $sdl = null; + } + } + + if ($wantAudio) { + $this->audio = AudioManager::create($sdl); + } + if ($options->enableGamepad && $sdl !== null) { + $this->gamepad = new GamepadManager($sdl, $this->dispatcher); + } } /** @@ -213,6 +256,11 @@ public function ready() : void { $this->options->ready?->__invoke($this); + // bind the gamepad manager as a system if enabled so it gets registered + if ($this->gamepad !== null) { + $this->bindSystem($this->gamepad); + } + // auto register all system from the registry $this->registerSystems($this->entities); @@ -239,6 +287,14 @@ public function update() : void // poll for new events $this->window->pollEvents(); + // update SDL3 audio stream + $this->audio?->update(); + + // poll SDL3 gamepad events (handled internally via update()) + if ($this->gamepad !== null) { + $this->gamepad->update($this->entities); + } + // run the update callback if available $this->options->update?->__invoke($this); @@ -279,6 +335,8 @@ public function render(float $deltaTime) : void // create a color attachment $sceneColorOptions = new TextureOptions; $sceneColorOptions->internalFormat = GL_RGBA; + $sceneColorOptions->wrapS = GL_CLAMP_TO_EDGE; + $sceneColorOptions->wrapT = GL_CLAMP_TO_EDGE; $sceneColorAtt = $context->pipeline->createColorAttachment($quickstartPassData->renderTarget, 'quickstartColor', $sceneColorOptions); // we plan to render the scene color buffer to the backbuffer @@ -303,10 +361,9 @@ function(RenderPass $pass, RenderPipeline $pipeline, PipelineContainer $data) { function(PipelineContainer $data, PipelineResources $resources) use($context) { $quickstartPassData = $data->get(QuickstartPassData::class); - $renderTarget = $resources->getRenderTarget($quickstartPassData->renderTarget); $renderTarget->preparePass(); - + // begin the VectorGraphics frame $this->vg->beginFrame($renderTarget->effectiveWidth(), $renderTarget->effectiveHeight(), $renderTarget->contentScaleX); @@ -315,7 +372,7 @@ function(PipelineContainer $data, PipelineResources $resources) use($context) // end the FlyUI frame FlyUI::endFrame(); - + // end the VectorGraphics frame $this->vg->endFrame(); // because VG touches the GL state we need to reset it diff --git a/src/Quickstart/QuickstartOptions.php b/src/Quickstart/QuickstartOptions.php index c1c7c01..e86f00b 100644 --- a/src/Quickstart/QuickstartOptions.php +++ b/src/Quickstart/QuickstartOptions.php @@ -63,6 +63,23 @@ class QuickstartOptions */ public bool $drawAutoRenderVectorGraphics = true; + /** + * Enable audio subsystem. + * Auto-detects backend: SDL3 (if available) -> OpenAL -> error. + */ + public bool $enableAudio = false; + + /** + * @deprecated Use $enableAudio instead. + */ + public bool $enableSDL3Audio = false; + + /** + * Enable SDL3 gamepad/controller subsystem via FFI. + * Requires SDL3 to be installed on the system. + */ + public bool $enableGamepad = false; + /** * A callable that is invoked once the app is ready to run. * diff --git a/src/Quickstart/Render/QuickstartDebugMetricsOverlay.php b/src/Quickstart/Render/QuickstartDebugMetricsOverlay.php index b0a2740..a67f7d7 100644 --- a/src/Quickstart/Render/QuickstartDebugMetricsOverlay.php +++ b/src/Quickstart/Render/QuickstartDebugMetricsOverlay.php @@ -44,7 +44,7 @@ static public function debugString(string $row) : void /** * Should the debug overlay be rendered? */ - public bool $enabled = true; + public bool $enabled = false; private DebugOverlayTextRenderer $overlayTextRenderer; @@ -61,9 +61,12 @@ public function __construct( DebugOverlayTextRenderer::loadDebugFontAtlas(), ); - // listen to keyboard events to toggle debug overlay - $container->getTyped(Dispatcher::class, 'dispatcher')->register('input.key', function(KeySignal $keySignal) { - if ($keySignal->key == Key::F1 && $keySignal->action == Input::PRESS) { + // listen to keyboard events to toggle debug overlay (cross-check with + // polling to filter phantom key events from macOS fullscreen transitions) + $input = $container->getTyped(Input::class, 'input'); + $container->getTyped(Dispatcher::class, 'dispatcher')->register('input.key', function(KeySignal $keySignal) use ($input) { + if ($keySignal->key == Key::F1 && $keySignal->action == Input::PRESS + && $input->getKeyState(Key::F1) === GLFW_PRESS) { $this->enabled = !$this->enabled; } }); diff --git a/src/SDL3/Exception/SDLException.php b/src/SDL3/Exception/SDLException.php new file mode 100644 index 0000000..d883319 --- /dev/null +++ b/src/SDL3/Exception/SDLException.php @@ -0,0 +1,7 @@ +findLibrary(); + $this->ffi = FFI::cdef($declarations, $libPath); + } + + private function findLibrary(): string + { + $candidates = [ + // macOS – Homebrew (linked) + '/opt/homebrew/lib/libSDL3.dylib', + '/usr/local/lib/libSDL3.dylib', + // Linux + '/usr/lib/libSDL3.so', + '/usr/lib/x86_64-linux-gnu/libSDL3.so', + '/usr/lib/aarch64-linux-gnu/libSDL3.so', + '/usr/local/lib/libSDL3.so', + ]; + + // macOS – Homebrew keg-only or specific Cellar versions + if (PHP_OS_FAMILY === 'Darwin') { + foreach (['/opt/homebrew/opt/sdl3/lib', '/usr/local/opt/sdl3/lib'] as $kegDir) { + if (is_dir($kegDir)) { + $candidates[] = $kegDir . '/libSDL3.dylib'; + } + } + foreach (['/opt/homebrew/Cellar/sdl3', '/usr/local/Cellar/sdl3'] as $cellarDir) { + if (is_dir($cellarDir)) { + $versions = @scandir($cellarDir, SCANDIR_SORT_DESCENDING); + if ($versions) { + foreach ($versions as $ver) { + if ($ver[0] === '.') continue; + $candidates[] = $cellarDir . '/' . $ver . '/lib/libSDL3.dylib'; + } + } + } + } + } + + foreach ($candidates as $path) { + if (file_exists($path)) { + return $path; + } + } + + // Last resort: let the dynamic linker try + $fallbacks = PHP_OS_FAMILY === 'Windows' + ? ['SDL3.dll'] + : ['libSDL3.so', 'libSDL3.dylib']; + + foreach ($fallbacks as $name) { + try { + \FFI::cdef('int SDL_GetVersion(void);', $name); + return $name; + } catch (\FFI\Exception) { + continue; + } + } + + throw new SDLException('SDL3 shared library not found. Install SDL3 (e.g. brew install sdl3).'); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function init(int $flags): void + { + if (!$this->ffi->SDL_Init($flags)) { + throw new SDLException('SDL_Init failed: ' . $this->getError()); + } + } + + public function initSubSystem(int $flags): void + { + if (!$this->ffi->SDL_InitSubSystem($flags)) { + throw new SDLException('SDL_InitSubSystem failed: ' . $this->getError()); + } + } + + public function quitSubSystem(int $flags): void + { + $this->ffi->SDL_QuitSubSystem($flags); + } + + public function quit(): void + { + $this->ffi->SDL_Quit(); + } + + public function getError(): string + { + return FFI::string($this->ffi->SDL_GetError()); + } + + /** + * Poll for pending SDL events. + * Returns an associative array describing the event, or null if the queue is empty. + * + * @return array|null + */ + public function pollEvent(): ?array + { + $event = $this->ffi->new('SDL_Event'); + if (!$this->ffi->SDL_PollEvent(FFI::addr($event))) { + return null; + } + + $type = $event->type; + $result = ['type' => $type]; + + switch ($type) { + case self::EVENT_GAMEPAD_AXIS_MOTION: + $result['which'] = $event->gaxis->which; + $result['axis'] = $event->gaxis->axis; + $result['value'] = $event->gaxis->value; + break; + + case self::EVENT_GAMEPAD_BUTTON_DOWN: + case self::EVENT_GAMEPAD_BUTTON_UP: + $result['which'] = $event->gbutton->which; + $result['button'] = $event->gbutton->button; + $result['down'] = $event->gbutton->down; + break; + + case self::EVENT_GAMEPAD_ADDED: + case self::EVENT_GAMEPAD_REMOVED: + $result['which'] = $event->gdevice->which; + break; + } + + return $result; + } + + public function pumpEvents(): void + { + $this->ffi->SDL_PumpEvents(); + } +} diff --git a/src/Save/SaveManager.php b/src/Save/SaveManager.php new file mode 100644 index 0000000..7ddfb0e --- /dev/null +++ b/src/Save/SaveManager.php @@ -0,0 +1,271 @@ +): array> Migration callbacks keyed by from-version number. + */ + private array $migrations = []; + + public function __construct( + private string $savePath, + private ?DispatcherInterface $dispatcher = null, + ) { + if (!is_dir($this->savePath)) { + mkdir($this->savePath, 0755, true); + } + } + + /** + * Set the current schema version. + */ + public function setSchemaVersion(int $version): void + { + $this->schemaVersion = $version; + } + + /** + * Register a migration from one version to the next. + * + * @param callable(int, array): array $callback + */ + public function registerMigration(int $fromVersion, callable $callback): void + { + $this->migrations[$fromVersion] = $callback; + } + + /** + * Set autosave interval in seconds (0 = disabled). + */ + public function setAutosaveInterval(float $seconds): void + { + $this->autosaveInterval = $seconds; + } + + /** + * Set the autosave slot name. + */ + public function setAutosaveSlot(string $name): void + { + $this->autosaveSlot = $name; + } + + /** + * Save game state to a named slot. + * + * @param array $gameState Arbitrary game data to persist + * @param array|null $sceneData Optional scene entity data (from SceneSaver::toArray) + */ + public function save( + string $slotName, + array $gameState, + float $playTime = 0.0, + string $description = '', + ?array $sceneData = null, + ): SaveSlot { + $slot = new SaveSlot( + name: $slotName, + version: $this->schemaVersion, + timestamp: microtime(true), + playTime: $playTime, + description: $description, + gameState: $gameState, + sceneData: $sceneData, + ); + + $path = $this->slotPath($slotName); + $json = json_encode($slot->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException("Failed to encode save data for slot: {$slotName}"); + } + + if (file_put_contents($path, $json) === false) { + throw new \RuntimeException("Failed to write save file: {$path}"); + } + + $this->dispatcher?->dispatch('save.completed', new SaveSignal($slotName, SaveSignal::SAVE)); + + return $slot; + } + + /** + * Load game state from a named slot. + */ + public function load(string $slotName): SaveSlot + { + $path = $this->slotPath($slotName); + if (!file_exists($path)) { + throw new \RuntimeException("Save slot not found: {$slotName}"); + } + + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException("Failed to read save file: {$path}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid save file: {$path}"); + } + + // Run migrations if needed + $data = $this->migrate($data); + + $slot = SaveSlot::fromArray($data); + + $this->dispatcher?->dispatch('save.loaded', new SaveSignal($slotName, SaveSignal::LOAD)); + + return $slot; + } + + /** + * Check if a save slot exists. + */ + public function exists(string $slotName): bool + { + return file_exists($this->slotPath($slotName)); + } + + /** + * Delete a save slot. + */ + public function delete(string $slotName): bool + { + $path = $this->slotPath($slotName); + if (!file_exists($path)) { + return false; + } + + $result = unlink($path); + + if ($result) { + $this->dispatcher?->dispatch('save.deleted', new SaveSignal($slotName, SaveSignal::DELETE)); + } + + return $result; + } + + /** + * List all available save slots with metadata. + * + * @return array + */ + public function listSlots(): array + { + $slots = []; + $pattern = $this->savePath . '/*.json'; + + foreach (glob($pattern) ?: [] as $file) { + $slotName = pathinfo($file, PATHINFO_FILENAME); + $json = file_get_contents($file); + if ($json === false) { + continue; + } + + $data = json_decode($json, true); + if (!is_array($data)) { + continue; + } + + $slots[] = new SaveSlotInfo( + name: $slotName, + timestamp: (float) ($data['timestamp'] ?? 0.0), + playTime: (float) ($data['playTime'] ?? 0.0), + description: (string) ($data['description'] ?? ''), + version: (int) ($data['version'] ?? 1), + ); + } + + // Sort by timestamp descending (newest first) + usort($slots, fn(SaveSlotInfo $a, SaveSlotInfo $b) => $b->timestamp <=> $a->timestamp); + + return $slots; + } + + /** + * Update autosave timer. Call this every frame with deltaTime. + * Returns the SaveSlot if autosave was triggered, null otherwise. + * + * @param array $gameState Current game state + * @param array|null $sceneData Current scene data + */ + public function updateAutosave( + float $deltaTime, + array $gameState, + float $playTime = 0.0, + ?array $sceneData = null, + ): ?SaveSlot { + if ($this->autosaveInterval <= 0.0) { + return null; + } + + $this->timeSinceAutosave += $deltaTime; + + if ($this->timeSinceAutosave >= $this->autosaveInterval) { + $this->timeSinceAutosave = 0.0; + return $this->save($this->autosaveSlot, $gameState, $playTime, 'Autosave', $sceneData); + } + + return null; + } + + /** + * Run migrations on save data to bring it up to the current schema version. + * + * @param array $data + * @return array + */ + private function migrate(array $data): array + { + $version = (int) ($data['version'] ?? 1); + + while ($version < $this->schemaVersion) { + if (!isset($this->migrations[$version])) { + throw new \RuntimeException( + "No migration registered for save version {$version} -> " . ($version + 1) + ); + } + + $data = ($this->migrations[$version])($version, $data); + $version++; + $data['version'] = $version; + } + + return $data; + } + + private function slotPath(string $slotName): string + { + // Sanitize slot name to prevent directory traversal + $safe = preg_replace('/[^a-zA-Z0-9_\-]/', '_', $slotName); + return $this->savePath . '/' . $safe . '.json'; + } +} diff --git a/src/Save/SaveSlot.php b/src/Save/SaveSlot.php new file mode 100644 index 0000000..64d587c --- /dev/null +++ b/src/Save/SaveSlot.php @@ -0,0 +1,56 @@ + */ + public readonly array $gameState, + /** @var array|null */ + public readonly ?array $sceneData = null, + ) { + } + + /** + * @return array + */ + public function toArray(): array + { + $data = [ + 'name' => $this->name, + 'version' => $this->version, + 'timestamp' => $this->timestamp, + 'playTime' => $this->playTime, + 'description' => $this->description, + 'gameState' => $this->gameState, + ]; + + if ($this->sceneData !== null) { + $data['sceneData'] = $this->sceneData; + } + + return $data; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + return new self( + name: (string) ($data['name'] ?? ''), + version: (int) ($data['version'] ?? 1), + timestamp: (float) ($data['timestamp'] ?? 0.0), + playTime: (float) ($data['playTime'] ?? 0.0), + description: (string) ($data['description'] ?? ''), + gameState: (array) ($data['gameState'] ?? []), + sceneData: isset($data['sceneData']) ? (array) $data['sceneData'] : null, + ); + } +} diff --git a/src/Save/SaveSlotInfo.php b/src/Save/SaveSlotInfo.php new file mode 100644 index 0000000..066ed0e --- /dev/null +++ b/src/Save/SaveSlotInfo.php @@ -0,0 +1,15 @@ + parsed JSON array. + * + * @var array> + */ + private array $prefabCache = []; + + public function __construct( + private SceneLoader $loader, + private string $basePath = '', + ) { + } + + /** + * Loads a prefab definition from a JSON file. + * Prefab files have the same format as scene entity definitions: + * + * { + * "name": "Employee", + * "transform": { ... }, + * "components": [ ... ], + * "children": [ ... ] + * } + * + * @return array + */ + public function loadPrefab(string $path): array + { + if (isset($this->prefabCache[$path])) { + return $this->prefabCache[$path]; + } + + $fullPath = $this->basePath !== '' + ? rtrim($this->basePath, '/') . '/' . ltrim($path, '/') + : $path; + + if (!file_exists($fullPath)) { + throw new \RuntimeException("Prefab file not found: {$fullPath}"); + } + + $json = file_get_contents($fullPath); + if ($json === false) { + throw new \RuntimeException("Failed to read prefab file: {$fullPath}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in prefab file: {$fullPath}"); + } + + $this->prefabCache[$path] = $data; + return $data; + } + + /** + * Instantiates a prefab, creating entities in the registry. + * Supports property overrides merged onto the prefab definition. + * + * @param array $overrides Override properties (name, transform, components). + * @return array Created entity IDs. + */ + public function instantiate(string $path, EntitiesInterface $entities, array $overrides = []): array + { + $prefabData = $this->loadPrefab($path); + $merged = $this->mergeOverrides($prefabData, $overrides); + + // Wrap in scene format for the loader + $sceneData = ['entities' => [$merged]]; + return $this->loader->loadArray($sceneData, $entities); + } + + /** + * Merges override values onto a prefab definition. + * + * @param array $base + * @param array $overrides + * @return array + */ + private function mergeOverrides(array $base, array $overrides): array + { + foreach ($overrides as $key => $value) { + if ($key === 'transform' && isset($base['transform']) && is_array($value)) { + // Merge transform sub-keys + $base['transform'] = array_merge($base['transform'], $value); + } elseif ($key === 'components' && is_array($value)) { + // Merge/override components by type + $base['components'] = $this->mergeComponents( + $base['components'] ?? [], + $value + ); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + /** + * Merges component arrays by type. Overrides win for matching types. + * + * @param array> $baseComponents + * @param array> $overrideComponents + * @return array> + */ + private function mergeComponents(array $baseComponents, array $overrideComponents): array + { + // Index base by type + $byType = []; + foreach ($baseComponents as $i => $comp) { + $type = $comp['type'] ?? null; + if ($type !== null) { + $byType[$type] = $i; + } + } + + foreach ($overrideComponents as $override) { + $type = $override['type'] ?? null; + if ($type === null) { + continue; + } + + if (isset($byType[$type])) { + // Merge properties onto existing component + $baseComponents[$byType[$type]] = array_merge( + $baseComponents[$byType[$type]], + $override + ); + } else { + // Add new component + $baseComponents[] = $override; + } + } + + return $baseComponents; + } + + /** + * Clears the prefab cache. + */ + public function clearCache(): void + { + $this->prefabCache = []; + } +} diff --git a/src/Scene/SceneLoader.php b/src/Scene/SceneLoader.php new file mode 100644 index 0000000..cd72109 --- /dev/null +++ b/src/Scene/SceneLoader.php @@ -0,0 +1,235 @@ +transpiledDir = rtrim($dir, '/'); + } + + /** + * Loads a scene JSON file and populates entities in the registry. + * If a transpiled PHP factory exists (and transpiledDir is set), + * the factory is used instead of parsing JSON at runtime. + * + * @return array List of created entity IDs. + */ + public function loadFile(string $path, EntitiesInterface $entities): array + { + // Try transpiled factory first + if ($this->transpiledDir !== null) { + $factoryClass = $this->resolveTranspiledClass($path, 'VISU\\Generated\\Scenes'); + if ($factoryClass !== null) { + /** @var array */ + return $factoryClass::load($entities); + } + } + + if (!file_exists($path)) { + throw new \RuntimeException("Scene file not found: {$path}"); + } + + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException("Failed to read scene file: {$path}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in scene file: {$path}"); + } + + return $this->loadArray($data, $entities); + } + + /** + * Loads entities from a scene data array. + * + * @param array $data + * @return array List of created entity IDs. + */ + public function loadArray(array $data, EntitiesInterface $entities): array + { + $createdEntities = []; + $entityDefs = $data['entities'] ?? []; + + foreach ($entityDefs as $entityDef) { + $created = $this->loadEntity($entityDef, $entities, null); + array_push($createdEntities, ...$created); + } + + return $createdEntities; + } + + /** + * Loads a single entity definition recursively (including children). + * + * @param array $def + * @return array All created entity IDs (parent + children). + */ + private function loadEntity(array $def, EntitiesInterface $entities, ?int $parentEntity): array + { + $entity = $entities->create(); + $created = [$entity]; + + // Name component + if (isset($def['name'])) { + $entities->attach($entity, new NameComponent((string)$def['name'])); + } + + // Transform + $transform = $this->buildTransform($def['transform'] ?? []); + if ($parentEntity !== null) { + $transform->setParent($entities, $parentEntity); + } + $entities->attach($entity, $transform); + + // Components + foreach ($def['components'] ?? [] as $componentDef) { + $typeName = $componentDef['type'] ?? null; + if ($typeName === null) { + continue; + } + + // Extract properties (everything except 'type') + $properties = $componentDef; + unset($properties['type']); + + $component = $this->componentRegistry->create($typeName, $properties); + $entities->attach($entity, $component); + } + + // Children (recursive) + foreach ($def['children'] ?? [] as $childDef) { + $childCreated = $this->loadEntity($childDef, $entities, $entity); + array_push($created, ...$childCreated); + } + + return $created; + } + + /** + * Builds a Transform from a JSON definition. + * + * @param array $def + */ + private function buildTransform(array $def): Transform + { + $transform = new Transform(); + + if (isset($def['position'])) { + $p = $def['position']; + if (is_array($p)) { + $transform->position = new Vec3( + (float)($p[0] ?? $p['x'] ?? 0), + (float)($p[1] ?? $p['y'] ?? 0), + (float)($p[2] ?? $p['z'] ?? 0), + ); + } + } + + if (isset($def['rotation'])) { + $r = $def['rotation']; + if (is_array($r)) { + // Euler angles in degrees -> quaternion + $q = new Quat(); + $rx = GLM::radians((float)($r[0] ?? $r['x'] ?? 0)); + $ry = GLM::radians((float)($r[1] ?? $r['y'] ?? 0)); + $rz = GLM::radians((float)($r[2] ?? $r['z'] ?? 0)); + if ($rx != 0.0) { + $q->rotate($rx, new Vec3(1, 0, 0)); + } + if ($ry != 0.0) { + $q->rotate($ry, new Vec3(0, 1, 0)); + } + if ($rz != 0.0) { + $q->rotate($rz, new Vec3(0, 0, 1)); + } + $transform->orientation = $q; + } + } + + if (isset($def['scale'])) { + $s = $def['scale']; + if (is_array($s)) { + $transform->scale = new Vec3( + (float)($s[0] ?? $s['x'] ?? 1), + (float)($s[1] ?? $s['y'] ?? 1), + (float)($s[2] ?? $s['z'] ?? 1), + ); + } + } + + $transform->markDirty(); + return $transform; + } + + /** + * Resolves a JSON file path to its transpiled PHP factory class. + * Returns the FQCN if the file exists and the class is loadable, null otherwise. + * + * @return class-string|null + */ + private function resolveTranspiledClass(string $jsonPath, string $namespace): ?string + { + $baseName = pathinfo($jsonPath, PATHINFO_FILENAME); + $className = $this->toClassName($baseName); + $fqcn = $namespace . '\\' . $className; + + $subDir = match ($namespace) { + 'VISU\\Generated\\Scenes' => 'Scenes', + 'VISU\\Generated\\Prefabs' => 'Prefabs', + default => 'Scenes', + }; + + $phpFile = $this->transpiledDir . '/' . $subDir . '/' . $className . '.php'; + + if (!file_exists($phpFile)) { + return null; + } + + if (!class_exists($fqcn, false)) { + require_once $phpFile; + } + + if (!class_exists($fqcn, false)) { + return null; + } + + return $fqcn; + } + + /** + * Converts a file basename like "office_level1" to PascalCase "OfficeLevel1". + */ + private function toClassName(string $baseName): string + { + $cleaned = preg_replace('/[^a-zA-Z0-9]+/', ' ', $baseName) ?? $baseName; + return str_replace(' ', '', ucwords($cleaned)); + } +} diff --git a/src/Scene/SceneLoaderInterface.php b/src/Scene/SceneLoaderInterface.php new file mode 100644 index 0000000..741dc20 --- /dev/null +++ b/src/Scene/SceneLoaderInterface.php @@ -0,0 +1,23 @@ + List of created entity IDs. + */ + public function loadFile(string $path, EntitiesInterface $entities): array; + + /** + * Loads entities from a scene data array. + * + * @param array $data + * @return array List of created entity IDs. + */ + public function loadArray(array $data, EntitiesInterface $entities): array; +} diff --git a/src/Scene/SceneManager.php b/src/Scene/SceneManager.php new file mode 100644 index 0000000..fbe42fc --- /dev/null +++ b/src/Scene/SceneManager.php @@ -0,0 +1,178 @@ + + */ + private array $sceneStack = []; + + /** + * Registered scene definitions: name => file path. + * + * @var array + */ + private array $scenePaths = []; + + /** + * Entity IDs created by each scene, for cleanup on unload. + * + * @var array> + */ + private array $sceneEntities = []; + + /** + * Entity IDs marked as persistent (survive scene changes). + * + * @var array + */ + private array $persistentEntities = []; + + public function __construct( + private EntityRegistry $entities, + private SceneLoader $loader, + private Dispatcher $dispatcher, + ) { + } + + /** + * Registers a scene by name with its JSON file path. + */ + public function registerScene(string $name, string $path): void + { + $this->scenePaths[$name] = $path; + } + + /** + * Loads and activates a scene, unloading the previous active scene. + */ + public function loadScene(string $name): void + { + // Unload current active scene + if ($this->activeScene !== null) { + $this->unloadScene($this->activeScene); + } + + $this->doLoadScene($name); + $this->activeScene = $name; + } + + /** + * Pushes a scene onto the stack (e.g. pause overlay). + * The previous scene stays loaded but becomes inactive. + */ + public function pushScene(string $name): void + { + if ($this->activeScene !== null) { + $this->sceneStack[] = $this->activeScene; + } + + $this->doLoadScene($name); + $this->activeScene = $name; + } + + /** + * Pops the current scene and returns to the previous one on the stack. + */ + public function popScene(): void + { + if ($this->activeScene !== null) { + $this->unloadScene($this->activeScene); + } + + $this->activeScene = array_pop($this->sceneStack); + } + + /** + * Unloads a specific scene, destroying all non-persistent entities created by it. + */ + public function unloadScene(string $name): void + { + $entityIds = $this->sceneEntities[$name] ?? []; + + foreach ($entityIds as $entityId) { + // Skip persistent entities + if (isset($this->persistentEntities[$entityId])) { + continue; + } + + if ($this->entities->valid($entityId)) { + $this->entities->destroy($entityId); + } + } + + unset($this->sceneEntities[$name]); + + $this->dispatcher->dispatch( + SceneUnloadedSignal::class, + new SceneUnloadedSignal($name) + ); + } + + /** + * Marks an entity as persistent (won't be destroyed on scene change). + */ + public function markPersistent(int $entityId): void + { + $this->persistentEntities[$entityId] = true; + } + + /** + * Removes persistent mark from an entity. + */ + public function unmarkPersistent(int $entityId): void + { + unset($this->persistentEntities[$entityId]); + } + + /** + * Returns the currently active scene name. + */ + public function getActiveScene(): ?string + { + return $this->activeScene; + } + + /** + * Returns entity IDs created by a scene. + * + * @return array + */ + public function getSceneEntities(string $name): array + { + return $this->sceneEntities[$name] ?? []; + } + + /** + * Internal: loads a scene from its registered path. + */ + private function doLoadScene(string $name): void + { + if (!isset($this->scenePaths[$name])) { + throw new \RuntimeException("Scene not registered: '{$name}'"); + } + + $path = $this->scenePaths[$name]; + $entityIds = $this->loader->loadFile($path, $this->entities); + $this->sceneEntities[$name] = $entityIds; + + $this->dispatcher->dispatch( + SceneLoadedSignal::class, + new SceneLoadedSignal($name, $path) + ); + } +} diff --git a/src/Scene/SceneSaver.php b/src/Scene/SceneSaver.php new file mode 100644 index 0000000..40b3539 --- /dev/null +++ b/src/Scene/SceneSaver.php @@ -0,0 +1,171 @@ +|null $entityIds Specific entities to save, or null for all with Transform. + */ + public function saveFile(string $path, EntitiesInterface $entities, ?array $entityIds = null): void + { + $data = $this->toArray($entities, $entityIds); + + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException("Failed to encode scene to JSON"); + } + + if (file_put_contents($path, $json) === false) { + throw new \RuntimeException("Failed to write scene file: {$path}"); + } + } + + /** + * Converts entities to a scene data array. + * + * @param array|null $entityIds + * @return array + */ + public function toArray(EntitiesInterface $entities, ?array $entityIds = null): array + { + // Collect root entities (no parent transform) + if ($entityIds === null) { + $entityIds = $entities->list(Transform::class); + } + + // Filter to root entities only (parent === null) + $rootEntities = []; + foreach ($entityIds as $id) { + $transform = $entities->tryGet($id, Transform::class); + if ($transform !== null && $transform->parent === null) { + $rootEntities[] = $id; + } + } + + $entityDefs = []; + foreach ($rootEntities as $id) { + $entityDefs[] = $this->serializeEntity($id, $entities); + } + + return ['entities' => $entityDefs]; + } + + /** + * Serializes a single entity and its children. + * + * @return array + */ + private function serializeEntity(int $entityId, EntitiesInterface $entities): array + { + $def = []; + + // Name + $name = $entities->tryGet($entityId, NameComponent::class); + if ($name !== null) { + $def['name'] = $name->name; + } + + // Transform + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform !== null) { + $def['transform'] = $this->serializeTransform($transform); + } + + // Components (excluding Transform and NameComponent) + $components = $entities->components($entityId); + $componentDefs = []; + foreach ($components as $className => $component) { + if ($className === Transform::class || $className === NameComponent::class) { + continue; + } + + $typeName = $this->componentRegistry->getTypeName($className); + if ($typeName === null) { + continue; // Skip unregistered components + } + + $componentDef = $this->serializeComponent($typeName, $component); + $componentDefs[] = $componentDef; + } + + if (!empty($componentDefs)) { + $def['components'] = $componentDefs; + } + + // Children — find all entities whose parent is this entity + $children = []; + foreach ($entities->view(Transform::class) as $childId => $childTransform) { + if ($childTransform->parent === $entityId) { + $children[] = $this->serializeEntity($childId, $entities); + } + } + + if (!empty($children)) { + $def['children'] = $children; + } + + return $def; + } + + /** + * @return array + */ + private function serializeTransform(Transform $transform): array + { + $euler = $transform->getLocalEulerAngles(); + + $rad2deg = 180.0 / M_PI; + + return [ + 'position' => [ + round($transform->position->x, 4), + round($transform->position->y, 4), + round($transform->position->z, 4), + ], + 'rotation' => [ + round($euler->x * $rad2deg, 4), + round($euler->y * $rad2deg, 4), + round($euler->z * $rad2deg, 4), + ], + 'scale' => [ + round($transform->scale->x, 4), + round($transform->scale->y, 4), + round($transform->scale->z, 4), + ], + ]; + } + + /** + * @return array + */ + private function serializeComponent(string $typeName, object $component): array + { + $def = ['type' => $typeName]; + + $ref = new \ReflectionObject($component); + foreach ($ref->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + $value = $prop->getValue($component); + $def[$prop->getName()] = $value; + } + + return $def; + } +} diff --git a/src/Setup/ComposerSetupScript.php b/src/Setup/ComposerSetupScript.php new file mode 100644 index 0000000..5aa1aef --- /dev/null +++ b/src/Setup/ComposerSetupScript.php @@ -0,0 +1,103 @@ +getComposer()->getConfig()->get('vendor-dir'); + $projectRoot = (string) realpath($vendorDir . '/..'); + + // Detect if we are the root package (standalone / development mode). + // In that case, don't scaffold — the developer is working on VISU itself. + /** @var string $rootPackage */ + $rootPackage = $event->getComposer()->getPackage()->getName(); + if ($rootPackage === 'phpgl/visu') { + return; + } + + $io = $event->getIO(); + + // Quick check: if all essential files exist, skip entirely (silent). + if (self::isProjectReady($projectRoot)) { + return; + } + + $io->write(''); + $io->write('VISU Engine — Setting up project structure...'); + $io->write(''); + + /** @var bool $interactive */ + $interactive = $io->isInteractive(); + + $setup = new ProjectSetup( + projectRoot: $projectRoot, + interactive: $interactive, + output: function (string $line) use ($io): void { + $io->write($line); + }, + confirm: function (string $question) use ($io): bool { + /** @var bool $result */ + $result = $io->askConfirmation($question . ' [Y/n] ', true); + return $result; + }, + ); + + $setup->run(); + } + + /** + * Check whether all essential project files already exist. + * If so, there's nothing to do and we can skip silently. + */ + private static function isProjectReady(string $projectRoot): bool + { + $ds = DIRECTORY_SEPARATOR; + $required = [ + $projectRoot . $ds . 'app.ctn', + $projectRoot . $ds . 'var' . $ds . 'cache', + $projectRoot . $ds . 'var' . $ds . 'store', + $projectRoot . $ds . 'resources', + ]; + + foreach ($required as $path) { + if (!file_exists($path)) { + return false; + } + } + + return true; + } +} diff --git a/src/Setup/ProjectSetup.php b/src/Setup/ProjectSetup.php new file mode 100644 index 0000000..70beeae --- /dev/null +++ b/src/Setup/ProjectSetup.php @@ -0,0 +1,370 @@ + + */ + private array $created = []; + + /** + * Tracks what was skipped. + * @var array + */ + private array $skipped = []; + + /** + * @param string $projectRoot Absolute path to the consumer project root + * @param bool $interactive Whether to prompt user for decisions + * @param callable(string): void $output Output callback + * @param callable(string): bool $confirm Confirmation callback + */ + public function __construct( + private readonly string $projectRoot, + bool $interactive = true, + ?callable $output = null, + ?callable $confirm = null, + ) { + $this->interactive = $interactive; + $this->output = $output ?? function (string $line): void { + echo $line . PHP_EOL; + }; + $this->confirm = $confirm ?? function (string $question): bool { + echo $question . ' [Y/n] '; + $line = trim((string) fgets(STDIN)); + return $line === '' || strtolower($line) === 'y'; + }; + } + + /** + * Run the full setup process. + * + * @return bool True if any files/directories were created + */ + public function run(): bool + { + $this->out(''); + $this->out(' VISU Engine — Project Setup'); + $this->out(' =========================='); + $this->out(''); + + $this->ensureDirectories(); + $this->ensureAppCtn(); + $this->ensureBootstrapEntry(); + $this->ensureClaudeMd(); + $this->ensureGitignoreEntries(); + + $this->out(''); + $this->printSummary(); + + return count($this->created) > 0; + } + + /** + * Ensure all required directories exist. + */ + private function ensureDirectories(): void + { + $dirs = [ + 'var/cache' => 'Container cache', + 'var/store' => 'Persistent storage (saves, etc.)', + 'resources' => 'Application resources (shaders, textures, scenes)', + 'resources/shader' => 'Application shaders', + ]; + + foreach ($dirs as $relPath => $description) { + $absPath = $this->projectRoot . DIRECTORY_SEPARATOR . $relPath; + if (is_dir($absPath)) { + $this->skip($relPath, 'directory exists'); + continue; + } + mkdir($absPath, 0777, true); + $this->created($relPath . '/', $description); + } + } + + /** + * Ensure app.ctn container config exists. + */ + private function ensureAppCtn(): void + { + $file = $this->projectRoot . DIRECTORY_SEPARATOR . 'app.ctn'; + if (file_exists($file)) { + $this->skip('app.ctn', 'already exists'); + return; + } + + $content = <<<'CTN' +/** + * VISU application container configuration. + * + * Register your game's services, commands and systems here. + * @see https://container.clancats.com/ + */ + +CTN; + file_put_contents($file, $content); + $this->created('app.ctn', 'DI container config'); + } + + /** + * Ensure a bootstrap/entry point script exists. + */ + private function ensureBootstrapEntry(): void + { + $file = $this->projectRoot . DIRECTORY_SEPARATOR . 'game.php'; + if (file_exists($file)) { + $this->skip('game.php', 'already exists'); + return; + } + + if (!$this->shouldCreate('game.php', 'Application entry point')) { + return; + } + + $content = <<<'PHP' +run(); + +PHP; + file_put_contents($file, $content); + $this->created('game.php', 'Application entry point'); + } + + /** + * Ensure CLAUDE.md exists with VISU context. + */ + private function ensureClaudeMd(): void + { + $file = $this->projectRoot . DIRECTORY_SEPARATOR . 'CLAUDE.md'; + if (file_exists($file)) { + $this->skip('CLAUDE.md', 'already exists'); + return; + } + + if (!$this->shouldCreate('CLAUDE.md', 'Claude Code project instructions')) { + return; + } + + $content = <<<'MD' +# Game Project — powered by VISU Engine + +> This project uses the VISU PHP Game Engine as a Composer dependency. +> Engine source lives in `vendor/phpgl/visu/` — refer to its CLAUDE.md for engine internals. + +--- + +## Project Structure + +``` +project-root/ + game.php # Application entry point + app.ctn # DI container config (ClanCats Container) + composer.json # Dependencies + resources/ # Game resources (shaders, textures, scenes, UI) + shader/ # Application shaders + var/ + cache/ # Container cache (auto-generated) + store/ # Persistent storage (saves, etc.) + vendor/ + phpgl/visu/ # VISU Engine +``` + +## Key Commands + +```bash +# Run the game +php game.php + +# VISU CLI (commands, editor, transpiler) +./vendor/bin/visu + +# Available VISU commands +./vendor/bin/visu commands:available + +# Start the World Editor +./vendor/bin/visu world-editor + +# Transpile scenes/UI to PHP factories +./vendor/bin/visu transpile +``` + +## Engine Reference + +For engine internals, architecture, and coding conventions see: +`vendor/phpgl/visu/CLAUDE.md` + +Key engine concepts: +- **ECS**: EntityRegistry, Components, Systems (`VISU\ECS\`) +- **Scenes**: JSON scene files loaded via SceneLoader (`VISU\Scene\`) +- **UI**: JSON UI layouts interpreted by UIInterpreter (`VISU\UI\`) +- **Signals**: Event dispatching via Dispatcher (`VISU\Signal\`) +- **Audio**: AudioManager with channels (`VISU\Audio\`) +- **Save/Load**: SaveManager with slots and migrations (`VISU\Save\`) +- **Graphics**: OpenGL 4.1 rendering pipeline (`VISU\Graphics\`) + +## Conventions + +- Namespace: Use your own project namespace (configured in composer.json) +- Game logic: Components + Systems pattern, communicate via Signals +- Scenes: JSON format in `resources/scenes/` +- UI layouts: JSON format in `resources/ui/` +- Saves: Managed by SaveManager, stored in `var/store/` +MD; + file_put_contents($file, $content); + $this->created('CLAUDE.md', 'Claude Code project instructions'); + } + + /** + * Ensure .gitignore has required entries. + */ + private function ensureGitignoreEntries(): void + { + $file = $this->projectRoot . DIRECTORY_SEPARATOR . '.gitignore'; + $requiredEntries = [ + '/vendor/', + '/var/', + '.DS_Store', + ]; + + $existingContent = ''; + if (file_exists($file)) { + $existingContent = file_get_contents($file) ?: ''; + } + + $existingLines = array_map('trim', explode("\n", $existingContent)); + $missing = []; + + foreach ($requiredEntries as $entry) { + if (!in_array($entry, $existingLines, true)) { + $missing[] = $entry; + } + } + + if (empty($missing)) { + $this->skip('.gitignore', 'all entries present'); + return; + } + + if (file_exists($file)) { + // Append missing entries + $append = PHP_EOL . '# VISU Engine' . PHP_EOL; + foreach ($missing as $entry) { + $append .= $entry . PHP_EOL; + } + file_put_contents($file, $existingContent . $append); + $this->created('.gitignore', 'added ' . count($missing) . ' missing entries'); + } else { + $content = '# VISU Engine' . PHP_EOL; + foreach ($requiredEntries as $entry) { + $content .= $entry . PHP_EOL; + } + file_put_contents($file, $content); + $this->created('.gitignore', 'created with defaults'); + } + } + + /** + * Ask user whether to create a file (in interactive mode). + */ + private function shouldCreate(string $relPath, string $description): bool + { + if (!$this->interactive) { + return true; + } + return ($this->confirm)(" Create {$relPath} ({$description})?"); + } + + private function created(string $relPath, string $description): void + { + $this->created[] = $relPath; + $this->out(" + {$relPath} ({$description})"); + } + + private function skip(string $relPath, string $reason): void + { + $this->skipped[] = $relPath; + } + + private function out(string $line): void + { + ($this->output)($line); + } + + private function printSummary(): void + { + if (count($this->created) === 0) { + $this->out(' Everything up to date — nothing to create.'); + } else { + $this->out(' Created ' . count($this->created) . ' item(s), ' + . count($this->skipped) . ' already existed.'); + } + $this->out(''); + } + + /** + * Get the list of created items. + * @return array + */ + public function getCreated(): array + { + return $this->created; + } + + /** + * Get the list of skipped items. + * @return array + */ + public function getSkipped(): array + { + return $this->skipped; + } +} diff --git a/src/Signals/ECS/Collision3DSignal.php b/src/Signals/ECS/Collision3DSignal.php new file mode 100644 index 0000000..b45736b --- /dev/null +++ b/src/Signals/ECS/Collision3DSignal.php @@ -0,0 +1,18 @@ + $data + */ + public function __construct( + public readonly string $event, + public readonly array $data = [], + ) { + } +} diff --git a/src/System/AISystem.php b/src/System/AISystem.php new file mode 100644 index 0000000..b3f179a --- /dev/null +++ b/src/System/AISystem.php @@ -0,0 +1,63 @@ +registerComponent(BehaviourTreeComponent::class); + $entities->registerComponent(StateMachineComponent::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function setDeltaTime(float $dt): void + { + $this->deltaTime = $dt; + } + + public function update(EntitiesInterface $entities): void + { + // tick behaviour trees + foreach ($entities->view(BehaviourTreeComponent::class) as $entity => $bt) { + if (!$bt->enabled || $bt->root === null) { + continue; + } + + $context = new BTContext($entity, $entities, $this->deltaTime); + $context->blackboard = $bt->blackboard; + + $bt->lastStatus = $bt->root->tick($context); + $bt->blackboard = $context->blackboard; + } + + // tick state machines + foreach ($entities->view(StateMachineComponent::class) as $entity => $sm) { + if (!$sm->enabled || $sm->stateMachine === null) { + continue; + } + + $context = new BTContext($entity, $entities, $this->deltaTime); + $context->blackboard = $sm->blackboard; + + $sm->stateMachine->update($context); + $sm->blackboard = $context->blackboard; + } + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } +} diff --git a/src/System/Camera2DSystem.php b/src/System/Camera2DSystem.php new file mode 100644 index 0000000..17aef24 --- /dev/null +++ b/src/System/Camera2DSystem.php @@ -0,0 +1,218 @@ +cameraData = new Camera2DData(); + } + + public function register(EntitiesInterface $entities): void + { + } + + public function unregister(EntitiesInterface $entities): void + { + } + + /** + * Set the entity for the camera to follow. + */ + public function setFollowTarget(?int $entityId, float $damping = 0.1): void + { + $this->followTarget = $entityId; + $this->followDamping = $damping; + } + + /** + * Set zoom level. + */ + public function setZoom(float $zoom): void + { + $this->cameraData->zoom = max($this->minZoom, min($this->maxZoom, $zoom)); + } + + /** + * Adjust zoom by delta. + */ + public function zoom(float $delta): void + { + $this->setZoom($this->cameraData->zoom + $delta); + } + + /** + * Set zoom limits. + */ + public function setZoomLimits(float $min, float $max): void + { + $this->minZoom = $min; + $this->maxZoom = $max; + } + + /** + * Set world bounds to clamp camera position. + */ + public function setBounds(float $minX, float $minY, float $maxX, float $maxY): void + { + $this->bounds = [$minX, $minY, $maxX, $maxY]; + } + + /** + * Set camera position directly. + */ + public function setPosition(float $x, float $y): void + { + $this->cameraData->x = $x; + $this->cameraData->y = $y; + $this->clampToBounds(); + } + + /** + * Trigger a camera shake effect. + * + * @param float $intensity Shake strength (0.0 to 1.0) + * @param float $duration Duration in seconds + * @param float $maxOffset Maximum pixel offset + */ + public function shake(float $intensity = 0.5, float $duration = 0.3, float $maxOffset = 10.0): void + { + $this->shakeIntensity = max(0.0, min(1.0, $intensity)); + $this->shakeDuration = $duration; + $this->shakeMaxOffset = $maxOffset; + } + + /** + * Returns whether the camera is currently shaking. + */ + public function isShaking(): bool + { + return $this->shakeDuration > 0.0; + } + + /** + * Get the current shake offset (useful for external rendering). + * + * @return array{float, float} + */ + public function getShakeOffset(): array + { + return [$this->shakeOffsetX, $this->shakeOffsetY]; + } + + public function update(EntitiesInterface $entities): void + { + // Remove previous shake offset before computing new position + $this->cameraData->x -= $this->shakeOffsetX; + $this->cameraData->y -= $this->shakeOffsetY; + $this->shakeOffsetX = 0.0; + $this->shakeOffsetY = 0.0; + + if ($this->followTarget !== null) { + $transform = $entities->tryGet($this->followTarget, Transform::class); + if ($transform !== null) { + $targetX = $transform->position->x; + $targetY = $transform->position->y; + + // Smooth follow (lerp) + $t = 1.0 - $this->followDamping; + $this->cameraData->x += ($targetX - $this->cameraData->x) * $t; + $this->cameraData->y += ($targetY - $this->cameraData->y) * $t; + } + } + + $this->clampToBounds(); + + // Apply shake + if ($this->shakeDuration > 0.0) { + // Decay factor based on remaining duration + $decay = $this->shakeDuration > 0.0 ? min(1.0, $this->shakeDuration) : 0.0; + $offset = $this->shakeIntensity * $this->shakeMaxOffset * $decay; + + $this->shakeOffsetX = ((mt_rand() / mt_getrandmax()) * 2.0 - 1.0) * $offset; + $this->shakeOffsetY = ((mt_rand() / mt_getrandmax()) * 2.0 - 1.0) * $offset; + + $this->cameraData->x += $this->shakeOffsetX; + $this->cameraData->y += $this->shakeOffsetY; + + // Use a fixed delta for now (system doesn't receive deltaTime) + $this->shakeDuration -= 1.0 / 60.0; + if ($this->shakeDuration <= 0.0) { + $this->shakeDuration = 0.0; + $this->shakeIntensity = 0.0; + $this->shakeOffsetX = 0.0; + $this->shakeOffsetY = 0.0; + } + } + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + private function clampToBounds(): void + { + if ($this->bounds === null) { + return; + } + + $this->cameraData->x = max($this->bounds[0], min($this->bounds[2], $this->cameraData->x)); + $this->cameraData->y = max($this->bounds[1], min($this->bounds[3], $this->cameraData->y)); + } +} diff --git a/src/System/Camera3DSystem.php b/src/System/Camera3DSystem.php new file mode 100644 index 0000000..87b702d --- /dev/null +++ b/src/System/Camera3DSystem.php @@ -0,0 +1,348 @@ + + */ + private SignalQueue $cursorQueue; + + /** + * @var SignalQueue + */ + private SignalQueue $scrollQueue; + + /** + * Accumulated scroll delta (consumed during update) + */ + private float $scrollDelta = 0.0; + + /** + * Accumulated cursor movement (consumed during update) + */ + private Vec2 $cursorDelta; + + private Vec2 $panDelta; + + public function __construct( + private Input $input, + private Dispatcher $dispatcher, + ) { + $this->cursorDelta = new Vec2(0.0, 0.0); + $this->panDelta = new Vec2(0.0, 0.0); + } + + /** + * Spawns a camera entity with Camera + Camera3DComponent and sets it active. + */ + public function spawnCamera( + EntitiesInterface $entities, + Camera3DMode $mode, + ?Vec3 $position = null, + ): int { + $entity = $entities->create(); + $camera = $entities->attach($entity, new Camera(CameraProjectionMode::perspective)); + $camera->nearPlane = 0.1; + $camera->farPlane = 500.0; + + $comp = $entities->attach($entity, new Camera3DComponent()); + + if ($position !== null) { + $camera->transform->setPosition($position); + } + + $this->cameraEntity = $entity; + $this->mode = $mode; + + // initialize yaw/pitch from position for orbit mode + if ($mode === Camera3DMode::orbit && $position !== null) { + $dir = $position - $comp->orbitTarget; + $comp->orbitDistance = $dir->length(); + if ($comp->orbitDistance > 0.001) { + $normalized = Vec3::normalized($dir); + $comp->yaw = atan2($normalized->x, $normalized->z) * (180.0 / M_PI); + $comp->pitch = asin(max(-1.0, min(1.0, $normalized->y))) * (180.0 / M_PI); + } + } + + return $entity; + } + + /** + * Returns the active camera entity + */ + public function getCameraEntity(): int + { + return $this->cameraEntity; + } + + /** + * Sets the camera mode at runtime + */ + public function setMode(Camera3DMode $mode): void + { + $this->mode = $mode; + } + + public function getMode(): Camera3DMode + { + return $this->mode; + } + + public function register(EntitiesInterface $entities): void + { + $entities->registerComponent(Camera::class); + $entities->registerComponent(Camera3DComponent::class); + + $this->cursorQueue = $this->dispatcher->createSignalQueue(Input::EVENT_CURSOR); + $this->scrollQueue = $this->dispatcher->createSignalQueue(Input::EVENT_SCROLL); + } + + public function unregister(EntitiesInterface $entities): void + { + $this->dispatcher->destroySignalQueue($this->cursorQueue); + $this->dispatcher->destroySignalQueue($this->scrollQueue); + } + + public function update(EntitiesInterface $entities): void + { + if ($this->cameraEntity === 0 || !$entities->valid($this->cameraEntity)) { + return; + } + + $camera = $entities->get($this->cameraEntity, Camera::class); + $comp = $entities->get($this->cameraEntity, Camera3DComponent::class); + + $camera->finalizeFrame(); + + // drain input queues + $this->cursorDelta->x = 0.0; + $this->cursorDelta->y = 0.0; + $this->panDelta->x = 0.0; + $this->panDelta->y = 0.0; + + while ($signal = $this->cursorQueue->shift()) { + if (!$this->input->isContextUnclaimed()) continue; + + if ($this->mode === Camera3DMode::firstPerson) { + // first-person always captures cursor movement when left mouse is held + if ($this->input->isMouseButtonPressed(MouseButton::LEFT) + && !$this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)) { + $this->cursorDelta->x += $signal->offsetX; + $this->cursorDelta->y += $signal->offsetY; + } + } else { + // orbit and third-person: left-drag rotates + if ($this->input->isMouseButtonPressed(MouseButton::LEFT) + && !$this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)) { + $this->cursorDelta->x += $signal->offsetX; + $this->cursorDelta->y += $signal->offsetY; + } + // orbit: right-drag pans + if ($this->input->isMouseButtonPressed(MouseButton::RIGHT) + && !$this->input->hasMouseButtonBeenPressed(MouseButton::RIGHT)) { + $this->panDelta->x += $signal->offsetX; + $this->panDelta->y += $signal->offsetY; + } + } + } + + while ($signal = $this->scrollQueue->shift()) { + if (!$this->input->isContextUnclaimed()) continue; + $this->scrollDelta += $signal->y; + } + + if (!$this->input->isContextUnclaimed()) { + return; + } + + // cursor mode management for first-person + if ($this->mode === Camera3DMode::firstPerson) { + if ($this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)) { + $this->input->setCursorMode(CursorMode::DISABLED); + } + if (!$this->input->isMouseButtonPressed(MouseButton::LEFT)) { + $this->input->setCursorMode(CursorMode::NORMAL); + } + } else { + // orbit/third-person: set disabled while left-dragging for smooth movement + if ($this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)) { + $this->input->setCursorMode(CursorMode::DISABLED); + } + if (!$this->input->isMouseButtonPressed(MouseButton::LEFT) + && !$this->input->isMouseButtonPressed(MouseButton::RIGHT)) { + $this->input->setCursorMode(CursorMode::NORMAL); + } + } + + match ($this->mode) { + Camera3DMode::orbit => $this->updateOrbit($entities, $camera, $comp), + Camera3DMode::firstPerson => $this->updateFirstPerson($camera, $comp), + Camera3DMode::thirdPerson => $this->updateThirdPerson($entities, $camera, $comp), + }; + + $this->scrollDelta = 0.0; + } + + private function updateOrbit(EntitiesInterface $entities, Camera $camera, Camera3DComponent $comp): void + { + // rotation from cursor drag + $comp->yaw -= $this->cursorDelta->x * $comp->sensitivity; + $comp->pitch -= $this->cursorDelta->y * $comp->sensitivity; + $comp->pitch = max($comp->pitchMin, min($comp->pitchMax, $comp->pitch)); + + // zoom from scroll + $comp->orbitDistance -= $this->scrollDelta * $comp->orbitZoomSpeed; + $comp->orbitDistance = max($comp->orbitDistanceMin, min($comp->orbitDistanceMax, $comp->orbitDistance)); + + // pan from right-drag + if ($this->panDelta->x != 0.0 || $this->panDelta->y != 0.0) { + $right = $camera->transform->dirRight(); + $up = $camera->transform->dirUp(); + $panScale = $comp->orbitPanSpeed * $comp->orbitDistance; + /** @var Vec3 $newTarget */ + $newTarget = $comp->orbitTarget + - $right * ($this->panDelta->x * $panScale) + + $up * ($this->panDelta->y * $panScale); + $comp->orbitTarget = $newTarget; + } + + // compute camera position from spherical coordinates + $yawRad = GLM::radians($comp->yaw); + $pitchRad = GLM::radians($comp->pitch); + $cosPitch = cos($pitchRad); + + $offset = new Vec3( + sin($yawRad) * $cosPitch * $comp->orbitDistance, + sin($pitchRad) * $comp->orbitDistance, + cos($yawRad) * $cosPitch * $comp->orbitDistance, + ); + + $camera->transform->setPosition($comp->orbitTarget + $offset); + $camera->transform->lookAt($comp->orbitTarget); + } + + private function updateFirstPerson(Camera $camera, Camera3DComponent $comp): void + { + // rotation from cursor + $comp->yaw -= $this->cursorDelta->x * $comp->sensitivity; + $comp->pitch -= $this->cursorDelta->y * $comp->sensitivity; + $comp->pitch = max($comp->pitchMin, min($comp->pitchMax, $comp->pitch)); + + // apply orientation + $quatYaw = new Quat(); + $quatYaw->rotate(GLM::radians($comp->yaw), new Vec3(0.0, 1.0, 0.0)); + $quatPitch = new Quat(); + $quatPitch->rotate(GLM::radians($comp->pitch), new Vec3(1.0, 0.0, 0.0)); + $camera->transform->setOrientation($quatYaw * $quatPitch); + + // movement + $speed = $comp->moveSpeed; + if ($this->input->isKeyPressed(Key::LEFT_SHIFT)) { + $speed *= $comp->sprintMultiplier; + } + + if ($this->input->isKeyPressed(Key::W)) { + $camera->transform->moveForward($speed); + } + if ($this->input->isKeyPressed(Key::S)) { + $camera->transform->moveBackward($speed); + } + if ($this->input->isKeyPressed(Key::A)) { + $camera->transform->moveLeft($speed); + } + if ($this->input->isKeyPressed(Key::D)) { + $camera->transform->moveRight($speed); + } + if ($this->input->isKeyPressed(Key::SPACE)) { + $camera->transform->position->y += $speed; + $camera->transform->markDirty(); + } + if ($this->input->isKeyPressed(Key::LEFT_CONTROL)) { + $camera->transform->position->y -= $speed; + $camera->transform->markDirty(); + } + } + + private function updateThirdPerson(EntitiesInterface $entities, Camera $camera, Camera3DComponent $comp): void + { + if ($comp->followTarget === 0 || !$entities->valid($comp->followTarget)) { + return; + } + + $targetTransform = $entities->get($comp->followTarget, Transform::class); + $targetPos = $targetTransform->getWorldPosition($entities); + $lookTarget = new Vec3($targetPos->x, $targetPos->y + $comp->followHeightOffset, $targetPos->z); + + // rotation from cursor drag + $comp->yaw -= $this->cursorDelta->x * $comp->sensitivity; + $comp->pitch -= $this->cursorDelta->y * $comp->sensitivity; + $comp->pitch = max($comp->pitchMin, min($comp->pitchMax, $comp->pitch)); + + // zoom from scroll + $comp->followDistance -= $this->scrollDelta * $comp->followZoomSpeed; + $comp->followDistance = max($comp->followDistanceMin, min($comp->followDistanceMax, $comp->followDistance)); + + // compute desired position from spherical coordinates around target + $yawRad = GLM::radians($comp->yaw); + $pitchRad = GLM::radians($comp->pitch); + $cosPitch = cos($pitchRad); + + $offset = new Vec3( + sin($yawRad) * $cosPitch * $comp->followDistance, + sin($pitchRad) * $comp->followDistance, + cos($yawRad) * $cosPitch * $comp->followDistance, + ); + + $desiredPos = $lookTarget + $offset; + + // smooth follow with damping + $currentPos = $camera->transform->position; + $damping = $comp->followDamping; + $newPos = new Vec3( + $currentPos->x + ($desiredPos->x - $currentPos->x) * (1.0 - $damping), + $currentPos->y + ($desiredPos->y - $currentPos->y) * (1.0 - $damping), + $currentPos->z + ($desiredPos->z - $currentPos->z) * (1.0 - $damping), + ); + + $camera->transform->setPosition($newPos); + $camera->transform->lookAt($lookTarget); + } + + /** + * Render pass: writes CameraData to the pipeline container + */ + public function render(EntitiesInterface $entities, RenderContext $context): void + { + if ($this->cameraEntity === 0) return; + + $camera = $entities->get($this->cameraEntity, Camera::class); + $renderTarget = $context->resources->getActiveRenderTarget(); + $context->data->set($camera->createCameraData($renderTarget, $context->compensation)); + } +} diff --git a/src/System/Collision2DSystem.php b/src/System/Collision2DSystem.php new file mode 100644 index 0000000..965a096 --- /dev/null +++ b/src/System/Collision2DSystem.php @@ -0,0 +1,347 @@ + + */ + private array $activePairs = []; + + public function __construct( + private DispatcherInterface $dispatcher, + float $cellSize = 64.0, + ) { + $this->cellSize = $cellSize; + } + + public function register(EntitiesInterface $entities): void + { + $entities->registerComponent(BoxCollider2D::class); + $entities->registerComponent(CircleCollider2D::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function update(EntitiesInterface $entities): void + { + // Collect all colliders with world positions + $colliders = []; + $this->collectBoxColliders($entities, $colliders); + $this->collectCircleColliders($entities, $colliders); + + // Broad phase: spatial grid + $candidates = $this->broadPhase($colliders); + + // Narrow phase: test each candidate pair + $currentPairs = []; + foreach ($candidates as [$idxA, $idxB]) { + $a = $colliders[$idxA]; + $b = $colliders[$idxB]; + + // Layer/mask filtering + if (($a['layer'] & $b['mask']) === 0 || ($b['layer'] & $a['mask']) === 0) { + continue; + } + + $result = $this->narrowPhase($a, $b); + if ($result === null) { + continue; + } + + $entityA = $a['entity']; + $entityB = $b['entity']; + $pairKey = $entityA < $entityB ? "{$entityA}:{$entityB}" : "{$entityB}:{$entityA}"; + $currentPairs[$pairKey] = true; + + $isTrigger = $a['isTrigger'] || $b['isTrigger']; + + if ($isTrigger) { + $phase = isset($this->activePairs[$pairKey]) ? TriggerSignal::STAY : TriggerSignal::ENTER; + $this->dispatcher->dispatch('collision.trigger', new TriggerSignal($entityA, $entityB, $phase)); + } else { + $this->dispatcher->dispatch('collision', new CollisionSignal( + $entityA, + $entityB, + $result['contactX'], + $result['contactY'], + false, + )); + } + } + + // Trigger exit for pairs that were active last frame but not this frame + foreach ($this->activePairs as $pairKey => $_) { + if (!isset($currentPairs[$pairKey])) { + [$entityA, $entityB] = explode(':', $pairKey); + $this->dispatcher->dispatch('collision.trigger', new TriggerSignal( + (int) $entityA, + (int) $entityB, + TriggerSignal::EXIT, + )); + } + } + + $this->activePairs = $currentPairs; + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + /** + * @param array> $colliders + */ + private function collectBoxColliders(EntitiesInterface $entities, array &$colliders): void + { + foreach ($entities->view(BoxCollider2D::class) as $entityId => $box) { + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + + $worldPos = $this->getWorldPosition($entities, $entityId, $transform); + $cx = $worldPos[0] + $box->offsetX; + $cy = $worldPos[1] + $box->offsetY; + + $colliders[] = [ + 'entity' => $entityId, + 'type' => 'box', + 'cx' => $cx, + 'cy' => $cy, + 'halfW' => $box->halfWidth, + 'halfH' => $box->halfHeight, + 'isTrigger' => $box->isTrigger, + 'layer' => $box->layer, + 'mask' => $box->mask, + ]; + } + } + + /** + * @param array> $colliders + */ + private function collectCircleColliders(EntitiesInterface $entities, array &$colliders): void + { + foreach ($entities->view(CircleCollider2D::class) as $entityId => $circle) { + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) { + continue; + } + + $worldPos = $this->getWorldPosition($entities, $entityId, $transform); + $cx = $worldPos[0] + $circle->offsetX; + $cy = $worldPos[1] + $circle->offsetY; + + $colliders[] = [ + 'entity' => $entityId, + 'type' => 'circle', + 'cx' => $cx, + 'cy' => $cy, + 'radius' => $circle->radius, + 'isTrigger' => $circle->isTrigger, + 'layer' => $circle->layer, + 'mask' => $circle->mask, + ]; + } + } + + /** + * @return array{float, float} + */ + private function getWorldPosition(EntitiesInterface $entities, int $entityId, Transform $transform): array + { + $x = $transform->position->x; + $y = $transform->position->y; + $parent = $transform->parent; + while ($parent !== null) { + $pt = $entities->tryGet($parent, Transform::class); + if ($pt === null) { + break; + } + $x += $pt->position->x; + $y += $pt->position->y; + $parent = $pt->parent; + } + return [$x, $y]; + } + + /** + * Spatial grid broad phase. Returns pairs of indices into $colliders that share a grid cell. + * + * @param array> $colliders + * @return array + */ + private function broadPhase(array $colliders): array + { + /** @var array> $grid */ + $grid = []; + $inv = 1.0 / $this->cellSize; + + foreach ($colliders as $i => $c) { + // Compute AABB for grid insertion + if ($c['type'] === 'box') { + $minX = $c['cx'] - $c['halfW']; + $minY = $c['cy'] - $c['halfH']; + $maxX = $c['cx'] + $c['halfW']; + $maxY = $c['cy'] + $c['halfH']; + } else { + $r = $c['radius']; + $minX = $c['cx'] - $r; + $minY = $c['cy'] - $r; + $maxX = $c['cx'] + $r; + $maxY = $c['cy'] + $r; + } + + $cellMinX = (int) floor($minX * $inv); + $cellMinY = (int) floor($minY * $inv); + $cellMaxX = (int) floor($maxX * $inv); + $cellMaxY = (int) floor($maxY * $inv); + + for ($gx = $cellMinX; $gx <= $cellMaxX; $gx++) { + for ($gy = $cellMinY; $gy <= $cellMaxY; $gy++) { + $key = "{$gx},{$gy}"; + $grid[$key][] = $i; + } + } + } + + // Collect unique pairs from cells + /** @var array $pairMap */ + $pairMap = []; + foreach ($grid as $cell) { + $count = count($cell); + for ($i = 0; $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + $a = $cell[$i]; + $b = $cell[$j]; + // Skip self-collision (same entity with multiple colliders) + if ($colliders[$a]['entity'] === $colliders[$b]['entity']) { + continue; + } + $pairKey = $a < $b ? "{$a},{$b}" : "{$b},{$a}"; + if (!isset($pairMap[$pairKey])) { + $pairMap[$pairKey] = [$a, $b]; + } + } + } + } + + return array_values($pairMap); + } + + /** + * Narrow phase test between two colliders. + * + * @param array $a + * @param array $b + * @return array{contactX: float, contactY: float}|null + */ + private function narrowPhase(array $a, array $b): ?array + { + $typeA = $a['type']; + $typeB = $b['type']; + + if ($typeA === 'box' && $typeB === 'box') { + return $this->testBoxBox($a, $b); + } + if ($typeA === 'circle' && $typeB === 'circle') { + return $this->testCircleCircle($a, $b); + } + // box vs circle (order doesn't matter) + if ($typeA === 'box' && $typeB === 'circle') { + return $this->testBoxCircle($a, $b); + } + return $this->testBoxCircle($b, $a); + } + + /** + * @param array $a + * @param array $b + * @return array{contactX: float, contactY: float}|null + */ + private function testBoxBox(array $a, array $b): ?array + { + $dx = abs($a['cx'] - $b['cx']); + $dy = abs($a['cy'] - $b['cy']); + $overlapX = $a['halfW'] + $b['halfW'] - $dx; + $overlapY = $a['halfH'] + $b['halfH'] - $dy; + + if ($overlapX <= 0 || $overlapY <= 0) { + return null; + } + + return [ + 'contactX' => ($a['cx'] + $b['cx']) * 0.5, + 'contactY' => ($a['cy'] + $b['cy']) * 0.5, + ]; + } + + /** + * @param array $a + * @param array $b + * @return array{contactX: float, contactY: float}|null + */ + private function testCircleCircle(array $a, array $b): ?array + { + $dx = $b['cx'] - $a['cx']; + $dy = $b['cy'] - $a['cy']; + $distSq = $dx * $dx + $dy * $dy; + $minDist = $a['radius'] + $b['radius']; + + if ($distSq > $minDist * $minDist) { + return null; + } + + return [ + 'contactX' => ($a['cx'] + $b['cx']) * 0.5, + 'contactY' => ($a['cy'] + $b['cy']) * 0.5, + ]; + } + + /** + * @param array $box + * @param array $circle + * @return array{contactX: float, contactY: float}|null + */ + private function testBoxCircle(array $box, array $circle): ?array + { + // Closest point on box to circle center + $closestX = max($box['cx'] - $box['halfW'], min($circle['cx'], $box['cx'] + $box['halfW'])); + $closestY = max($box['cy'] - $box['halfH'], min($circle['cy'], $box['cy'] + $box['halfH'])); + + $dx = $circle['cx'] - $closestX; + $dy = $circle['cy'] - $closestY; + $distSq = $dx * $dx + $dy * $dy; + $r = $circle['radius']; + + if ($distSq > $r * $r) { + return null; + } + + return [ + 'contactX' => $closestX, + 'contactY' => $closestY, + ]; + } +} diff --git a/src/System/Collision3DSystem.php b/src/System/Collision3DSystem.php new file mode 100644 index 0000000..06d4822 --- /dev/null +++ b/src/System/Collision3DSystem.php @@ -0,0 +1,537 @@ + + */ + private array $activePairs = []; + + public function __construct( + private DispatcherInterface $dispatcher, + float $cellSize = 4.0, + ) { + $this->cellSize = $cellSize; + } + + public function register(EntitiesInterface $entities): void + { + $entities->registerComponent(BoxCollider3D::class); + $entities->registerComponent(SphereCollider3D::class); + $entities->registerComponent(CapsuleCollider3D::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function update(EntitiesInterface $entities): void + { + $colliders = []; + $this->collectBoxColliders($entities, $colliders); + $this->collectSphereColliders($entities, $colliders); + $this->collectCapsuleColliders($entities, $colliders); + + $candidates = $this->broadPhase($colliders); + + $currentPairs = []; + foreach ($candidates as [$idxA, $idxB]) { + $a = $colliders[$idxA]; + $b = $colliders[$idxB]; + + if (($a['layer'] & $b['mask']) === 0 || ($b['layer'] & $a['mask']) === 0) { + continue; + } + + $result = $this->narrowPhase($a, $b); + if ($result === null) { + continue; + } + + $entityA = $a['entity']; + $entityB = $b['entity']; + $pairKey = $entityA < $entityB ? "{$entityA}:{$entityB}" : "{$entityB}:{$entityA}"; + $currentPairs[$pairKey] = true; + + $isTrigger = $a['isTrigger'] || $b['isTrigger']; + + if ($isTrigger) { + $phase = isset($this->activePairs[$pairKey]) ? TriggerSignal::STAY : TriggerSignal::ENTER; + $this->dispatcher->dispatch('collision3d.trigger', new TriggerSignal($entityA, $entityB, $phase)); + } else { + $this->dispatcher->dispatch('collision3d', new Collision3DSignal( + $entityA, + $entityB, + $result['contact'], + $result['normal'], + $result['penetration'], + )); + } + } + + // trigger exit + foreach ($this->activePairs as $pairKey => $_) { + if (!isset($currentPairs[$pairKey])) { + [$entityA, $entityB] = explode(':', $pairKey); + $this->dispatcher->dispatch('collision3d.trigger', new TriggerSignal( + (int) $entityA, + (int) $entityB, + TriggerSignal::EXIT, + )); + } + } + + $this->activePairs = $currentPairs; + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + /** + * @param array> $colliders + */ + private function collectBoxColliders(EntitiesInterface $entities, array &$colliders): void + { + foreach ($entities->view(BoxCollider3D::class) as $entityId => $box) { + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $colliders[] = [ + 'entity' => $entityId, + 'type' => 'box', + 'cx' => $worldPos->x + $box->offset->x, + 'cy' => $worldPos->y + $box->offset->y, + 'cz' => $worldPos->z + $box->offset->z, + 'hx' => $box->halfExtents->x, + 'hy' => $box->halfExtents->y, + 'hz' => $box->halfExtents->z, + 'isTrigger' => $box->isTrigger, + 'layer' => $box->layer, + 'mask' => $box->mask, + ]; + } + } + + /** + * @param array> $colliders + */ + private function collectSphereColliders(EntitiesInterface $entities, array &$colliders): void + { + foreach ($entities->view(SphereCollider3D::class) as $entityId => $sphere) { + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $colliders[] = [ + 'entity' => $entityId, + 'type' => 'sphere', + 'cx' => $worldPos->x + $sphere->offset->x, + 'cy' => $worldPos->y + $sphere->offset->y, + 'cz' => $worldPos->z + $sphere->offset->z, + 'radius' => $sphere->radius, + 'isTrigger' => $sphere->isTrigger, + 'layer' => $sphere->layer, + 'mask' => $sphere->mask, + ]; + } + } + + /** + * @param array> $colliders + */ + private function collectCapsuleColliders(EntitiesInterface $entities, array &$colliders): void + { + foreach ($entities->view(CapsuleCollider3D::class) as $entityId => $capsule) { + $transform = $entities->tryGet($entityId, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + $colliders[] = [ + 'entity' => $entityId, + 'type' => 'capsule', + 'cx' => $worldPos->x + $capsule->offset->x, + 'cy' => $worldPos->y + $capsule->offset->y, + 'cz' => $worldPos->z + $capsule->offset->z, + 'radius' => $capsule->radius, + 'halfHeight' => $capsule->halfHeight, + 'isTrigger' => $capsule->isTrigger, + 'layer' => $capsule->layer, + 'mask' => $capsule->mask, + ]; + } + } + + /** + * 3D spatial grid broad phase. + * + * @param array> $colliders + * @return array + */ + private function broadPhase(array $colliders): array + { + /** @var array> $grid */ + $grid = []; + $inv = 1.0 / $this->cellSize; + + foreach ($colliders as $i => $c) { + $extent = $this->getColliderExtent($c); + $minX = (int) floor(($c['cx'] - $extent) * $inv); + $minY = (int) floor(($c['cy'] - $extent) * $inv); + $minZ = (int) floor(($c['cz'] - $extent) * $inv); + $maxX = (int) floor(($c['cx'] + $extent) * $inv); + $maxY = (int) floor(($c['cy'] + $extent) * $inv); + $maxZ = (int) floor(($c['cz'] + $extent) * $inv); + + for ($gx = $minX; $gx <= $maxX; $gx++) { + for ($gy = $minY; $gy <= $maxY; $gy++) { + for ($gz = $minZ; $gz <= $maxZ; $gz++) { + $key = "{$gx},{$gy},{$gz}"; + $grid[$key][] = $i; + } + } + } + } + + /** @var array $pairMap */ + $pairMap = []; + foreach ($grid as $cell) { + $count = count($cell); + for ($i = 0; $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + $a = $cell[$i]; + $b = $cell[$j]; + if ($colliders[$a]['entity'] === $colliders[$b]['entity']) continue; + $pairKey = $a < $b ? "{$a},{$b}" : "{$b},{$a}"; + if (!isset($pairMap[$pairKey])) { + $pairMap[$pairKey] = [$a, $b]; + } + } + } + } + + return array_values($pairMap); + } + + /** + * @param array $c + */ + private function getColliderExtent(array $c): float + { + return match ($c['type']) { + 'box' => max($c['hx'], $c['hy'], $c['hz']), + 'sphere' => $c['radius'], + 'capsule' => max($c['radius'], $c['halfHeight'] + $c['radius']), + default => 1.0, + }; + } + + /** + * @param array $a + * @param array $b + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function narrowPhase(array $a, array $b): ?array + { + $typeA = $a['type']; + $typeB = $b['type']; + + // sphere-sphere + if ($typeA === 'sphere' && $typeB === 'sphere') { + return $this->testSphereSphere($a, $b); + } + + // box-box (AABB) + if ($typeA === 'box' && $typeB === 'box') { + return $this->testBoxBox($a, $b); + } + + // sphere-box + if ($typeA === 'sphere' && $typeB === 'box') { + return $this->testSphereBox($a, $b); + } + if ($typeA === 'box' && $typeB === 'sphere') { + $result = $this->testSphereBox($b, $a); + if ($result !== null) { + $result['normal'] = new Vec3(-$result['normal']->x, -$result['normal']->y, -$result['normal']->z); + } + return $result; + } + + // capsule-sphere + if ($typeA === 'capsule' && $typeB === 'sphere') { + return $this->testCapsuleSphere($a, $b); + } + if ($typeA === 'sphere' && $typeB === 'capsule') { + $result = $this->testCapsuleSphere($b, $a); + if ($result !== null) { + $result['normal'] = new Vec3(-$result['normal']->x, -$result['normal']->y, -$result['normal']->z); + } + return $result; + } + + // capsule-capsule + if ($typeA === 'capsule' && $typeB === 'capsule') { + return $this->testCapsuleCapsule($a, $b); + } + + // capsule-box + if ($typeA === 'capsule' && $typeB === 'box') { + return $this->testCapsuleBox($a, $b); + } + if ($typeA === 'box' && $typeB === 'capsule') { + $result = $this->testCapsuleBox($b, $a); + if ($result !== null) { + $result['normal'] = new Vec3(-$result['normal']->x, -$result['normal']->y, -$result['normal']->z); + } + return $result; + } + + return null; + } + + /** + * @param array $a + * @param array $b + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function testSphereSphere(array $a, array $b): ?array + { + $dx = $b['cx'] - $a['cx']; + $dy = $b['cy'] - $a['cy']; + $dz = $b['cz'] - $a['cz']; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $minDist = $a['radius'] + $b['radius']; + + if ($distSq > $minDist * $minDist) return null; + + $dist = sqrt($distSq); + $penetration = $minDist - $dist; + + if ($dist > 1e-8) { + $normal = new Vec3($dx / $dist, $dy / $dist, $dz / $dist); + } else { + $normal = new Vec3(0.0, 1.0, 0.0); + } + + return [ + 'contact' => new Vec3( + $a['cx'] + $normal->x * $a['radius'], + $a['cy'] + $normal->y * $a['radius'], + $a['cz'] + $normal->z * $a['radius'], + ), + 'normal' => $normal, + 'penetration' => $penetration, + ]; + } + + /** + * @param array $a + * @param array $b + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function testBoxBox(array $a, array $b): ?array + { + $dx = abs($b['cx'] - $a['cx']); + $dy = abs($b['cy'] - $a['cy']); + $dz = abs($b['cz'] - $a['cz']); + + $overlapX = $a['hx'] + $b['hx'] - $dx; + $overlapY = $a['hy'] + $b['hy'] - $dy; + $overlapZ = $a['hz'] + $b['hz'] - $dz; + + if ($overlapX <= 0 || $overlapY <= 0 || $overlapZ <= 0) return null; + + // minimum penetration axis + $contact = new Vec3( + ($a['cx'] + $b['cx']) * 0.5, + ($a['cy'] + $b['cy']) * 0.5, + ($a['cz'] + $b['cz']) * 0.5, + ); + + if ($overlapX <= $overlapY && $overlapX <= $overlapZ) { + $sign = ($b['cx'] - $a['cx']) >= 0 ? 1.0 : -1.0; + return ['contact' => $contact, 'normal' => new Vec3($sign, 0.0, 0.0), 'penetration' => $overlapX]; + } + if ($overlapY <= $overlapZ) { + $sign = ($b['cy'] - $a['cy']) >= 0 ? 1.0 : -1.0; + return ['contact' => $contact, 'normal' => new Vec3(0.0, $sign, 0.0), 'penetration' => $overlapY]; + } + $sign = ($b['cz'] - $a['cz']) >= 0 ? 1.0 : -1.0; + return ['contact' => $contact, 'normal' => new Vec3(0.0, 0.0, $sign), 'penetration' => $overlapZ]; + } + + /** + * Sphere (a) vs Box (b) + * + * @param array $sphere + * @param array $box + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function testSphereBox(array $sphere, array $box): ?array + { + // closest point on box to sphere center + $closestX = max($box['cx'] - $box['hx'], min($sphere['cx'], $box['cx'] + $box['hx'])); + $closestY = max($box['cy'] - $box['hy'], min($sphere['cy'], $box['cy'] + $box['hy'])); + $closestZ = max($box['cz'] - $box['hz'], min($sphere['cz'], $box['cz'] + $box['hz'])); + + $dx = $sphere['cx'] - $closestX; + $dy = $sphere['cy'] - $closestY; + $dz = $sphere['cz'] - $closestZ; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $r = $sphere['radius']; + + if ($distSq > $r * $r) return null; + + $dist = sqrt($distSq); + if ($dist > 1e-8) { + $normal = new Vec3($dx / $dist, $dy / $dist, $dz / $dist); + } else { + $normal = new Vec3(0.0, 1.0, 0.0); + } + + return [ + 'contact' => new Vec3($closestX, $closestY, $closestZ), + 'normal' => $normal, + 'penetration' => $r - $dist, + ]; + } + + /** + * Capsule (a) vs Sphere (b). + * Y-axis aligned capsule. + * + * @param array $capsule + * @param array $sphere + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function testCapsuleSphere(array $capsule, array $sphere): ?array + { + // closest point on capsule's line segment to sphere center + $segY = max($capsule['cy'] - $capsule['halfHeight'], min($capsule['cy'] + $capsule['halfHeight'], $sphere['cy'])); + + $dx = $sphere['cx'] - $capsule['cx']; + $dy = $sphere['cy'] - $segY; + $dz = $sphere['cz'] - $capsule['cz']; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $minDist = $capsule['radius'] + $sphere['radius']; + + if ($distSq > $minDist * $minDist) return null; + + $dist = sqrt($distSq); + $penetration = $minDist - $dist; + + if ($dist > 1e-8) { + $normal = new Vec3($dx / $dist, $dy / $dist, $dz / $dist); + } else { + $normal = new Vec3(0.0, 1.0, 0.0); + } + + return [ + 'contact' => new Vec3( + $capsule['cx'] + $normal->x * $capsule['radius'], + $segY + $normal->y * $capsule['radius'], + $capsule['cz'] + $normal->z * $capsule['radius'], + ), + 'normal' => $normal, + 'penetration' => $penetration, + ]; + } + + /** + * Capsule vs Capsule (both Y-axis aligned). + * + * @param array $a + * @param array $b + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function testCapsuleCapsule(array $a, array $b): ?array + { + // find closest points between the two line segments (simplified Y-axis) + $aTop = $a['cy'] + $a['halfHeight']; + $aBot = $a['cy'] - $a['halfHeight']; + $bTop = $b['cy'] + $b['halfHeight']; + $bBot = $b['cy'] - $b['halfHeight']; + + // clamp each segment's center-Y to the other + $clampedAY = max($aBot, min($aTop, $b['cy'])); + $clampedBY = max($bBot, min($bTop, $clampedAY)); + // re-clamp A to the clamped B + $clampedAY = max($aBot, min($aTop, $clampedBY)); + + $dx = $b['cx'] - $a['cx']; + $dy = $clampedBY - $clampedAY; + $dz = $b['cz'] - $a['cz']; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $minDist = $a['radius'] + $b['radius']; + + if ($distSq > $minDist * $minDist) return null; + + $dist = sqrt($distSq); + $penetration = $minDist - $dist; + + if ($dist > 1e-8) { + $normal = new Vec3($dx / $dist, $dy / $dist, $dz / $dist); + } else { + $normal = new Vec3(0.0, 1.0, 0.0); + } + + return [ + 'contact' => new Vec3( + $a['cx'] + $normal->x * $a['radius'], + $clampedAY + $normal->y * $a['radius'], + $a['cz'] + $normal->z * $a['radius'], + ), + 'normal' => $normal, + 'penetration' => $penetration, + ]; + } + + /** + * Capsule vs Box (approximate: capsule segment closest point to box, then sphere-box test). + * + * @param array $capsule + * @param array $box + * @return array{contact: Vec3, normal: Vec3, penetration: float}|null + */ + private function testCapsuleBox(array $capsule, array $box): ?array + { + // find the point on the capsule's segment closest to the box center + $segY = max( + $capsule['cy'] - $capsule['halfHeight'], + min($capsule['cy'] + $capsule['halfHeight'], $box['cy']), + ); + + // treat as sphere at that point + $tempSphere = [ + 'cx' => $capsule['cx'], + 'cy' => $segY, + 'cz' => $capsule['cz'], + 'radius' => $capsule['radius'], + ]; + + return $this->testSphereBox($tempSphere, $box); + } +} diff --git a/src/System/ParticleSystem.php b/src/System/ParticleSystem.php new file mode 100644 index 0000000..f0a1f6b --- /dev/null +++ b/src/System/ParticleSystem.php @@ -0,0 +1,292 @@ + + */ + private array $pools = []; + + /** + * Fixed delta time for simulation + */ + public float $deltaTime = 1.0 / 60.0; + + public function register(EntitiesInterface $entities): void + { + $entities->registerComponent(ParticleEmitterComponent::class); + $entities->registerComponent(Transform::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + public function update(EntitiesInterface $entities): void + { + $dt = $this->deltaTime; + + foreach ($entities->view(ParticleEmitterComponent::class) as $entity => $emitter) { + // ensure pool exists + if (!isset($this->pools[$entity])) { + $this->pools[$entity] = new ParticlePool($emitter->maxParticles); + } + + $pool = $this->pools[$entity]; + $transform = $entities->get($entity, Transform::class); + $worldPos = $transform->getWorldPosition($entities); + + if ($emitter->playing) { + $emitter->elapsedTime += $dt; + + // initial burst + if (!$emitter->burstEmitted && $emitter->burstCount > 0) { + $this->emitParticles($emitter, $pool, $worldPos, $emitter->burstCount); + $emitter->burstEmitted = true; + } + + // rate-based emission + if ($emitter->emissionRate > 0) { + $emitter->emissionAccumulator += $dt * $emitter->emissionRate; + $toEmit = (int)$emitter->emissionAccumulator; + if ($toEmit > 0) { + $emitter->emissionAccumulator -= $toEmit; + $this->emitParticles($emitter, $pool, $worldPos, $toEmit); + } + } + + // handle duration for non-looping emitters + if (!$emitter->looping && $emitter->elapsedTime >= $emitter->duration) { + $emitter->playing = false; + } + } + + // simulate particles + $pool->simulate($dt, $emitter->gravityModifier, $emitter->drag); + } + + // clean up pools for destroyed entities + foreach ($this->pools as $entity => $pool) { + if (!$entities->valid($entity)) { + unset($this->pools[$entity]); + } + } + } + + /** + * Returns the particle pool for a given entity, or null if none exists. + */ + public function getPool(int $entity): ?ParticlePool + { + return $this->pools[$entity] ?? null; + } + + /** + * Returns all active pools indexed by entity ID. + * @return array + */ + public function getPools(): array + { + return $this->pools; + } + + private function emitParticles( + ParticleEmitterComponent $emitter, + ParticlePool $pool, + Vec3 $worldPos, + int $count, + ): void { + for ($n = 0; $n < $count; $n++) { + [$ox, $oy, $oz] = $this->computeSpawnOffset($emitter); + [$dx, $dy, $dz] = $this->computeDirection($emitter); + + $speed = $this->randomRange($emitter->speedMin, $emitter->speedMax); + $lifetime = $this->randomRange($emitter->lifetimeMin, $emitter->lifetimeMax); + + $pool->emit( + $worldPos->x + $ox, + $worldPos->y + $oy, + $worldPos->z + $oz, + $dx * $speed, + $dy * $speed, + $dz * $speed, + $emitter->startColor->x, + $emitter->startColor->y, + $emitter->startColor->z, + $emitter->startColor->w, + $emitter->endColor->x, + $emitter->endColor->y, + $emitter->endColor->z, + $emitter->endColor->w, + $emitter->startSize, + $emitter->endSize, + $lifetime, + ); + } + } + + /** + * Computes a spawn position offset based on the emitter shape. + * @return array{float, float, float} + */ + private function computeSpawnOffset(ParticleEmitterComponent $emitter): array + { + return match ($emitter->shape) { + ParticleEmitterShape::Point => [0.0, 0.0, 0.0], + ParticleEmitterShape::Sphere => $this->randomSpherePoint($emitter->sphereRadius), + ParticleEmitterShape::Cone => [0.0, 0.0, 0.0], // cone emits from apex + ParticleEmitterShape::Box => [ + $this->randomRange(-$emitter->boxHalfExtents->x, $emitter->boxHalfExtents->x), + $this->randomRange(-$emitter->boxHalfExtents->y, $emitter->boxHalfExtents->y), + $this->randomRange(-$emitter->boxHalfExtents->z, $emitter->boxHalfExtents->z), + ], + }; + } + + /** + * Computes a normalized emission direction based on the emitter settings. + * @return array{float, float, float} + */ + private function computeDirection(ParticleEmitterComponent $emitter): array + { + if ($emitter->shape === ParticleEmitterShape::Sphere) { + return $this->randomUnitSphere(); + } + + if ($emitter->shape === ParticleEmitterShape::Cone) { + return $this->randomConeDirection( + $emitter->direction->x, + $emitter->direction->y, + $emitter->direction->z, + $emitter->coneAngle, + ); + } + + // Point/Box: use direction with optional randomness + $dx = $emitter->direction->x; + $dy = $emitter->direction->y; + $dz = $emitter->direction->z; + + if ($emitter->directionRandomness > 0.0) { + [$rx, $ry, $rz] = $this->randomUnitSphere(); + $r = $emitter->directionRandomness; + $dx = $dx * (1.0 - $r) + $rx * $r; + $dy = $dy * (1.0 - $r) + $ry * $r; + $dz = $dz * (1.0 - $r) + $rz * $r; + + // renormalize + $len = sqrt($dx * $dx + $dy * $dy + $dz * $dz); + if ($len > 0.0001) { + $dx /= $len; + $dy /= $len; + $dz /= $len; + } + } + + return [$dx, $dy, $dz]; + } + + /** + * @return array{float, float, float} + */ + private function randomUnitSphere(): array + { + // uniform random point on unit sphere + $theta = $this->randomRange(0.0, 2.0 * M_PI); + $phi = acos($this->randomRange(-1.0, 1.0)); + $sinPhi = sin($phi); + return [ + $sinPhi * cos($theta), + $sinPhi * sin($theta), + cos($phi), + ]; + } + + /** + * @return array{float, float, float} + */ + private function randomSpherePoint(float $radius): array + { + [$x, $y, $z] = $this->randomUnitSphere(); + $r = $radius * pow($this->randomRange(0.0, 1.0), 1.0 / 3.0); + return [$x * $r, $y * $r, $z * $r]; + } + + /** + * @return array{float, float, float} + */ + private function randomConeDirection(float $dx, float $dy, float $dz, float $angleDeg): array + { + $angleRad = GLM::radians($angleDeg); + $cosAngle = cos($angleRad); + + // random point in cone around (0,0,1), then rotate to match direction + $z = $this->randomRange($cosAngle, 1.0); + $phi = $this->randomRange(0.0, 2.0 * M_PI); + $sinZ = sqrt(1.0 - $z * $z); + + $lx = $sinZ * cos($phi); + $ly = $sinZ * sin($phi); + $lz = $z; + + // build rotation from (0,0,1) to (dx, dy, dz) + $len = sqrt($dx * $dx + $dy * $dy + $dz * $dz); + if ($len < 0.0001) { + return [$lx, $ly, $lz]; + } + $dx /= $len; + $dy /= $len; + $dz /= $len; + + // find rotation axis and angle (cross product of (0,0,1) and dir) + $cx = -$dy; // (0,0,1) x (dx,dy,dz) + $cy = $dx; + $cz = 0.0; + $cLen = sqrt($cx * $cx + $cy * $cy); + + if ($cLen < 0.0001) { + // direction is (anti-)parallel to Z + return $dz > 0 ? [$lx, $ly, $lz] : [-$lx, -$ly, -$lz]; + } + + $cx /= $cLen; + $cy /= $cLen; + + $dot = $dz; // dot((0,0,1), dir) + $cosA = $dot; + $sinA = $cLen; + + // Rodrigues' rotation: v' = v*cosA + (k x v)*sinA + k*(k.v)*(1-cosA) + $kdotV = $cx * $lx + $cy * $ly; // cz is 0 + $kxVx = $cy * $lz; + $kxVy = -$cx * $lz; + $kxVz = $cx * $ly - $cy * $lx; + + $rx = $lx * $cosA + $kxVx * $sinA + $cx * $kdotV * (1.0 - $cosA); + $ry = $ly * $cosA + $kxVy * $sinA + $cy * $kdotV * (1.0 - $cosA); + $rz = $lz * $cosA + $kxVz * $sinA + 0.0; + + return [$rx, $ry, $rz]; + } + + private function randomRange(float $min, float $max): float + { + return $min + (mt_rand() / mt_getrandmax()) * ($max - $min); + } +} diff --git a/src/System/Physics3DSystem.php b/src/System/Physics3DSystem.php new file mode 100644 index 0000000..05a5806 --- /dev/null +++ b/src/System/Physics3DSystem.php @@ -0,0 +1,614 @@ +gravity = $gravity ?? new Vec3(0.0, -9.81, 0.0); + } + + public function register(EntitiesInterface $entities): void + { + $entities->registerComponent(RigidBody3D::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function update(EntitiesInterface $entities): void + { + $dt = $this->fixedDeltaTime; + + // 1. Apply forces (gravity) + $this->applyGravity($entities, $dt); + + // 2. Integrate velocities → positions (semi-implicit Euler) + $this->integrate($entities, $dt); + + // 3. Detect and resolve collisions + $this->detectAndResolveCollisions($entities); + + // 4. Apply damping + $this->applyDamping($entities, $dt); + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + private function applyGravity(EntitiesInterface $entities, float $dt): void + { + $gravity = $this->gravity; + foreach ($entities->view(RigidBody3D::class) as $entity => $rb) { + if ($rb->isKinematic || $rb->mass <= 0.0) continue; + + $vel = $rb->velocity; + $force = $rb->force; + $gs = $rb->gravityScale; + $m = $rb->mass; + $vel->x = $vel->x + ($gravity->x * $gs + $force->x / $m) * $dt; + $vel->y = $vel->y + ($gravity->y * $gs + $force->y / $m) * $dt; + $vel->z = $vel->z + ($gravity->z * $gs + $force->z / $m) * $dt; + + // clear accumulated force + $force->x = 0.0; + $force->y = 0.0; + $force->z = 0.0; + } + } + + private function integrate(EntitiesInterface $entities, float $dt): void + { + foreach ($entities->view(RigidBody3D::class) as $entity => $rb) { + if ($rb->isKinematic || $rb->mass <= 0.0) continue; + + $transform = $entities->tryGet($entity, Transform::class); + if ($transform === null) continue; + + $vel = $rb->velocity; + // apply freeze constraints + if ($rb->freezePositionX) $vel->x = 0.0; + if ($rb->freezePositionY) $vel->y = 0.0; + if ($rb->freezePositionZ) $vel->z = 0.0; + + $pos = $transform->position; + $pos->x = $pos->x + $vel->x * $dt; + $pos->y = $pos->y + $vel->y * $dt; + $pos->z = $pos->z + $vel->z * $dt; + $transform->markDirty(); + } + } + + private function applyDamping(EntitiesInterface $entities, float $dt): void + { + foreach ($entities->view(RigidBody3D::class) as $entity => $rb) { + if ($rb->isKinematic || $rb->mass <= 0.0) continue; + + $linearFactor = max(0.0, 1.0 - $rb->linearDrag * $dt); + $vel = $rb->velocity; + $vel->x = $vel->x * $linearFactor; + $vel->y = $vel->y * $linearFactor; + $vel->z = $vel->z * $linearFactor; + + $angularFactor = max(0.0, 1.0 - $rb->angularDrag * $dt); + $angVel = $rb->angularVelocity; + $angVel->x = $angVel->x * $angularFactor; + $angVel->y = $angVel->y * $angularFactor; + $angVel->z = $angVel->z * $angularFactor; + } + } + + private function detectAndResolveCollisions(EntitiesInterface $entities): void + { + // collect physics bodies with colliders + $bodies = []; + $this->collectBodies($entities, $bodies); + + if (count($bodies) < 2) return; + + for ($iter = 0; $iter < $this->solverIterations; $iter++) { + $count = count($bodies); + for ($i = 0; $i < $count; $i++) { + for ($j = $i + 1; $j < $count; $j++) { + $a = $bodies[$i]; + $b = $bodies[$j]; + + // layer/mask check + if (($a['layer'] & $b['mask']) === 0 || ($b['layer'] & $a['mask']) === 0) { + continue; + } + + // both static? skip + $invMassA = $a['rb']->inverseMass(); + $invMassB = $b['rb']->inverseMass(); + if ($invMassA <= 0.0 && $invMassB <= 0.0) continue; + + $contact = $this->testCollision($a, $b); + if ($contact === null) continue; + + $this->resolveCollision($entities, $a, $b, $contact); + } + } + + // re-collect positions for next iteration + if ($iter < $this->solverIterations - 1) { + $this->refreshBodyPositions($entities, $bodies); + } + } + } + + /** + * @param array> $bodies + */ + private function collectBodies(EntitiesInterface $entities, array &$bodies): void + { + foreach ($entities->view(RigidBody3D::class) as $entity => $rb) { + $transform = $entities->tryGet($entity, Transform::class); + if ($transform === null) continue; + + $worldPos = $transform->getWorldPosition($entities); + + // determine collider type and properties + $box = $entities->tryGet($entity, BoxCollider3D::class); + if ($box !== null) { + $bodies[] = [ + 'entity' => $entity, + 'rb' => $rb, + 'transform' => $transform, + 'type' => 'box', + 'cx' => $worldPos->x + $box->offset->x, + 'cy' => $worldPos->y + $box->offset->y, + 'cz' => $worldPos->z + $box->offset->z, + 'hx' => $box->halfExtents->x, + 'hy' => $box->halfExtents->y, + 'hz' => $box->halfExtents->z, + 'layer' => $box->layer, + 'mask' => $box->mask, + ]; + continue; + } + + $sphere = $entities->tryGet($entity, SphereCollider3D::class); + if ($sphere !== null) { + $bodies[] = [ + 'entity' => $entity, + 'rb' => $rb, + 'transform' => $transform, + 'type' => 'sphere', + 'cx' => $worldPos->x + $sphere->offset->x, + 'cy' => $worldPos->y + $sphere->offset->y, + 'cz' => $worldPos->z + $sphere->offset->z, + 'radius' => $sphere->radius, + 'layer' => $sphere->layer, + 'mask' => $sphere->mask, + ]; + continue; + } + + $capsule = $entities->tryGet($entity, CapsuleCollider3D::class); + if ($capsule !== null) { + $bodies[] = [ + 'entity' => $entity, + 'rb' => $rb, + 'transform' => $transform, + 'type' => 'capsule', + 'cx' => $worldPos->x + $capsule->offset->x, + 'cy' => $worldPos->y + $capsule->offset->y, + 'cz' => $worldPos->z + $capsule->offset->z, + 'radius' => $capsule->radius, + 'halfHeight' => $capsule->halfHeight, + 'layer' => $capsule->layer, + 'mask' => $capsule->mask, + ]; + } + } + } + + /** + * @param array> $bodies + */ + private function refreshBodyPositions(EntitiesInterface $entities, array &$bodies): void + { + foreach ($bodies as &$body) { + $worldPos = $body['transform']->getWorldPosition($entities); + $offset = match ($body['type']) { + 'box' => $entities->get($body['entity'], BoxCollider3D::class)->offset, + 'sphere' => $entities->get($body['entity'], SphereCollider3D::class)->offset, + 'capsule' => $entities->get($body['entity'], CapsuleCollider3D::class)->offset, + default => new Vec3(0, 0, 0), + }; + $body['cx'] = $worldPos->x + $offset->x; + $body['cy'] = $worldPos->y + $offset->y; + $body['cz'] = $worldPos->z + $offset->z; + } + } + + /** + * @param array $a + * @param array $b + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testCollision(array $a, array $b): ?array + { + $typeA = $a['type']; + $typeB = $b['type']; + + if ($typeA === 'sphere' && $typeB === 'sphere') { + return $this->testSphereSphere($a, $b); + } + if ($typeA === 'box' && $typeB === 'box') { + return $this->testBoxBox($a, $b); + } + if ($typeA === 'sphere' && $typeB === 'box') { + return $this->testSphereBox($a, $b); + } + if ($typeA === 'box' && $typeB === 'sphere') { + $r = $this->testSphereBox($b, $a); + if ($r !== null) $r['normal'] = new Vec3(-$r['normal']->x, -$r['normal']->y, -$r['normal']->z); + return $r; + } + + // capsule pairs + if ($typeA === 'capsule' && $typeB === 'sphere') { + return $this->testCapsuleSphere($a, $b); + } + if ($typeA === 'sphere' && $typeB === 'capsule') { + $r = $this->testCapsuleSphere($b, $a); + if ($r !== null) $r['normal'] = new Vec3(-$r['normal']->x, -$r['normal']->y, -$r['normal']->z); + return $r; + } + if ($typeA === 'capsule' && $typeB === 'capsule') { + return $this->testCapsuleCapsule($a, $b); + } + if ($typeA === 'capsule' && $typeB === 'box') { + return $this->testCapsuleBox($a, $b); + } + if ($typeA === 'box' && $typeB === 'capsule') { + $r = $this->testCapsuleBox($b, $a); + if ($r !== null) $r['normal'] = new Vec3(-$r['normal']->x, -$r['normal']->y, -$r['normal']->z); + return $r; + } + + return null; + } + + /** + * @param array $a + * @param array $b + * @param array{normal: Vec3, penetration: float, contact: Vec3} $contact + */ + private function resolveCollision(EntitiesInterface $entities, array $a, array $b, array $contact): void + { + /** @var RigidBody3D $rbA */ + $rbA = $a['rb']; + /** @var RigidBody3D $rbB */ + $rbB = $b['rb']; + + $invMassA = $rbA->inverseMass(); + $invMassB = $rbB->inverseMass(); + $totalInvMass = $invMassA + $invMassB; + if ($totalInvMass <= 0.0) return; + + $normal = $contact['normal']; + $penetration = $contact['penetration']; + + $velA = $rbA->velocity; + $velB = $rbB->velocity; + + // relative velocity + $relVelX = $velB->x - $velA->x; + $relVelY = $velB->y - $velA->y; + $relVelZ = $velB->z - $velA->z; + + $velAlongNormal = $relVelX * $normal->x + $relVelY * $normal->y + $relVelZ * $normal->z; + + // only resolve if objects are moving towards each other + if ($velAlongNormal > 0) { + // still need positional correction + $this->positionalCorrection($a, $b, $invMassA, $invMassB, $totalInvMass, $normal, $penetration); + return; + } + + // restitution (use minimum) + $e = min($rbA->restitution, $rbB->restitution); + + // impulse magnitude + $j = -(1.0 + $e) * $velAlongNormal / $totalInvMass; + + // apply impulse + $velA->x = $velA->x - $j * $invMassA * $normal->x; + $velA->y = $velA->y - $j * $invMassA * $normal->y; + $velA->z = $velA->z - $j * $invMassA * $normal->z; + + $velB->x = $velB->x + $j * $invMassB * $normal->x; + $velB->y = $velB->y + $j * $invMassB * $normal->y; + $velB->z = $velB->z + $j * $invMassB * $normal->z; + + // friction impulse (tangential) + $tangentX = $relVelX - $velAlongNormal * $normal->x; + $tangentY = $relVelY - $velAlongNormal * $normal->y; + $tangentZ = $relVelZ - $velAlongNormal * $normal->z; + $tangentLen = sqrt($tangentX * $tangentX + $tangentY * $tangentY + $tangentZ * $tangentZ); + + if ($tangentLen > 1e-8) { + $tangentX /= $tangentLen; + $tangentY /= $tangentLen; + $tangentZ /= $tangentLen; + + $jt = -($relVelX * $tangentX + $relVelY * $tangentY + $relVelZ * $tangentZ) / $totalInvMass; + $mu = min($rbA->friction, $rbB->friction); + + // Coulomb's law: clamp friction impulse + $jt = max(-abs($j) * $mu, min(abs($j) * $mu, $jt)); + + $velA->x = $velA->x - $jt * $invMassA * $tangentX; + $velA->y = $velA->y - $jt * $invMassA * $tangentY; + $velA->z = $velA->z - $jt * $invMassA * $tangentZ; + + $velB->x = $velB->x + $jt * $invMassB * $tangentX; + $velB->y = $velB->y + $jt * $invMassB * $tangentY; + $velB->z = $velB->z + $jt * $invMassB * $tangentZ; + } + + // positional correction (Baumgarte stabilization) + $this->positionalCorrection($a, $b, $invMassA, $invMassB, $totalInvMass, $normal, $penetration); + + // dispatch collision signal + $this->dispatcher->dispatch('collision3d', new Collision3DSignal( + $a['entity'], + $b['entity'], + $contact['contact'], + $normal, + $penetration, + )); + } + + /** + * @param array $a + * @param array $b + */ + private function positionalCorrection( + array $a, + array $b, + float $invMassA, + float $invMassB, + float $totalInvMass, + Vec3 $normal, + float $penetration, + ): void { + $correction = max($penetration - $this->penetrationSlop, 0.0) * $this->baumgarteFactor / $totalInvMass; + + /** @var Transform $transformA */ + $transformA = $a['transform']; + /** @var Transform $transformB */ + $transformB = $b['transform']; + + $posA = $transformA->position; + $posA->x = $posA->x - $correction * $invMassA * $normal->x; + $posA->y = $posA->y - $correction * $invMassA * $normal->y; + $posA->z = $posA->z - $correction * $invMassA * $normal->z; + $transformA->markDirty(); + + $posB = $transformB->position; + $posB->x = $posB->x + $correction * $invMassB * $normal->x; + $posB->y = $posB->y + $correction * $invMassB * $normal->y; + $posB->z = $posB->z + $correction * $invMassB * $normal->z; + $transformB->markDirty(); + } + + // -- Narrow phase (same algorithms as Collision3DSystem) -- + + /** + * @param array $a + * @param array $b + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testSphereSphere(array $a, array $b): ?array + { + $dx = $b['cx'] - $a['cx']; + $dy = $b['cy'] - $a['cy']; + $dz = $b['cz'] - $a['cz']; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $minDist = $a['radius'] + $b['radius']; + if ($distSq > $minDist * $minDist) return null; + + $dist = sqrt($distSq); + if ($dist > 1e-8) { + $normal = new Vec3($dx / $dist, $dy / $dist, $dz / $dist); + } else { + $normal = new Vec3(0.0, 1.0, 0.0); + } + + return [ + 'normal' => $normal, + 'penetration' => $minDist - $dist, + 'contact' => new Vec3( + $a['cx'] + $normal->x * $a['radius'], + $a['cy'] + $normal->y * $a['radius'], + $a['cz'] + $normal->z * $a['radius'], + ), + ]; + } + + /** + * @param array $a + * @param array $b + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testBoxBox(array $a, array $b): ?array + { + $dx = abs($b['cx'] - $a['cx']); + $dy = abs($b['cy'] - $a['cy']); + $dz = abs($b['cz'] - $a['cz']); + $overlapX = $a['hx'] + $b['hx'] - $dx; + $overlapY = $a['hy'] + $b['hy'] - $dy; + $overlapZ = $a['hz'] + $b['hz'] - $dz; + if ($overlapX <= 0 || $overlapY <= 0 || $overlapZ <= 0) return null; + + $contact = new Vec3(($a['cx'] + $b['cx']) * 0.5, ($a['cy'] + $b['cy']) * 0.5, ($a['cz'] + $b['cz']) * 0.5); + + if ($overlapX <= $overlapY && $overlapX <= $overlapZ) { + $sign = ($b['cx'] - $a['cx']) >= 0 ? 1.0 : -1.0; + return ['normal' => new Vec3($sign, 0, 0), 'penetration' => $overlapX, 'contact' => $contact]; + } + if ($overlapY <= $overlapZ) { + $sign = ($b['cy'] - $a['cy']) >= 0 ? 1.0 : -1.0; + return ['normal' => new Vec3(0, $sign, 0), 'penetration' => $overlapY, 'contact' => $contact]; + } + $sign = ($b['cz'] - $a['cz']) >= 0 ? 1.0 : -1.0; + return ['normal' => new Vec3(0, 0, $sign), 'penetration' => $overlapZ, 'contact' => $contact]; + } + + /** + * @param array $sphere + * @param array $box + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testSphereBox(array $sphere, array $box): ?array + { + $closestX = max($box['cx'] - $box['hx'], min($sphere['cx'], $box['cx'] + $box['hx'])); + $closestY = max($box['cy'] - $box['hy'], min($sphere['cy'], $box['cy'] + $box['hy'])); + $closestZ = max($box['cz'] - $box['hz'], min($sphere['cz'], $box['cz'] + $box['hz'])); + + $dx = $sphere['cx'] - $closestX; + $dy = $sphere['cy'] - $closestY; + $dz = $sphere['cz'] - $closestZ; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $r = $sphere['radius']; + if ($distSq > $r * $r) return null; + + $dist = sqrt($distSq); + $normal = $dist > 1e-8 ? new Vec3($dx / $dist, $dy / $dist, $dz / $dist) : new Vec3(0, 1, 0); + + return [ + 'normal' => $normal, + 'penetration' => $r - $dist, + 'contact' => new Vec3($closestX, $closestY, $closestZ), + ]; + } + + /** + * @param array $capsule + * @param array $sphere + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testCapsuleSphere(array $capsule, array $sphere): ?array + { + $segY = max($capsule['cy'] - $capsule['halfHeight'], min($capsule['cy'] + $capsule['halfHeight'], $sphere['cy'])); + $dx = $sphere['cx'] - $capsule['cx']; + $dy = $sphere['cy'] - $segY; + $dz = $sphere['cz'] - $capsule['cz']; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $minDist = $capsule['radius'] + $sphere['radius']; + if ($distSq > $minDist * $minDist) return null; + + $dist = sqrt($distSq); + $normal = $dist > 1e-8 ? new Vec3($dx / $dist, $dy / $dist, $dz / $dist) : new Vec3(0, 1, 0); + + return [ + 'normal' => $normal, + 'penetration' => $minDist - $dist, + 'contact' => new Vec3( + $capsule['cx'] + $normal->x * $capsule['radius'], + $segY + $normal->y * $capsule['radius'], + $capsule['cz'] + $normal->z * $capsule['radius'], + ), + ]; + } + + /** + * @param array $a + * @param array $b + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testCapsuleCapsule(array $a, array $b): ?array + { + $aTop = $a['cy'] + $a['halfHeight']; + $aBot = $a['cy'] - $a['halfHeight']; + $bTop = $b['cy'] + $b['halfHeight']; + $bBot = $b['cy'] - $b['halfHeight']; + + $clampedAY = max($aBot, min($aTop, $b['cy'])); + $clampedBY = max($bBot, min($bTop, $clampedAY)); + $clampedAY = max($aBot, min($aTop, $clampedBY)); + + $dx = $b['cx'] - $a['cx']; + $dy = $clampedBY - $clampedAY; + $dz = $b['cz'] - $a['cz']; + $distSq = $dx * $dx + $dy * $dy + $dz * $dz; + $minDist = $a['radius'] + $b['radius']; + if ($distSq > $minDist * $minDist) return null; + + $dist = sqrt($distSq); + $normal = $dist > 1e-8 ? new Vec3($dx / $dist, $dy / $dist, $dz / $dist) : new Vec3(0, 1, 0); + + return [ + 'normal' => $normal, + 'penetration' => $minDist - $dist, + 'contact' => new Vec3( + $a['cx'] + $normal->x * $a['radius'], + $clampedAY + $normal->y * $a['radius'], + $a['cz'] + $normal->z * $a['radius'], + ), + ]; + } + + /** + * @param array $capsule + * @param array $box + * @return array{normal: Vec3, penetration: float, contact: Vec3}|null + */ + private function testCapsuleBox(array $capsule, array $box): ?array + { + $segY = max($capsule['cy'] - $capsule['halfHeight'], min($capsule['cy'] + $capsule['halfHeight'], $box['cy'])); + $tempSphere = [ + 'cx' => $capsule['cx'], + 'cy' => $segY, + 'cz' => $capsule['cz'], + 'radius' => $capsule['radius'], + ]; + return $this->testSphereBox($tempSphere, $box); + } +} diff --git a/src/System/Rendering3DSystem.php b/src/System/Rendering3DSystem.php new file mode 100644 index 0000000..714a569 --- /dev/null +++ b/src/System/Rendering3DSystem.php @@ -0,0 +1,314 @@ +fullscreenRenderer = new FullscreenTextureRenderer($this->gl); + $this->fullscreenDebugDepthRenderer = new FullscreenDebugDepthRenderer($this->gl); + $this->ssaoRenderer = new SSAORenderer($this->gl, $this->shaders); + + $this->geometryShader = $this->shaders->get('visu/pbr/geometry'); + $this->lightingShader = $this->shaders->get('visu/pbr/lightpass'); + $this->shadowDepthShader = $this->shaders->get('visu/pbr/shadow_depth'); + $this->pointShadowDepthShader = $this->shaders->get('visu/pbr/point_shadow_depth'); + } + + public function register(EntitiesInterface $entities): void + { + $entities->registerComponent(MeshRendererComponent::class); + $entities->registerComponent(PointLightComponent::class); + $entities->registerComponent(SpotLightComponent::class); + $entities->registerComponent(Transform::class); + + $entities->setSingleton(new DirectionalLightComponent); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function update(EntitiesInterface $entities): void + { + } + + public function setRenderTarget(RenderTargetResource $renderTargetRes): void + { + $this->currentRenderTargetRes = $renderTargetRes; + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + if ($this->currentRenderTargetRes === null) { + throw new \RuntimeException('No render target set, call setRenderTarget() before render()'); + } + + // PBR GBuffer pass (creates standard + metallic/roughness + emissive attachments) + $context->pipeline->addPass(new PBRGBufferPass); + + $gbuffer = $context->data->get(GBufferPassData::class); + $pbrGbuffer = $context->data->get(PBRGBufferData::class); + + // geometry pass — render all MeshRendererComponent entities + $context->pipeline->addPass(new CallbackPass( + 'PBRGeometry', + function (RenderPass $pass, RenderPipeline $pipeline, PipelineContainer $data) use ($gbuffer) { + $pipeline->writes($pass, $gbuffer->renderTarget); + }, + function (PipelineContainer $data, PipelineResources $resources) use ($entities) { + $cameraData = $data->get(CameraData::class); + + $this->geometryShader->use(); + $this->geometryShader->setUniformMatrix4f('projection', false, $cameraData->projection); + $this->geometryShader->setUniformMatrix4f('view', false, $cameraData->view); + glEnable(GL_DEPTH_TEST); + + foreach ($entities->view(MeshRendererComponent::class) as $entity => $renderer) { + $transform = $entities->get($entity, Transform::class); + $this->geometryShader->setUniformMatrix4f('model', false, $transform->getWorldMatrix($entities)); + + if (!$this->modelCollection->has($renderer->modelIdentifier)) { + continue; + } + + $model = $this->modelCollection->get($renderer->modelIdentifier); + + foreach ($model->meshes as $mesh) { + $material = $renderer->materialOverride ?? $mesh->material; + $this->bindMaterial($material); + $mesh->draw(); + } + } + } + )); + + // capture render target (non-null guaranteed by check above) and reset + assert($this->currentRenderTargetRes !== null); + $renderTarget = $this->currentRenderTargetRes; + $this->currentRenderTargetRes = null; + + // debug modes + if ($this->debugMode === self::DEBUG_MODE_NORMALS) { + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $gbuffer->normalTexture); + return; + } + if ($this->debugMode === self::DEBUG_MODE_POSITION) { + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $gbuffer->worldSpacePositionTexture); + return; + } + if ($this->debugMode === self::DEBUG_MODE_DEPTH) { + $this->fullscreenDebugDepthRenderer->attachPass($context->pipeline, $renderTarget, $gbuffer->depthTexture); + return; + } + if ($this->debugMode === self::DEBUG_MODE_ALBEDO) { + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $gbuffer->albedoTexture); + return; + } + if ($this->debugMode === self::DEBUG_MODE_METALLIC_ROUGHNESS) { + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $pbrGbuffer->metallicRoughnessTexture); + return; + } + if ($this->debugMode === self::DEBUG_MODE_EMISSIVE) { + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $pbrGbuffer->emissiveTexture); + return; + } + + // Shadow map pass (before SSAO and lighting) + if ($this->shadowsEnabled) { + $context->pipeline->addPass(new ShadowMapPass( + $this->shadowDepthShader, + $entities->getSingleton(DirectionalLightComponent::class), + $entities, + $this->modelCollection, + cascadeCount: $this->shadowCascadeCount, + resolution: $this->shadowResolution, + )); + } + + // Point light cubemap shadow pass + if ($this->pointShadowsEnabled) { + $context->pipeline->addPass(new PointLightShadowPass( + $this->pointShadowDepthShader, + $entities, + $this->modelCollection, + resolution: $this->pointShadowResolution, + )); + } + + // SSAO + $this->ssaoRenderer->attachPass($context->pipeline); + $ssaoData = $context->data->get(SSAOData::class); + + if ($this->debugMode === self::DEBUG_MODE_SSAO) { + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $ssaoData->blurTexture, true); + return; + } + + // PBR light pass (directional + point lights + shadows) + $context->pipeline->addPass(new PBRDeferredLightPass( + $this->lightingShader, + $entities->getSingleton(DirectionalLightComponent::class), + $entities, + )); + + // post-processing chain + $lightpass = $context->data->get(DeferredLightPassData::class); + $finalOutput = $lightpass->output; + + if ($this->postProcessStack !== null && $this->postProcessStack->hasActiveEffects()) { + $finalOutput = $this->postProcessStack->attachPasses( + $context->pipeline, + $context->data, + $finalOutput, + ); + } + + // copy to final render target + $this->fullscreenRenderer->attachPass($context->pipeline, $renderTarget, $finalOutput); + } + + /** + * Binds material uniforms and textures to the geometry shader + */ + private function bindMaterial(Material $material): void + { + $this->geometryShader->setUniform4f( + 'u_albedo_color', + $material->albedoColor->x, + $material->albedoColor->y, + $material->albedoColor->z, + $material->albedoColor->w + ); + $this->geometryShader->setUniform1f('u_metallic', $material->metallic); + $this->geometryShader->setUniform1f('u_roughness', $material->roughness); + $this->geometryShader->setUniformVec3('u_emissive_color', $material->emissiveColor); + + $flags = $material->getTextureFlags(); + $this->geometryShader->setUniform1i('u_texture_flags', $flags); + + $texUnit = 0; + + if ($material->albedoTexture !== null) { + $material->albedoTexture->bind(GL_TEXTURE0 + $texUnit); + $this->geometryShader->setUniform1i('u_albedo_map', $texUnit); + $texUnit++; + } + if ($material->normalTexture !== null) { + $material->normalTexture->bind(GL_TEXTURE0 + $texUnit); + $this->geometryShader->setUniform1i('u_normal_map', $texUnit); + $texUnit++; + } + if ($material->metallicRoughnessTexture !== null) { + $material->metallicRoughnessTexture->bind(GL_TEXTURE0 + $texUnit); + $this->geometryShader->setUniform1i('u_metallic_roughness_map', $texUnit); + $texUnit++; + } + if ($material->aoTexture !== null) { + $material->aoTexture->bind(GL_TEXTURE0 + $texUnit); + $this->geometryShader->setUniform1i('u_ao_map', $texUnit); + $texUnit++; + } + if ($material->emissiveTexture !== null) { + $material->emissiveTexture->bind(GL_TEXTURE0 + $texUnit); + $this->geometryShader->setUniform1i('u_emissive_map', $texUnit); + } + + // double-sided + if ($material->doubleSided) { + glDisable(GL_CULL_FACE); + } else { + glEnable(GL_CULL_FACE); + } + } +} diff --git a/src/System/SkeletalAnimationSystem.php b/src/System/SkeletalAnimationSystem.php new file mode 100644 index 0000000..30d48d0 --- /dev/null +++ b/src/System/SkeletalAnimationSystem.php @@ -0,0 +1,164 @@ +registerComponent(SkeletalAnimationComponent::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function update(EntitiesInterface $entities): void + { + $dt = $this->deltaTime; + + foreach ($entities->view(SkeletalAnimationComponent::class) as $entity => $anim) { + if (!$anim->playing || $anim->skeleton === null) { + continue; + } + + $clip = $anim->getActiveClip(); + if ($clip === null) { + // no clip playing — compute identity bone matrices + $this->computeBindPose($anim); + continue; + } + + // advance time + $anim->time += $dt * $anim->speed; + + // handle looping/clamping + if ($clip->duration > 0) { + if ($anim->looping) { + $anim->time = fmod($anim->time, $clip->duration); + if ($anim->time < 0) { + $anim->time += $clip->duration; + } + } else { + if ($anim->time >= $clip->duration) { + $anim->time = $clip->duration; + $anim->playing = false; + } + } + } + + $this->computeBoneMatrices($anim, $clip); + } + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } + + /** + * Computes the final bone matrices for the current animation frame. + */ + private function computeBoneMatrices(SkeletalAnimationComponent $anim, AnimationClip $clip): void + { + $skeleton = $anim->skeleton; + if ($skeleton === null) { + return; + } + + $boneCount = $skeleton->boneCount(); + + // sample animation channels to get local transforms per bone + /** @var array */ + $translations = []; + /** @var array */ + $rotations = []; + /** @var array */ + $scales = []; + + foreach ($clip->channels as $channel) { + $value = $channel->sample($anim->time); + if ($channel->property === 'translation' && $value instanceof Vec3) { + $translations[$channel->boneIndex] = $value; + } elseif ($channel->property === 'rotation' && $value instanceof Quat) { + $rotations[$channel->boneIndex] = $value; + } elseif ($channel->property === 'scale' && $value instanceof Vec3) { + $scales[$channel->boneIndex] = $value; + } + } + + // compute local transform matrices for each bone + /** @var array */ + $localMatrices = []; + for ($i = 0; $i < $boneCount; $i++) { + $localMatrices[$i] = $this->composeTransform( + $translations[$i] ?? new Vec3(0, 0, 0), + $rotations[$i] ?? new Quat(), + $scales[$i] ?? new Vec3(1, 1, 1), + ); + } + + // compute world-space (model-space) transforms by walking the hierarchy + /** @var array */ + $worldMatrices = []; + for ($i = 0; $i < $boneCount; $i++) { + $bone = $skeleton->bones[$i]; + if ($bone->parentIndex >= 0 && isset($worldMatrices[$bone->parentIndex])) { + /** @var Mat4 $world */ + $world = $worldMatrices[$bone->parentIndex] * $localMatrices[$i]; + $worldMatrices[$i] = $world; + } else { + $worldMatrices[$i] = $localMatrices[$i]; + } + } + + // final bone matrix = worldTransform * inverseBindMatrix + $anim->boneMatrices = []; + for ($i = 0; $i < $boneCount; $i++) { + /** @var Mat4 $final */ + $final = $worldMatrices[$i] * $skeleton->bones[$i]->inverseBindMatrix; + $anim->boneMatrices[$i] = $final; + } + } + + /** + * Sets identity bone matrices (bind pose). + */ + private function computeBindPose(SkeletalAnimationComponent $anim): void + { + if ($anim->skeleton === null) { + return; + } + + $anim->boneMatrices = []; + for ($i = 0; $i < $anim->skeleton->boneCount(); $i++) { + $anim->boneMatrices[$i] = new Mat4(); + } + } + + /** + * Composes a 4x4 transform matrix from translation, rotation, and scale. + * Uses the same T*R*S order as Transform::getLocalMatrix(). + */ + private function composeTransform(Vec3 $translation, Quat $rotation, Vec3 $scale): Mat4 + { + $mat = new Mat4(); + $mat->translate($translation); + $mat = Mat4::multiplyQuat($mat, $rotation); + $mat->scale($scale); + return $mat; + } +} diff --git a/src/System/SpriteAnimatorSystem.php b/src/System/SpriteAnimatorSystem.php new file mode 100644 index 0000000..fc1bbfc --- /dev/null +++ b/src/System/SpriteAnimatorSystem.php @@ -0,0 +1,85 @@ +registerComponent(SpriteAnimator::class); + $entities->registerComponent(SpriteRenderer::class); + } + + public function unregister(EntitiesInterface $entities): void + { + } + + public function update(EntitiesInterface $entities): void + { + $now = microtime(true); + $dt = $this->lastTime > 0 ? $now - $this->lastTime : 0.0; + $this->lastTime = $now; + + if ($dt <= 0 || $dt > 1.0) { + return; + } + + foreach ($entities->view(SpriteAnimator::class) as $entityId => $animator) { + if (!$animator->playing || $animator->finished) { + continue; + } + + $animName = $animator->currentAnimation; + if ($animName === '' || !isset($animator->animations[$animName])) { + continue; + } + + $anim = $animator->animations[$animName]; + $frames = $anim['frames']; + $fps = $anim['fps']; + $loop = $anim['loop']; + $frameCount = count($frames); + + if ($frameCount === 0 || $fps <= 0) { + continue; + } + + $animator->elapsed += $dt; + $frameDuration = 1.0 / $fps; + + while ($animator->elapsed >= $frameDuration) { + $animator->elapsed -= $frameDuration; + $animator->currentFrame++; + + if ($animator->currentFrame >= $frameCount) { + if ($loop) { + $animator->currentFrame = 0; + } else { + $animator->currentFrame = $frameCount - 1; + $animator->finished = true; + $animator->playing = false; + break; + } + } + } + + // Apply current frame UV rect to SpriteRenderer + $spriteRenderer = $entities->tryGet($entityId, SpriteRenderer::class); + if ($spriteRenderer !== null && isset($frames[$animator->currentFrame])) { + $spriteRenderer->uvRect = $frames[$animator->currentFrame]; + } + } + } + + public function render(EntitiesInterface $entities, RenderContext $context): void + { + } +} diff --git a/src/Testing/FakeInput.php b/src/Testing/FakeInput.php new file mode 100644 index 0000000..abbea7b --- /dev/null +++ b/src/Testing/FakeInput.php @@ -0,0 +1,332 @@ +simulateCursorPos(320.0, 240.0); + * + * // Press and hold a mouse button + * $input->simulateMouseButton(0, true); + * + * // Press a key this frame + * $input->simulateKeyPress(Key::ESCAPE); + * + * // Advance one frame (clears per-frame state) + * $input->endFrame(); + */ +final class FakeInput implements InputInterface +{ + public const PRESS = 1; // GLFW_PRESS + public const RELEASE = 0; // GLFW_RELEASE + public const REPEAT = 2; // GLFW_REPEAT + + // ── Cursor ──────────────────────────────────────────────────────────── + // Stored as plain floats to avoid Vec2 property-access quirks in php-glfw + // when objects are assigned/read across multiple PHPUnit frames. + private float $cursorX = 0.0; + private float $cursorY = 0.0; + private float $lastCursorX = 0.0; + private float $lastCursorY = 0.0; + + // ── Keys ───────────────────────────────────────────────────────────── + /** @var array key => PRESS|RELEASE|REPEAT */ + private array $keyStates = []; + + /** @var array keys pressed since last endFrame() */ + private array $keysDidPress = []; + + /** @var array keys released since last endFrame() */ + private array $keysDidRelease = []; + + /** @var array keys pressed this frame only */ + private array $keysDidPressFrame = []; + + /** @var array keys released this frame only */ + private array $keysDidReleaseFrame = []; + + // ── Mouse buttons ───────────────────────────────────────────────────── + /** @var array button => PRESS|RELEASE */ + private array $mouseButtonStates = []; + + /** @var array */ + private array $mouseButtonsDidPress = []; + + /** @var array */ + private array $mouseButtonsDidRelease = []; + + /** @var array */ + private array $mouseButtonsDidPressFrame = []; + + /** @var array */ + private array $mouseButtonsDidReleaseFrame = []; + + // ── Cursor mode ─────────────────────────────────────────────────────── + private CursorMode $cursorMode = CursorMode::NORMAL; + + // ── Input context ───────────────────────────────────────────────────── + private ?string $inputContext = null; + + public function __construct( + private readonly DispatcherInterface $dispatcher, + private readonly int $windowWidth = 1280, + private readonly int $windowHeight = 720, + ) { + } + + // ── Simulation API ──────────────────────────────────────────────────── + + /** + * Move the cursor to the given window-space position and dispatch a CursorPosSignal. + */ + public function simulateCursorPos(float $x, float $y): void + { + $this->lastCursorX = $this->cursorX; + $this->lastCursorY = $this->cursorY; + $this->cursorX = $x; + $this->cursorY = $y; + } + + /** + * Press or release a mouse button and dispatch a MouseButtonSignal. + * + * @param int $button 0 = left, 1 = right, 2 = middle + * @param bool $press true = press, false = release + */ + public function simulateMouseButton(int $button, bool $press): void + { + $action = $press ? self::PRESS : self::RELEASE; + $this->mouseButtonStates[$button] = $action; + + if ($press) { + $this->mouseButtonsDidPress[$button] = true; + $this->mouseButtonsDidPressFrame[$button] = true; + } else { + $this->mouseButtonsDidRelease[$button] = true; + $this->mouseButtonsDidReleaseFrame[$button] = true; + } + } + + /** + * Simulate a full click (press + release) on the current cursor position. + * + * @param int $button 0 = left, 1 = right, 2 = middle + */ + public function simulateClick(int $button = 0): void + { + $this->simulateMouseButton($button, true); + $this->simulateMouseButton($button, false); + } + + /** + * Register a key press for this frame. + */ + public function simulateKeyPress(int $key): void + { + $this->keyStates[$key] = self::PRESS; + $this->keysDidPress[$key] = true; + $this->keysDidPressFrame[$key] = true; + } + + /** + * Register a key release for this frame. + */ + public function simulateKeyRelease(int $key): void + { + $this->keyStates[$key] = self::RELEASE; + $this->keysDidRelease[$key] = true; + $this->keysDidReleaseFrame[$key] = true; + } + + /** + * Placeholder for character input simulation. + * Full signal dispatch requires a Window object — use the real Input class + * wired to a hidden GLFW window (VisualTestCase) for char/scroll signal tests. + */ + public function simulateChar(int $codepoint): void + { + // no-op: CharSignal requires a Window reference (php-glfw limitation) + } + + /** + * Placeholder for scroll simulation. + * Full signal dispatch requires a Window object — use VisualTestCase for scroll tests. + */ + public function simulateScroll(float $xOffset, float $yOffset): void + { + // no-op: ScrollSignal requires a Window reference (php-glfw limitation) + } + + /** + * Advance one frame: clears per-frame state (pressed/released this frame). + * Call this at the end of each simulated frame. + */ + public function endFrame(): void + { + $this->keysDidPressFrame = []; + $this->keysDidReleaseFrame = []; + $this->keysDidPress = []; + $this->keysDidRelease = []; + $this->mouseButtonsDidPressFrame = []; + $this->mouseButtonsDidReleaseFrame = []; + $this->mouseButtonsDidPress = []; + $this->mouseButtonsDidRelease = []; + } + + // ── InputInterface ──────────────────────────────────────────────────── + + public function getKeyState(int $key): int + { + return $this->keyStates[$key] ?? self::RELEASE; + } + + public function isKeyPressed(int $key): bool + { + return $this->getKeyState($key) === self::PRESS; + } + + public function isKeyReleased(int $key): bool + { + return $this->getKeyState($key) === self::RELEASE; + } + + public function isKeyRepeated(int $key): bool + { + return $this->getKeyState($key) === self::REPEAT; + } + + public function getMouseButtonState(int $button): int + { + return $this->mouseButtonStates[$button] ?? self::RELEASE; + } + + public function isMouseButtonPressed(int $button): bool + { + return $this->getMouseButtonState($button) === self::PRESS; + } + + public function isMouseButtonReleased(int $button): bool + { + return $this->getMouseButtonState($button) === self::RELEASE; + } + + public function hasMouseButtonBeenPressed(int $button): bool + { + return $this->mouseButtonsDidPress[$button] ?? false; + } + + public function hasMouseButtonBeenReleased(int $button): bool + { + return $this->mouseButtonsDidRelease[$button] ?? false; + } + + public function hasMouseButtonBeenPressedThisFrame(int $button): bool + { + return $this->mouseButtonsDidPressFrame[$button] ?? false; + } + + public function hasMouseButtonBeenReleasedThisFrame(int $button): bool + { + return $this->mouseButtonsDidReleaseFrame[$button] ?? false; + } + + public function hasKeyBeenPressed(int $key): bool + { + return $this->keysDidPress[$key] ?? false; + } + + public function hasKeyBeenReleased(int $key): bool + { + return $this->keysDidRelease[$key] ?? false; + } + + public function hasKeyBeenPressedThisFrame(int $key): bool + { + return $this->keysDidPressFrame[$key] ?? false; + } + + public function hasKeyBeenReleasedThisFrame(int $key): bool + { + return $this->keysDidReleaseFrame[$key] ?? false; + } + + public function getKeyPresses(): array + { + return array_keys($this->keysDidPress); + } + + public function getKeyPressesThisFrame(): array + { + return array_keys($this->keysDidPressFrame); + } + + public function getCursorPosition(): Vec2 + { + return new Vec2($this->cursorX, $this->cursorY); + } + + public function getNormalizedCursorPosition(): Vec2 + { + return new Vec2( + ($this->cursorX / max(1, $this->windowWidth)) * 2.0 - 1.0, + ($this->cursorY / max(1, $this->windowHeight)) * 2.0 - 1.0, + ); + } + + public function getLastCursorPosition(): Vec2 + { + return new Vec2($this->lastCursorX, $this->lastCursorY); + } + + public function setCursorPosition(Vec2 $position): void + { + $this->simulateCursorPos($position->x, $position->y); + } + + public function setCursorMode(CursorMode $mode): void + { + $this->cursorMode = $mode; + } + + public function getCursorMode(): CursorMode + { + return $this->cursorMode; + } + + public function isContextUnclaimed(): bool + { + return $this->inputContext === null; + } + + public function claimContext(string $context): void + { + $this->inputContext = $context; + } + + public function releaseContext(string $context): void + { + if ($this->inputContext === $context) { + $this->inputContext = null; + } + } + + public function getCurrentContext(): ?string + { + return $this->inputContext; + } + + public function isClaimedContext(string $context): bool + { + return $this->inputContext === $context; + } +} diff --git a/src/Testing/SnapshotComparator.php b/src/Testing/SnapshotComparator.php new file mode 100644 index 0000000..6bea946 --- /dev/null +++ b/src/Testing/SnapshotComparator.php @@ -0,0 +1,129 @@ +> 16) & 0xFF; + $ag = ($ac >> 8) & 0xFF; + $ab = $ac & 0xFF; + + $rr = ($rc >> 16) & 0xFF; + $rg = ($rc >> 8) & 0xFF; + $rb = $rc & 0xFF; + + // Mean per-channel absolute difference for this pixel (0–255 → 0–1) + $totalDiff += (abs($ar - $rr) + abs($ag - $rg) + abs($ab - $rb)) / (3.0 * 255.0); + } + } + + imagedestroy($actual); + imagedestroy($reference); + + return ($totalDiff / $pixelCount) * 100.0; + } + + /** + * Generate a side-by-side diff image (reference | actual | diff-highlight). + * + * @return string PNG binary of the diff visualization + */ + public static function generateDiffImage(string $actualPng, string $referencePng): string + { + $actual = @imagecreatefromstring($actualPng); + $reference = @imagecreatefromstring($referencePng); + + if ($actual === false || $reference === false) { + throw new \RuntimeException('Failed to decode PNG image for diff generation.'); + } + + $w = imagesx($actual); + $h = imagesy($actual); + $gap = 4; + $totalW = $w * 3 + $gap * 2; + + $diff = imagecreatetruecolor($totalW, $h); + if ($diff === false) { + throw new \RuntimeException('Failed to create diff image.'); + } + + $bgColor = imagecolorallocate($diff, 30, 30, 30) ?: 0; + imagefill($diff, 0, 0, $bgColor); + + // Copy reference (left) + imagecopy($diff, $reference, 0, 0, 0, 0, $w, $h); + + // Copy actual (center) + imagecopy($diff, $actual, $w + $gap, 0, 0, 0, $w, $h); + + // Generate diff highlight (right) + $diffOffsetX = ($w + $gap) * 2; + for ($y = 0; $y < $h; $y++) { + for ($x = 0; $x < $w; $x++) { + $ac = imagecolorat($actual, $x, $y); + $rc = imagecolorat($reference, $x, $y); + + if ($ac === $rc) { + // Identical: dark gray + $color = imagecolorallocate($diff, 40, 40, 40); + } else { + // Different: red intensity proportional to difference + $dr = abs((($ac >> 16) & 0xFF) - (($rc >> 16) & 0xFF)); + $dg = abs((($ac >> 8) & 0xFF) - (($rc >> 8) & 0xFF)); + $db = abs(($ac & 0xFF) - ($rc & 0xFF)); + $intensity = min(255, (int)(($dr + $dg + $db) / 3.0 * 4.0)); + $color = imagecolorallocate($diff, $intensity, (int)($intensity * 0.15), (int)($intensity * 0.1)); + } + + imagesetpixel($diff, $diffOffsetX + $x, $y, $color ?: 0); + } + } + + imagedestroy($actual); + imagedestroy($reference); + + ob_start(); + imagepng($diff); + $png = ob_get_clean(); + imagedestroy($diff); + + return $png ?: ''; + } +} diff --git a/src/Testing/SnapshotResult.php b/src/Testing/SnapshotResult.php new file mode 100644 index 0000000..b6572b7 --- /dev/null +++ b/src/Testing/SnapshotResult.php @@ -0,0 +1,17 @@ +visible = false; + + self::$window = new Window('VRT Offscreen', $this->viewportWidth, $this->viewportHeight, $hints); + self::$window->initailize(self::$glstate); + + self::$vgContext = new VGContext(VGContext::ANTIALIAS); + self::$dispatcher = new Dispatcher(); + self::$input = new Input(self::$window, self::$dispatcher); + + FlyUI::initailize(self::$vgContext, self::$dispatcher, self::$input); + } + + // Fresh FakeInput per test — cursor at origin, no pressed keys + $this->fakeInput = new FakeInput( + self::$dispatcher, + $this->viewportWidth, + $this->viewportHeight, + ); + } + + /** + * Inject FakeInput into FlyUI for a single frame. + * Use this when you want to test UI interactions (hover, click) without + * touching the real GLFW cursor state. + * + * Example: + * $this->fakeInput->simulateCursorPos(100, 50); + * $this->fakeInput->simulateMouseButton(0, true); + * $png = $this->renderFrameWithFakeInput(function($vg) { ... }); + */ + /** + * Inject FakeInput into FlyUI for a single frame and render it. + * + * Preserves viewportOffset and viewportSize across the FlyUI re-initialize + * so that tests can set those properties before calling this method. + * + * Does NOT advance the FakeInput frame (endFrame) — the caller controls + * when to advance so multi-frame click sequences work correctly. + */ + protected function renderFrameWithFakeInput(callable $drawCallback): string + { + $w = $this->viewportWidth; + $h = $this->viewportHeight; + + glViewport(0, 0, $w, $h); + glClearColor(0.0, 0.0, 0.0, 1.0); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + + $resolution = new Vec2((float) $w, (float) $h); + + // Save viewport properties so they survive the FlyUI re-initialize below + $savedOffset = FlyUI::$instance->viewportOffset; + $savedSize = FlyUI::$instance->viewportSize; + + self::$vgContext->beginFrame($w, $h, 1.0); + + // Swap FlyUI input to FakeInput for this frame + FlyUI::initailize(self::$vgContext, self::$dispatcher, $this->fakeInput); + FlyUI::$instance->viewportOffset = $savedOffset; + FlyUI::$instance->viewportSize = $savedSize; + + FlyUI::beginFrame($resolution); + + $drawCallback(self::$vgContext); + + FlyUI::endFrame(); + self::$vgContext->endFrame(); + + // Restore real input, keeping the viewport properties consistent + $savedOffset = FlyUI::$instance->viewportOffset; + $savedSize = FlyUI::$instance->viewportSize; + FlyUI::initailize(self::$vgContext, self::$dispatcher, self::$input); + FlyUI::$instance->viewportOffset = $savedOffset; + FlyUI::$instance->viewportSize = $savedSize; + + glFinish(); + + return $this->readFramebufferAsPng($w, $h); + } + + /** + * Render a frame by calling the given draw callback, then read back the framebuffer as PNG. + * + * @param callable $drawCallback Called between beginFrame/endFrame. Receives VGContext as argument. + * @return string Raw PNG binary + */ + protected function renderFrame(callable $drawCallback): string + { + $w = $this->viewportWidth; + $h = $this->viewportHeight; + + glViewport(0, 0, $w, $h); + glClearColor(0.0, 0.0, 0.0, 1.0); + glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + + $resolution = new Vec2((float)$w, (float)$h); + + self::$vgContext->beginFrame($w, $h, 1.0); + FlyUI::beginFrame($resolution); + + $drawCallback(self::$vgContext); + + FlyUI::endFrame(); + self::$vgContext->endFrame(); + + glFinish(); + + return $this->readFramebufferAsPng($w, $h); + } + + /** + * Read the current framebuffer pixels and encode as PNG. + */ + private function readFramebufferAsPng(int $w, int $h): string + { + $buffer = new UByteBuffer(); + glReadPixels(0, 0, $w, $h, GL_RGBA, GL_UNSIGNED_BYTE, $buffer); + + $img = imagecreatetruecolor($w, $h); + if ($img === false) { + throw new \RuntimeException('Failed to create image for framebuffer readback.'); + } + + // OpenGL reads bottom-to-top, so we flip vertically + for ($y = 0; $y < $h; $y++) { + for ($x = 0; $x < $w; $x++) { + $srcIdx = (($h - 1 - $y) * $w + $x) * 4; + $r = $buffer[$srcIdx]; + $g = $buffer[$srcIdx + 1]; + $b = $buffer[$srcIdx + 2]; + $color = imagecolorallocate($img, $r, $g, $b) ?: 0; + imagesetpixel($img, $x, $y, $color); + } + } + + ob_start(); + imagepng($img); + $png = ob_get_clean(); + imagedestroy($img); + + return $png ?: ''; + } + + /** + * Assert that the rendered frame matches the stored reference snapshot. + * + * When the UPDATE_SNAPSHOTS environment variable is set, the reference is overwritten + * instead of compared, allowing easy golden file updates. + */ + protected function assertMatchesSnapshot(string $actualPng, string $snapshotName, ?float $threshold = null): SnapshotResult + { + $threshold ??= $this->snapshotThreshold; + $dir = $this->resolveSnapshotDirectory(); + $snapshotsDir = $dir . DIRECTORY_SEPARATOR . 'Snapshots'; + $diffsDir = $dir . DIRECTORY_SEPARATOR . 'Diffs'; + + $referencePath = $snapshotsDir . DIRECTORY_SEPARATOR . $snapshotName . '.png'; + $actualPath = $diffsDir . DIRECTORY_SEPARATOR . $snapshotName . '_actual.png'; + $diffPath = $diffsDir . DIRECTORY_SEPARATOR . $snapshotName . '_diff.png'; + + // Update mode: overwrite reference and pass + if (getenv('UPDATE_SNAPSHOTS')) { + if (!is_dir($snapshotsDir)) { + mkdir($snapshotsDir, 0755, true); + } + file_put_contents($referencePath, $actualPng); + + return new SnapshotResult( + passed: true, + diffPercent: 0.0, + threshold: $threshold, + snapshotName: $snapshotName, + referencePath: $referencePath, + actualPath: null, + diffPath: null, + isNew: true, + ); + } + + // No reference yet — fail with instructions + if (!file_exists($referencePath)) { + if (!is_dir($snapshotsDir)) { + mkdir($snapshotsDir, 0755, true); + } + file_put_contents($referencePath, $actualPng); + + $this->fail( + "No reference snapshot found for '{$snapshotName}'. " + . "A new reference has been created at: {$referencePath}. " + . "Re-run the test to compare against it." + ); + } + + $referencePng = file_get_contents($referencePath); + if ($referencePng === false) { + $this->fail("Failed to read reference snapshot: {$referencePath}"); + } + $diffPercent = SnapshotComparator::compare($actualPng, $referencePng); + $passed = $diffPercent <= $threshold; + + if (!$passed) { + if (!is_dir($diffsDir)) { + mkdir($diffsDir, 0755, true); + } + file_put_contents($actualPath, $actualPng); + file_put_contents($diffPath, SnapshotComparator::generateDiffImage($actualPng, $referencePng)); + } + + $result = new SnapshotResult( + passed: $passed, + diffPercent: $diffPercent, + threshold: $threshold, + snapshotName: $snapshotName, + referencePath: $referencePath, + actualPath: $passed ? null : $actualPath, + diffPath: $passed ? null : $diffPath, + isNew: false, + ); + + $this->assertTrue( + $passed, + sprintf( + "Snapshot '%s' differs by %.4f%% (threshold: %.4f%%). Diff saved to: %s", + $snapshotName, + $diffPercent, + $threshold, + $diffPath + ) + ); + + return $result; + } + + /** + * Resolve the snapshot directory. Defaults to a Snapshots/ folder next to the test file. + */ + private function resolveSnapshotDirectory(): string + { + if ($this->snapshotDir !== null) { + return $this->snapshotDir; + } + + $reflection = new \ReflectionClass($this); + $fileName = $reflection->getFileName(); + if ($fileName === false) { + throw new \RuntimeException('Cannot resolve snapshot directory for internal class.'); + } + return dirname($fileName); + } +} diff --git a/src/Transpiler/PrefabTranspiler.php b/src/Transpiler/PrefabTranspiler.php new file mode 100644 index 0000000..bf8ebfa --- /dev/null +++ b/src/Transpiler/PrefabTranspiler.php @@ -0,0 +1,53 @@ +sceneTranspiler = new SceneTranspiler($componentRegistry); + } + + /** + * Transpiles a prefab JSON file to a PHP factory class. + * + * @param string $jsonPath Path to the source JSON file + * @param string $className Short class name (e.g. "Employee") + * @param string $namespace PHP namespace for the generated class + * @return string Generated PHP source code + */ + public function transpile(string $jsonPath, string $className, string $namespace = 'VISU\\Generated\\Prefabs'): string + { + $json = file_get_contents($jsonPath); + if ($json === false) { + throw new \RuntimeException("Failed to read prefab file: {$jsonPath}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in prefab file: {$jsonPath}"); + } + + return $this->transpileArray($data, $className, $namespace, $jsonPath); + } + + /** + * Transpiles a prefab data array to a PHP factory class. + * Prefabs are single entity definitions — we wrap them as a scene. + * + * @param array $data Single entity definition + * @return string Generated PHP source code + */ + public function transpileArray(array $data, string $className, string $namespace = 'VISU\\Generated\\Prefabs', ?string $sourcePath = null): string + { + // Wrap single entity as scene format + $sceneData = ['entities' => [$data]]; + return $this->sceneTranspiler->transpileArray($sceneData, $className, $namespace, $sourcePath); + } +} diff --git a/src/Transpiler/SceneTranspiler.php b/src/Transpiler/SceneTranspiler.php new file mode 100644 index 0000000..2fca3fb --- /dev/null +++ b/src/Transpiler/SceneTranspiler.php @@ -0,0 +1,285 @@ +transpileArray($data, $className, $namespace, $jsonPath); + } + + /** + * Transpiles a scene data array to a PHP factory class. + * + * @param array $data + * @return string Generated PHP source code + */ + public function transpileArray(array $data, string $className, string $namespace = 'VISU\\Generated\\Scenes', ?string $sourcePath = null): string + { + $entities = $data['entities'] ?? []; + $context = new TranspileContext(); + + foreach ($entities as $entityDef) { + $this->transpileEntity($entityDef, $context, null); + } + + return $this->generateClass($className, $namespace, $context, $sourcePath); + } + + /** + * @param array $def + */ + private function transpileEntity(array $def, TranspileContext $ctx, ?string $parentVar): void + { + $idx = $ctx->nextEntityIndex(); + $entityVar = '$e' . $idx; + $transformVar = '$t' . $idx; + + $ctx->addLine("{$entityVar} = \$entities->create();"); + $ctx->addLine("\$ids[] = {$entityVar};"); + + // Name + if (isset($def['name'])) { + $name = $this->exportString($def['name']); + $ctx->addLine("\$entities->attach({$entityVar}, new NameComponent({$name}));"); + $ctx->requireUse('VISU\\Component\\NameComponent'); + } + + // Transform + $this->transpileTransform($def['transform'] ?? [], $transformVar, $entityVar, $parentVar, $ctx); + + // Components + foreach ($def['components'] ?? [] as $componentDef) { + $this->transpileComponent($componentDef, $entityVar, $ctx); + } + + // Children + $ctx->addLine(''); + foreach ($def['children'] ?? [] as $childDef) { + $this->transpileEntity($childDef, $ctx, $entityVar); + } + } + + /** + * @param array $def + */ + private function transpileTransform(array $def, string $transformVar, string $entityVar, ?string $parentVar, TranspileContext $ctx): void + { + $ctx->requireUse('VISU\\Geo\\Transform'); + $ctx->requireUse('GL\\Math\\Vec3'); + + $ctx->addLine("{$transformVar} = new Transform();"); + + // Position + $pos = $def['position'] ?? [0, 0, 0]; + $px = (float) ($pos[0] ?? $pos['x'] ?? 0); + $py = (float) ($pos[1] ?? $pos['y'] ?? 0); + $pz = (float) ($pos[2] ?? $pos['z'] ?? 0); + if ($px !== 0.0 || $py !== 0.0 || $pz !== 0.0) { + $ctx->addLine("{$transformVar}->position = new Vec3({$this->exportFloat($px)}, {$this->exportFloat($py)}, {$this->exportFloat($pz)});"); + } + + // Rotation (Euler degrees -> quaternion) + if (isset($def['rotation'])) { + $r = $def['rotation']; + $rx = (float) ($r[0] ?? $r['x'] ?? 0); + $ry = (float) ($r[1] ?? $r['y'] ?? 0); + $rz = (float) ($r[2] ?? $r['z'] ?? 0); + if ($rx !== 0.0 || $ry !== 0.0 || $rz !== 0.0) { + $ctx->requireUse('GL\\Math\\GLM'); + $ctx->requireUse('GL\\Math\\Quat'); + $ctx->addLine("\$q{$transformVar} = new Quat();"); + if ($rx !== 0.0) { + $ctx->addLine("\$q{$transformVar}->rotate(GLM::radians({$this->exportFloat($rx)}), new Vec3(1, 0, 0));"); + } + if ($ry !== 0.0) { + $ctx->addLine("\$q{$transformVar}->rotate(GLM::radians({$this->exportFloat($ry)}), new Vec3(0, 1, 0));"); + } + if ($rz !== 0.0) { + $ctx->addLine("\$q{$transformVar}->rotate(GLM::radians({$this->exportFloat($rz)}), new Vec3(0, 0, 1));"); + } + $ctx->addLine("{$transformVar}->orientation = \$q{$transformVar};"); + } + } + + // Scale + $scale = $def['scale'] ?? [1, 1, 1]; + $sx = (float) ($scale[0] ?? $scale['x'] ?? 1); + $sy = (float) ($scale[1] ?? $scale['y'] ?? 1); + $sz = (float) ($scale[2] ?? $scale['z'] ?? 1); + if ($sx !== 1.0 || $sy !== 1.0 || $sz !== 1.0) { + $ctx->addLine("{$transformVar}->scale = new Vec3({$this->exportFloat($sx)}, {$this->exportFloat($sy)}, {$this->exportFloat($sz)});"); + } + + // Parent + if ($parentVar !== null) { + $ctx->addLine("{$transformVar}->setParent(\$entities, {$parentVar});"); + } + + $ctx->addLine("{$transformVar}->markDirty();"); + $ctx->addLine("\$entities->attach({$entityVar}, {$transformVar});"); + } + + /** + * @param array $def + */ + private function transpileComponent(array $def, string $entityVar, TranspileContext $ctx): void + { + $typeName = $def['type'] ?? null; + if ($typeName === null) { + return; + } + + $fqcn = $this->componentRegistry->resolve($typeName); + $ctx->requireUse($fqcn); + + $shortName = $this->shortClassName($fqcn); + $idx = $ctx->nextComponentIndex(); + $componentVar = '$c' . $idx; + + $properties = $def; + unset($properties['type']); + + $ctx->addLine("{$componentVar} = new {$shortName}();"); + + foreach ($properties as $key => $value) { + $exported = $this->exportValue($value); + $ctx->addLine("{$componentVar}->{$key} = {$exported};"); + } + + $ctx->addLine("\$entities->attach({$entityVar}, {$componentVar});"); + } + + private function generateClass(string $className, string $namespace, TranspileContext $ctx, ?string $sourcePath): string + { + $uses = $ctx->getUseStatements(); + $uses[] = 'VISU\\ECS\\EntitiesInterface'; + sort($uses); + + $useLines = implode("\n", array_map(fn(string $u) => "use {$u};", array_unique($uses))); + $bodyLines = implode("\n", array_map(fn(string $l) => $l === '' ? '' : " {$l}", $ctx->getLines())); + + $sourceComment = $sourcePath !== null + ? "\n /** Source: {$sourcePath} */\n" + : "\n"; + + $escapedNs = str_replace('\\', '\\\\', $namespace); + + return << Created entity IDs + */ + public static function load(EntitiesInterface \$entities): array + { + \$ids = []; + +{$bodyLines} + + return \$ids; + } +} + +PHP; + } + + private function exportValue(mixed $value): string + { + if (is_null($value)) { + return 'null'; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_int($value)) { + return (string) $value; + } + if (is_float($value)) { + return $this->exportFloat($value); + } + if (is_string($value)) { + return $this->exportString($value); + } + if (is_array($value)) { + return $this->exportArray($value); + } + return var_export($value, true); + } + + private function exportFloat(float $value): string + { + $s = (string) $value; + if (!str_contains($s, '.') && !str_contains($s, 'E') && !str_contains($s, 'e')) { + $s .= '.0'; + } + return $s; + } + + private function exportString(string $value): string + { + return "'" . addcslashes($value, "'\\") . "'"; + } + + /** + * @param array $value + */ + private function exportArray(array $value): string + { + // Check if it's a sequential (list) array + if (array_is_list($value)) { + $items = array_map(fn($v) => $this->exportValue($v), $value); + return '[' . implode(', ', $items) . ']'; + } + + // Associative array + $items = []; + foreach ($value as $k => $v) { + $items[] = $this->exportValue($k) . ' => ' . $this->exportValue($v); + } + return '[' . implode(', ', $items) . ']'; + } + + private function shortClassName(string $fqcn): string + { + $parts = explode('\\', $fqcn); + return end($parts); + } +} diff --git a/src/Transpiler/TranspileContext.php b/src/Transpiler/TranspileContext.php new file mode 100644 index 0000000..f9ba6bc --- /dev/null +++ b/src/Transpiler/TranspileContext.php @@ -0,0 +1,51 @@ + */ + private array $lines = []; + + /** @var array */ + private array $useStatements = []; + + private int $entityIndex = 0; + private int $componentIndex = 0; + + public function addLine(string $line): void + { + $this->lines[] = $line; + } + + public function requireUse(string $fqcn): void + { + $this->useStatements[$fqcn] = true; + } + + public function nextEntityIndex(): int + { + return $this->entityIndex++; + } + + public function nextComponentIndex(): int + { + return $this->componentIndex++; + } + + /** + * @return array + */ + public function getLines(): array + { + return $this->lines; + } + + /** + * @return array + */ + public function getUseStatements(): array + { + return array_keys($this->useStatements); + } +} diff --git a/src/Transpiler/TranspilerRegistry.php b/src/Transpiler/TranspilerRegistry.php new file mode 100644 index 0000000..b66bbec --- /dev/null +++ b/src/Transpiler/TranspilerRegistry.php @@ -0,0 +1,120 @@ + + */ + private array $entries = []; + + private string $registryPath; + + public function __construct(string $cachePath) + { + $this->registryPath = rtrim($cachePath, '/') . '/transpiler_registry.json'; + $this->load(); + } + + /** + * Check if a source file needs to be re-transpiled. + */ + public function needsUpdate(string $sourcePath): bool + { + if (!isset($this->entries[$sourcePath])) { + return true; + } + + $currentHash = $this->hashFile($sourcePath); + return $currentHash !== $this->entries[$sourcePath]['hash']; + } + + /** + * Record that a file has been transpiled. + */ + public function record(string $sourcePath, string $outputPath): void + { + $this->entries[$sourcePath] = [ + 'hash' => $this->hashFile($sourcePath), + 'output' => $outputPath, + 'timestamp' => microtime(true), + ]; + } + + /** + * Get the output path for a previously transpiled source. + */ + public function getOutputPath(string $sourcePath): ?string + { + return $this->entries[$sourcePath]['output'] ?? null; + } + + /** + * Remove a source entry from the registry. + */ + public function remove(string $sourcePath): void + { + unset($this->entries[$sourcePath]); + } + + /** + * Persist registry to disk. + */ + public function save(): void + { + $dir = dirname($this->registryPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $json = json_encode($this->entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json !== false) { + file_put_contents($this->registryPath, $json); + } + } + + /** + * Load registry from disk. + */ + private function load(): void + { + if (!file_exists($this->registryPath)) { + return; + } + + $json = file_get_contents($this->registryPath); + if ($json === false) { + return; + } + + $data = json_decode($json, true); + if (is_array($data)) { + $this->entries = $data; + } + } + + /** + * @return array + */ + public function getEntries(): array + { + return $this->entries; + } + + /** + * Clear all entries. + */ + public function clear(): void + { + $this->entries = []; + } + + private function hashFile(string $path): string + { + if (!file_exists($path)) { + return ''; + } + return md5_file($path) ?: ''; + } +} diff --git a/src/Transpiler/UITranspiler.php b/src/Transpiler/UITranspiler.php new file mode 100644 index 0000000..1901139 --- /dev/null +++ b/src/Transpiler/UITranspiler.php @@ -0,0 +1,429 @@ +transpileArray($data, $className, $namespace, $jsonPath); + } + + /** + * Transpiles a UI data array to a PHP render class. + * + * @param array $data + * @return string Generated PHP source code + */ + public function transpileArray(array $data, string $className, string $namespace = 'VISU\\Generated\\UI', ?string $sourcePath = null): string + { + $ctx = new TranspileContext(); + $ctx->requireUse('VISU\\FlyUI\\FlyUI'); + $ctx->requireUse('VISU\\UI\\UIDataContext'); + $ctx->requireUse('VISU\\Signal\\DispatcherInterface'); + + $this->transpileNode($data, $ctx); + + return $this->generateClass($className, $namespace, $ctx, $sourcePath); + } + + /** + * @param array $node + */ + private function transpileNode(array $node, TranspileContext $ctx): void + { + $type = $node['type'] ?? ''; + + match ($type) { + 'panel' => $this->transpilePanel($node, $ctx), + 'label' => $this->transpileLabel($node, $ctx), + 'button' => $this->transpileButton($node, $ctx), + 'progressbar' => $this->transpileProgressBar($node, $ctx), + 'checkbox' => $this->transpileCheckbox($node, $ctx), + 'select' => $this->transpileSelect($node, $ctx), + 'image' => $this->transpileImage($node, $ctx), + 'space' => $this->transpileSpace($node, $ctx), + default => null, + }; + } + + /** + * @param array $node + */ + private function transpilePanel(array $node, TranspileContext $ctx): void + { + $padding = $this->buildPaddingCode($node['padding'] ?? null, $ctx); + $layoutVar = '$l' . $ctx->nextEntityIndex(); + + $ctx->addLine("{$layoutVar} = FlyUI::beginLayout({$padding});"); + + $flow = $node['layout'] ?? 'column'; + if ($flow === 'row') { + $ctx->requireUse('VISU\\FlyUI\\FUILayoutFlow'); + $ctx->addLine("{$layoutVar}->flow(FUILayoutFlow::horizontal);"); + } + + if (isset($node['spacing'])) { + $ctx->addLine("{$layoutVar}->spacing({$this->exportFloat((float) $node['spacing'])});"); + } + + if (isset($node['backgroundColor'])) { + $colorCode = $this->buildColorCode($node['backgroundColor'], $ctx); + $ctx->addLine("{$layoutVar}->backgroundColor({$colorCode});"); + } + + // Sizing + if (isset($node['width'])) { + $ctx->addLine("{$layoutVar}->fixedWidth({$this->exportFloat((float) $node['width'])});"); + } else { + $wMode = $node['horizontalSizing'] ?? 'fill'; + if ($wMode === 'fit') { + $ctx->addLine("{$layoutVar}->horizontalFit();"); + } + } + + if (isset($node['height'])) { + $ctx->addLine("{$layoutVar}->fixedHeight({$this->exportFloat((float) $node['height'])});"); + } else { + $hMode = $node['verticalSizing'] ?? 'fit'; + if ($hMode === 'fill') { + $ctx->addLine("{$layoutVar}->verticalFill();"); + } + } + + $ctx->addLine(''); + + foreach ($node['children'] ?? [] as $child) { + if (is_array($child)) { + $this->transpileNode($child, $ctx); + } + } + + $ctx->addLine('FlyUI::end();'); + $ctx->addLine(''); + } + + /** + * @param array $node + */ + private function transpileLabel(array $node, TranspileContext $ctx): void + { + $text = $node['text'] ?? ''; + $textCode = $this->buildBindingCode($text, $ctx); + $colorCode = isset($node['color']) ? ', ' . $this->buildColorCode($node['color'], $ctx) : ''; + + $viewVar = '$v' . $ctx->nextComponentIndex(); + $ctx->addLine("{$viewVar} = FlyUI::text({$textCode}{$colorCode});"); + + if (isset($node['fontSize'])) { + $ctx->addLine("{$viewVar}->fontSize({$this->exportFloat((float) $node['fontSize'])});"); + } + if (!empty($node['bold'])) { + $ctx->addLine("{$viewVar}->bold();"); + } + } + + /** + * @param array $node + */ + private function transpileButton(array $node, TranspileContext $ctx): void + { + $label = $node['label'] ?? $node['text'] ?? 'Button'; + $labelCode = $this->buildBindingCode($label, $ctx); + $event = $node['event'] ?? null; + $eventData = $node['eventData'] ?? []; + + if ($event !== null) { + $ctx->requireUse('VISU\\Signals\\UI\\UIEventSignal'); + $eventStr = $this->exportString($event); + $dataStr = $this->exportArray($eventData); + $ctx->addLine("FlyUI::button({$labelCode}, function () use (\$dispatcher): void {"); + $ctx->addLine(" \$dispatcher->dispatch('ui.event', new UIEventSignal({$eventStr}, {$dataStr}));"); + $ctx->addLine("});"); + } else { + $ctx->addLine("FlyUI::button({$labelCode}, function (): void {});"); + } + } + + /** + * @param array $node + */ + private function transpileProgressBar(array $node, TranspileContext $ctx): void + { + $valueExpr = $node['value'] ?? '0'; + $valueCode = $this->buildValueCode($valueExpr, $ctx); + $colorCode = isset($node['color']) ? ', ' . $this->buildColorCode($node['color'], $ctx) : ''; + + $viewVar = '$pb' . $ctx->nextComponentIndex(); + $ctx->addLine("{$viewVar} = FlyUI::progressBar((float)({$valueCode}){$colorCode});"); + + if (isset($node['height'])) { + $ctx->addLine("{$viewVar}->height({$this->exportFloat((float) $node['height'])});"); + } + } + + /** + * @param array $node + */ + private function transpileCheckbox(array $node, TranspileContext $ctx): void + { + $text = $this->buildBindingCode($node['text'] ?? $node['label'] ?? '', $ctx); + $id = $node['id'] ?? 'cb_' . ($node['text'] ?? ''); + $event = $node['event'] ?? null; + + $stateVar = '$cbState_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $id); + $ctx->addLine("static {$stateVar} = false;"); + + if ($event !== null) { + $ctx->requireUse('VISU\\Signals\\UI\\UIEventSignal'); + $eventStr = $this->exportString($event); + $idStr = $this->exportString($id); + $ctx->addLine("FlyUI::checkbox({$text}, {$stateVar}, function (bool \$checked) use (\$dispatcher): void {"); + $ctx->addLine(" \$dispatcher->dispatch('ui.event', new UIEventSignal({$eventStr}, ['id' => {$idStr}, 'checked' => \$checked]));"); + $ctx->addLine("});"); + } else { + $ctx->addLine("FlyUI::checkbox({$text}, {$stateVar});"); + } + } + + /** + * @param array $node + */ + private function transpileSelect(array $node, TranspileContext $ctx): void + { + $name = $node['name'] ?? $node['id'] ?? 'select'; + $options = $this->exportArray($node['options'] ?? []); + $event = $node['event'] ?? null; + + $stateVar = '$selState_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $name); + $selected = isset($node['selected']) ? $this->exportString($node['selected']) : 'null'; + $ctx->addLine("static {$stateVar} = {$selected};"); + + $nameStr = $this->exportString($name); + + if ($event !== null) { + $ctx->requireUse('VISU\\Signals\\UI\\UIEventSignal'); + $eventStr = $this->exportString($event); + $ctx->addLine("FlyUI::select({$nameStr}, {$options}, {$stateVar}, function (string \$selected) use (\$dispatcher): void {"); + $ctx->addLine(" \$dispatcher->dispatch('ui.event', new UIEventSignal({$eventStr}, ['name' => {$nameStr}, 'selected' => \$selected]));"); + $ctx->addLine("});"); + } else { + $ctx->addLine("FlyUI::select({$nameStr}, {$options}, {$stateVar});"); + } + } + + /** + * @param array $node + */ + private function transpileImage(array $node, TranspileContext $ctx): void + { + $w = $this->exportFloat((float) ($node['width'] ?? 64)); + $h = $this->exportFloat((float) ($node['height'] ?? 64)); + $colorCode = isset($node['color']) ? $this->buildColorCode($node['color'], $ctx) : $this->buildColorCode('#808080', $ctx); + + $ctx->addLine("\$imgLayout = FlyUI::beginLayout();"); + $ctx->addLine("\$imgLayout->fixedWidth({$w})->fixedHeight({$h})->backgroundColor({$colorCode}, 4.0);"); + $ctx->addLine("FlyUI::end();"); + } + + /** + * @param array $node + */ + private function transpileSpace(array $node, TranspileContext $ctx): void + { + if (isset($node['width'])) { + $ctx->addLine("FlyUI::spaceX({$this->exportFloat((float) $node['width'])});"); + } + if (isset($node['height'])) { + $ctx->addLine("FlyUI::spaceY({$this->exportFloat((float) $node['height'])});"); + } + } + + /** + * Builds PHP code for a binding expression. + * Converts "{path}" patterns to direct $ctx->get() calls. + */ + private function buildBindingCode(string $text, TranspileContext $ctx): string + { + // Pure binding: "{economy.money}" + if (preg_match('/^\{([^}]+)\}$/', $text, $m)) { + return "\$ctx->get('{$m[1]}', '')"; + } + + // Mixed text with bindings: "Money: {economy.money}" + if (preg_match_all('/\{([^}]+)\}/', $text, $matches)) { + $parts = preg_split('/\{[^}]+\}/', $text) ?: []; + $result = ''; + foreach ($parts as $i => $part) { + if ($part !== '') { + $result .= ($result !== '' ? ' . ' : '') . $this->exportString($part); + } + if (isset($matches[1][$i])) { + $path = $matches[1][$i]; + $getValue = "\$ctx->get('{$path}', '')"; + $result .= ($result !== '' ? ' . ' : '') . $getValue; + } + } + return $result; + } + + return $this->exportString($text); + } + + /** + * Builds PHP code for a value binding (for progressbar values etc). + */ + private function buildValueCode(mixed $valueExpr, TranspileContext $ctx): string + { + if (is_numeric($valueExpr)) { + return $this->exportFloat((float) $valueExpr); + } + + if (is_string($valueExpr) && preg_match('/^\{([^}]+)\}$/', $valueExpr, $m)) { + return "\$ctx->get('{$m[1]}', 0)"; + } + + if (is_string($valueExpr)) { + return $this->exportString($valueExpr); + } + + return '0.0'; + } + + private function buildPaddingCode(mixed $padding, TranspileContext $ctx): string + { + if ($padding === null) { + return 'null'; + } + + $ctx->requireUse('GL\\Math\\Vec4'); + + if (is_numeric($padding)) { + $p = $this->exportFloat((float) $padding); + return "new Vec4({$p}, {$p}, {$p}, {$p})"; + } + + if (is_array($padding)) { + $l = $this->exportFloat((float) ($padding[0] ?? $padding['left'] ?? 0)); + $r = $this->exportFloat((float) ($padding[1] ?? $padding['right'] ?? 0)); + $t = $this->exportFloat((float) ($padding[2] ?? $padding['top'] ?? 0)); + $b = $this->exportFloat((float) ($padding[3] ?? $padding['bottom'] ?? 0)); + return "new Vec4({$l}, {$r}, {$t}, {$b})"; + } + + return 'null'; + } + + private function buildColorCode(mixed $color, TranspileContext $ctx): string + { + $ctx->requireUse('GL\\VectorGraphics\\VGColor'); + + if (is_string($color) && str_starts_with($color, '#')) { + $hex = ltrim($color, '#'); + $r = $this->exportFloat(hexdec(substr($hex, 0, 2)) / 255.0); + $g = $this->exportFloat(hexdec(substr($hex, 2, 2)) / 255.0); + $b = $this->exportFloat(hexdec(substr($hex, 4, 2)) / 255.0); + if (strlen($hex) >= 8) { + $a = $this->exportFloat(hexdec(substr($hex, 6, 2)) / 255.0); + return "VGColor::rgba({$r}, {$g}, {$b}, {$a})"; + } + return "VGColor::rgb({$r}, {$g}, {$b})"; + } + + if (is_array($color)) { + $r = $this->exportFloat((float) ($color[0] ?? 0)); + $g = $this->exportFloat((float) ($color[1] ?? 0)); + $b = $this->exportFloat((float) ($color[2] ?? 0)); + return "VGColor::rgb({$r}, {$g}, {$b})"; + } + + return 'VGColor::white()'; + } + + private function generateClass(string $className, string $namespace, TranspileContext $ctx, ?string $sourcePath): string + { + $uses = $ctx->getUseStatements(); + sort($uses); + $useLines = implode("\n", array_map(fn(string $u) => "use {$u};", array_unique($uses))); + $bodyLines = implode("\n", array_map(fn(string $l) => $l === '' ? '' : " {$l}", $ctx->getLines())); + + $sourceComment = $sourcePath !== null + ? "\n /** Source: {$sourcePath} */\n" + : "\n"; + + return << $value + */ + private function exportArray(mixed $value): string + { + if (!is_array($value)) { + return '[]'; + } + if (array_is_list($value)) { + $items = array_map(fn($v) => is_string($v) ? $this->exportString($v) : var_export($v, true), $value); + return '[' . implode(', ', $items) . ']'; + } + $items = []; + foreach ($value as $k => $v) { + $kStr = is_string($k) ? $this->exportString($k) : $k; + $vStr = is_string($v) ? $this->exportString($v) : var_export($v, true); + $items[] = "{$kStr} => {$vStr}"; + } + return '[' . implode(', ', $items) . ']'; + } +} diff --git a/src/UI/UIDataContext.php b/src/UI/UIDataContext.php new file mode 100644 index 0000000..326ff5f --- /dev/null +++ b/src/UI/UIDataContext.php @@ -0,0 +1,111 @@ + + */ + private array $data = []; + + private ?LocaleManager $localeManager = null; + + /** + * Sets a value at a dot-notation path. + * e.g. set('economy.money', 1500) + */ + public function set(string $path, mixed $value): void + { + $this->data[$path] = $value; + } + + /** + * Gets a value at a dot-notation path. + */ + public function get(string $path, mixed $default = null): mixed + { + return $this->data[$path] ?? $default; + } + + /** + * Bulk set multiple values. + * + * @param array $values + */ + public function setAll(array $values): void + { + foreach ($values as $path => $value) { + $this->data[$path] = $value; + } + } + + public function getLocaleManager(): ?LocaleManager + { + return $this->localeManager; + } + + public function setLocaleManager(LocaleManager $localeManager): void + { + $this->localeManager = $localeManager; + } + + /** + * Resolves binding expressions in a string. + * e.g. "Geld: {economy.money}" -> "Geld: 1500" + * + * Translation bindings use {t:key} or {t:key|param=value} syntax + * and are resolved via the LocaleManager when available. + */ + public function resolveBindings(string $text): string + { + // Resolve translation expressions first + if ($this->localeManager !== null && str_contains($text, '{t:')) { + $text = $this->localeManager->resolveTranslations($text); + } + + return (string) preg_replace_callback('/\{([^}]+)\}/', function (array $matches): string { + $path = $matches[1]; + $value = $this->get($path); + if ($value === null) { + return $matches[0]; // keep original if unresolved + } + if (is_float($value)) { + return number_format($value, 2); + } + return (string) $value; + }, $text); + } + + /** + * Resolves a single binding expression to its raw value. + * Returns the binding string itself if it's not a pure binding. + */ + public function resolveValue(string $expr): mixed + { + if (preg_match('/^\{([^}]+)\}$/', $expr, $matches)) { + return $this->get($matches[1]); + } + return $expr; + } + + /** + * Checks if a string contains binding expressions. + */ + public function hasBindings(string $text): bool + { + return (bool) preg_match('/\{[^}]+\}/', $text); + } + + /** + * Returns all stored data. + * + * @return array + */ + public function toArray(): array + { + return $this->data; + } +} diff --git a/src/UI/UIInterpreter.php b/src/UI/UIInterpreter.php new file mode 100644 index 0000000..e646478 --- /dev/null +++ b/src/UI/UIInterpreter.php @@ -0,0 +1,352 @@ + Checkbox state storage + */ + private array $checkboxStates = []; + + /** + * @var array Select state storage + */ + private array $selectStates = []; + + public function __construct( + private DispatcherInterface $dispatcher, + ?UIDataContext $dataContext = null, + ) { + $this->dataContext = $dataContext ?? new UIDataContext(); + } + + public function getDataContext(): UIDataContext + { + return $this->dataContext; + } + + public function setDataContext(UIDataContext $dataContext): void + { + $this->dataContext = $dataContext; + } + + /** + * Renders a UI layout from a JSON file. + */ + public function renderFile(string $path): void + { + if (!file_exists($path)) { + throw new \RuntimeException("UI layout file not found: {$path}"); + } + + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException("Failed to read UI layout file: {$path}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in UI layout file: {$path}"); + } + + $this->renderNode($data); + } + + /** + * Renders a UI layout from a data array. + * + * @param array $node + */ + public function renderNode(array $node): void + { + $type = UINodeType::tryFrom($node['type'] ?? ''); + if ($type === null) { + return; + } + + match ($type) { + UINodeType::Panel => $this->renderPanel($node), + UINodeType::Label => $this->renderLabel($node), + UINodeType::Button => $this->renderButton($node), + UINodeType::ProgressBar => $this->renderProgressBar($node), + UINodeType::Checkbox => $this->renderCheckbox($node), + UINodeType::Select => $this->renderSelect($node), + UINodeType::Image => $this->renderImage($node), + UINodeType::Space => $this->renderSpace($node), + }; + } + + /** + * @param array $node + */ + private function renderPanel(array $node): void + { + $padding = $this->parsePadding($node['padding'] ?? null); + $layout = FlyUI::beginLayout($padding); + + $flowStr = $node['layout'] ?? 'column'; + if ($flowStr === 'row') { + $layout->flow(FUILayoutFlow::horizontal); + } + + if (isset($node['spacing'])) { + $layout->spacing((float) $node['spacing']); + } + + if (isset($node['backgroundColor'])) { + $layout->backgroundColor($this->parseColor($node['backgroundColor'])); + } + + // Sizing + if (isset($node['width'])) { + $layout->fixedWidth((float) $node['width']); + } else { + $widthMode = $node['horizontalSizing'] ?? 'fill'; + if ($widthMode === 'fit') { + $layout->horizontalFit(); + } + } + + if (isset($node['height'])) { + $layout->fixedHeight((float) $node['height']); + } else { + $heightMode = $node['verticalSizing'] ?? 'fit'; + if ($heightMode === 'fill') { + $layout->verticalFill(); + } + } + + // Render children + foreach ($node['children'] ?? [] as $child) { + if (is_array($child)) { + $this->renderNode($child); + } + } + + FlyUI::end(); + } + + /** + * @param array $node + */ + private function renderLabel(array $node): void + { + $text = $this->resolveText($node['text'] ?? ''); + $color = isset($node['color']) ? $this->parseColor($node['color']) : null; + $view = FlyUI::text($text, $color); + + if (isset($node['fontSize'])) { + $view->fontSize((float) $node['fontSize']); + } + if (isset($node['bold']) && $node['bold']) { + $view->bold(); + } + } + + /** + * @param array $node + */ + private function renderButton(array $node): void + { + $label = $this->resolveText($node['label'] ?? $node['text'] ?? 'Button'); + $event = $node['event'] ?? null; + $eventData = $node['eventData'] ?? []; + + $dispatcher = $this->dispatcher; + $button = FlyUI::button($label, function () use ($event, $eventData, $dispatcher): void { + if ($event !== null) { + $dispatcher->dispatch('ui.event', new UIEventSignal($event, $eventData)); + } + }); + + if (isset($node['id'])) { + $button->setId((string) $node['id']); + } + if (isset($node['fullWidth']) && $node['fullWidth']) { + $button->setFullWidth(); + } + if (isset($node['style']) && $node['style'] === 'secondary') { + $button->applyStyle(FlyUI::$instance->theme->secondaryButton); + } + } + + /** + * @param array $node + */ + private function renderProgressBar(array $node): void + { + $valueExpr = $node['value'] ?? '0'; + $value = is_string($valueExpr) ? $this->dataContext->resolveValue($valueExpr) : $valueExpr; + $value = is_numeric($value) ? (float) $value : 0.0; + + $color = isset($node['color']) ? $this->parseColor($node['color']) : null; + $bar = FlyUI::progressBar($value, $color); + + if (isset($node['height'])) { + $bar->height((float) $node['height']); + } + } + + /** + * @param array $node + */ + private function renderCheckbox(array $node): void + { + $text = $this->resolveText($node['text'] ?? $node['label'] ?? ''); + $id = $node['id'] ?? 'cb_' . $text; + $event = $node['event'] ?? null; + + // Persist checkbox state + if (!isset($this->checkboxStates[$id])) { + $bindingExpr = $node['checked'] ?? false; + if (is_string($bindingExpr)) { + $resolved = $this->dataContext->resolveValue($bindingExpr); + $this->checkboxStates[$id] = (bool) $resolved; + } else { + $this->checkboxStates[$id] = (bool) $bindingExpr; + } + } + + $dispatcher = $this->dispatcher; + FlyUI::checkbox($text, $this->checkboxStates[$id], function (bool $checked) use ($event, $id, $dispatcher): void { + if ($event !== null) { + $dispatcher->dispatch('ui.event', new UIEventSignal($event, ['id' => $id, 'checked' => $checked])); + } + }); + } + + /** + * @param array $node + */ + private function renderSelect(array $node): void + { + $name = $node['name'] ?? $node['id'] ?? 'select_' . spl_object_id($this); + $options = $node['options'] ?? []; + $event = $node['event'] ?? null; + + if (!isset($this->selectStates[$name])) { + $this->selectStates[$name] = $node['selected'] ?? null; + } + + $dispatcher = $this->dispatcher; + FlyUI::select($name, $options, $this->selectStates[$name], function (string $selected) use ($event, $name, $dispatcher): void { + if ($event !== null) { + $dispatcher->dispatch('ui.event', new UIEventSignal($event, ['name' => $name, 'selected' => $selected])); + } + }); + } + + /** + * @param array $node + */ + private function renderImage(array $node): void + { + // Placeholder: render a colored rectangle with optional label + $width = (float) ($node['width'] ?? 64); + $height = (float) ($node['height'] ?? 64); + $color = isset($node['color']) ? $this->parseColor($node['color']) : VGColor::rgb(0.5, 0.5, 0.5); + + $layout = FlyUI::beginLayout(); + $layout->fixedWidth($width)->fixedHeight($height)->backgroundColor($color, 4.0); + FlyUI::end(); + } + + /** + * @param array $node + */ + private function renderSpace(array $node): void + { + if (isset($node['width'])) { + FlyUI::spaceX((float) $node['width']); + } + if (isset($node['height'])) { + FlyUI::spaceY((float) $node['height']); + } + } + + private function resolveText(string $text): string + { + return $this->dataContext->resolveBindings($text); + } + + private function parsePadding(mixed $padding): ?Vec4 + { + if ($padding === null) { + return null; + } + if (is_numeric($padding)) { + $p = (float) $padding; + return new Vec4($p, $p, $p, $p); + } + if (is_array($padding)) { + return new Vec4( + (float) ($padding[0] ?? $padding['left'] ?? 0), + (float) ($padding[1] ?? $padding['right'] ?? 0), + (float) ($padding[2] ?? $padding['top'] ?? 0), + (float) ($padding[3] ?? $padding['bottom'] ?? 0), + ); + } + return null; + } + + private function parseColor(mixed $color): VGColor + { + if ($color instanceof VGColor) { + return $color; + } + if (is_string($color)) { + // Hex color: #RRGGBB or #RRGGBBAA + if (str_starts_with($color, '#')) { + $hex = ltrim($color, '#'); + $r = hexdec(substr($hex, 0, 2)) / 255.0; + $g = hexdec(substr($hex, 2, 2)) / 255.0; + $b = hexdec(substr($hex, 4, 2)) / 255.0; + $a = strlen($hex) >= 8 ? hexdec(substr($hex, 6, 2)) / 255.0 : 1.0; + return VGColor::rgba((float) $r, (float) $g, (float) $b, (float) $a); + } + } + if (is_array($color)) { + return VGColor::rgb( + (float) ($color[0] ?? 0), + (float) ($color[1] ?? 0), + (float) ($color[2] ?? 0), + ); + } + return VGColor::white(); + } + + /** + * Returns the current checkbox state for a given ID. + */ + public function getCheckboxState(string $id): bool + { + return $this->checkboxStates[$id] ?? false; + } + + /** + * Returns the current select state for a given name. + */ + public function getSelectState(string $name): ?string + { + return $this->selectStates[$name] ?? null; + } + + /** + * Resets all widget states (checkbox, select, etc.). + */ + public function resetStates(): void + { + $this->checkboxStates = []; + $this->selectStates = []; + } +} diff --git a/src/UI/UINodeType.php b/src/UI/UINodeType.php new file mode 100644 index 0000000..a842932 --- /dev/null +++ b/src/UI/UINodeType.php @@ -0,0 +1,15 @@ +|null $layoutData Inline layout data (optional) + * @param bool $transparent If true, screens below this one are also rendered + */ + public function __construct( + public readonly string $name, + private ?string $layoutFile = null, + private ?array $layoutData = null, + private bool $transparent = false, + ) { + } + + public function setEnterTransition(UITransitionType $type, float $duration = 0.3, float $delay = 0.0): self + { + $this->enterTransition = new UITransition($type, $duration, $delay); + return $this; + } + + public function setExitTransition(UITransitionType $type, float $duration = 0.3, float $delay = 0.0): self + { + $this->exitTransition = new UITransition($type, $duration, $delay); + return $this; + } + + public function getEnterTransition(): ?UITransition + { + return $this->enterTransition; + } + + public function getExitTransition(): ?UITransition + { + return $this->exitTransition; + } + + public function isTransparent(): bool + { + return $this->transparent; + } + + public function isActive(): bool + { + return $this->active; + } + + public function getName(): string + { + return $this->name; + } + + public function getLayoutFile(): ?string + { + return $this->layoutFile; + } + + /** + * @return array|null + */ + public function getLayoutData(): ?array + { + return $this->layoutData; + } + + public function setLayoutFile(string $path): void + { + $this->layoutFile = $path; + } + + /** + * @param array $data + */ + public function setLayoutData(array $data): void + { + $this->layoutData = $data; + } + + public function onEnter(): void + { + $this->active = true; + $this->enterTransition?->reset(); + } + + public function onExit(): void + { + $this->active = false; + $this->exitTransition?->reset(); + } + + public function update(float $deltaTime): void + { + $this->enterTransition?->update($deltaTime); + $this->exitTransition?->update($deltaTime); + } + + public function render(UIInterpreter $interpreter): void + { + if ($this->layoutFile !== null) { + $interpreter->renderFile($this->layoutFile); + } elseif ($this->layoutData !== null) { + $interpreter->renderNode($this->layoutData); + } + } +} diff --git a/src/UI/UIScreenStack.php b/src/UI/UIScreenStack.php new file mode 100644 index 0000000..5e1058c --- /dev/null +++ b/src/UI/UIScreenStack.php @@ -0,0 +1,125 @@ + + */ + private array $stack = []; + + /** + * Pushes a screen onto the stack with an optional transition. + */ + public function push(UIScreen $screen): void + { + $this->stack[] = $screen; + $screen->onEnter(); + } + + /** + * Pops the top screen from the stack. + */ + public function pop(): ?UIScreen + { + if (empty($this->stack)) { + return null; + } + + $screen = array_pop($this->stack); + $screen->onExit(); + return $screen; + } + + /** + * Replaces the top screen with a new one. + */ + public function replace(UIScreen $screen): ?UIScreen + { + $old = $this->pop(); + $this->push($screen); + return $old; + } + + /** + * Returns the top screen without removing it. + */ + public function peek(): ?UIScreen + { + if (empty($this->stack)) { + return null; + } + return $this->stack[array_key_last($this->stack)]; + } + + /** + * Returns the number of screens on the stack. + */ + public function count(): int + { + return count($this->stack); + } + + /** + * Whether the stack is empty. + */ + public function isEmpty(): bool + { + return empty($this->stack); + } + + /** + * Clears all screens from the stack. + */ + public function clear(): void + { + while (!empty($this->stack)) { + $this->pop(); + } + } + + /** + * Updates all screens on the stack. + */ + public function update(float $deltaTime): void + { + foreach ($this->stack as $screen) { + $screen->update($deltaTime); + } + } + + /** + * Renders screens. By default only renders the top screen. + * If a screen is transparent, it also renders screens below it. + */ + public function render(UIInterpreter $interpreter): void + { + if (empty($this->stack)) { + return; + } + + // Find the lowest visible screen (walk from top looking for non-transparent) + $renderFrom = count($this->stack) - 1; + for ($i = count($this->stack) - 1; $i > 0; $i--) { + if (!$this->stack[$i]->isTransparent()) { + break; + } + $renderFrom = $i - 1; + } + + for ($i = $renderFrom; $i < count($this->stack); $i++) { + $this->stack[$i]->render($interpreter); + } + } + + /** + * Returns all screens on the stack (bottom to top). + * + * @return array + */ + public function getScreens(): array + { + return $this->stack; + } +} diff --git a/src/UI/UITransition.php b/src/UI/UITransition.php new file mode 100644 index 0000000..99a51fe --- /dev/null +++ b/src/UI/UITransition.php @@ -0,0 +1,108 @@ +elapsed += $deltaTime; + if ($this->elapsed >= $this->delay + $this->duration) { + $this->finished = true; + } + } + + public function isFinished(): bool + { + return $this->finished; + } + + public function getProgress(): float + { + $active = $this->elapsed - $this->delay; + if ($active <= 0.0) { + return 0.0; + } + if ($this->duration <= 0.0) { + return 1.0; + } + return min(1.0, $active / $this->duration); + } + + /** + * Eased progress using ease-out cubic. + */ + public function getEasedProgress(): float + { + $t = $this->getProgress(); + return 1.0 - pow(1.0 - $t, 3); + } + + /** + * Returns the current opacity (0.0 to 1.0). + */ + public function getOpacity(): float + { + $p = $this->getEasedProgress(); + return match ($this->type) { + UITransitionType::FadeIn, UITransitionType::SlideInLeft, UITransitionType::SlideInRight, + UITransitionType::SlideInTop, UITransitionType::SlideInBottom, UITransitionType::ScaleIn => $p, + UITransitionType::FadeOut, UITransitionType::ScaleOut => 1.0 - $p, + }; + } + + /** + * Returns the X offset for slide transitions. + */ + public function getOffsetX(float $containerWidth): float + { + $p = $this->getEasedProgress(); + return match ($this->type) { + UITransitionType::SlideInLeft => -$containerWidth * (1.0 - $p), + UITransitionType::SlideInRight => $containerWidth * (1.0 - $p), + default => 0.0, + }; + } + + /** + * Returns the Y offset for slide transitions. + */ + public function getOffsetY(float $containerHeight): float + { + $p = $this->getEasedProgress(); + return match ($this->type) { + UITransitionType::SlideInTop => -$containerHeight * (1.0 - $p), + UITransitionType::SlideInBottom => $containerHeight * (1.0 - $p), + default => 0.0, + }; + } + + /** + * Returns the current scale factor (0.0 to 1.0). + */ + public function getScale(): float + { + $p = $this->getEasedProgress(); + return match ($this->type) { + UITransitionType::ScaleIn => $p, + UITransitionType::ScaleOut => 1.0 - $p, + default => 1.0, + }; + } + + public function reset(): void + { + $this->elapsed = 0.0; + $this->finished = false; + } +} diff --git a/src/UI/UITransitionType.php b/src/UI/UITransitionType.php new file mode 100644 index 0000000..913e42d --- /dev/null +++ b/src/UI/UITransitionType.php @@ -0,0 +1,15 @@ +resourcesDir = $resourcesDir ?? (defined('VISU_PATH_RESOURCES') ? VISU_PATH_RESOURCES : getcwd() . '/resources'); + $this->cacheDir = $cacheDir ?? (defined('VISU_PATH_CACHE') ? VISU_PATH_CACHE : null); + } + + public function handle(string $method, string $path): void + { + header('Content-Type: application/json'); + header('Access-Control-Allow-Origin: *'); + header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); + + if ($method === 'OPTIONS') { + http_response_code(204); + return; + } + + // /api/config + if ($path === '/api/config') { + echo json_encode([ + 'tileSize' => 32, + 'gridWidth' => 32, + 'gridHeight' => 32, + 'worldsDir' => $this->worldsDir, + ]); + return; + } + + // /api/worlds + if ($path === '/api/worlds' && $method === 'GET') { + $this->listWorlds(); + return; + } + + // /api/worlds/{name}/entities/{layerId}/{entityId} — must match before /api/worlds/{name} + if (preg_match('#^/api/worlds/([a-zA-Z0-9_\-]+)/entities/([a-zA-Z0-9_\-]+)/([0-9]+)$#', $path, $m)) { + match ($method) { + 'PATCH' => $this->patchEntity($m[1], $m[2], (int) $m[3]), + 'DELETE' => $this->deleteEntity($m[1], $m[2], (int) $m[3]), + default => $this->error(405, 'Method not allowed'), + }; + return; + } + + // /api/worlds/{name} + if (preg_match('#^/api/worlds/([a-zA-Z0-9_\-]+)$#', $path, $m)) { + $name = $m[1]; + match ($method) { + 'GET' => $this->getWorld($name), + 'POST' => $this->saveWorld($name), + 'DELETE' => $this->deleteWorld($name), + default => $this->error(405, 'Method not allowed'), + }; + return; + } + + // /api/assets + if ($path === '/api/assets' && $method === 'GET') { + $this->browseAssets(''); + return; + } + + // /api/assets/browse?dir=path + if ($path === '/api/assets/browse' && $method === 'GET') { + $this->browseAssets($_GET['dir'] ?? ''); + return; + } + + // /api/scenes + if ($path === '/api/scenes' && $method === 'GET') { + $this->listScenes(); + return; + } + + // /api/scenes/{name} + if (preg_match('#^/api/scenes/([a-zA-Z0-9_\-]+)$#', $path, $m)) { + match ($method) { + 'GET' => $this->getScene($m[1]), + 'POST' => $this->saveScene($m[1]), + default => $this->error(405, 'Method not allowed'), + }; + return; + } + + // /api/ui + if ($path === '/api/ui' && $method === 'GET') { + $this->listUILayouts(); + return; + } + + // /api/ui/{name} + if (preg_match('#^/api/ui/([a-zA-Z0-9_\-]+)$#', $path, $m)) { + match ($method) { + 'GET' => $this->getUILayout($m[1]), + 'POST' => $this->saveUILayout($m[1]), + default => $this->error(405, 'Method not allowed'), + }; + return; + } + + // /api/transpile + if ($path === '/api/transpile' && $method === 'POST') { + $this->transpileAll(); + return; + } + + $this->error(404, 'API endpoint not found'); + } + + // ── World CRUD ────────────────────────────────────────────────────── + + private function listWorlds(): void + { + $worlds = []; + if (is_dir($this->worldsDir)) { + foreach (glob($this->worldsDir . '/*.world.json') ?: [] as $file) { + $name = basename($file, '.world.json'); + $worlds[] = [ + 'name' => $name, + 'modified' => date('c', filemtime($file) ?: 0), + 'size' => filesize($file), + ]; + } + } + echo json_encode($worlds); + } + + private function getWorld(string $name): void + { + $path = $this->worldPath($name); + if (!file_exists($path)) { + $this->error(404, "World '{$name}' not found"); + return; + } + + $content = file_get_contents($path); + if ($content === false) { + $this->error(500, 'Failed to read world file'); + return; + } + + echo $content; + } + + private function saveWorld(string $name): void + { + $body = file_get_contents('php://input'); + if ($body === false || $body === '') { + $this->error(400, 'Empty request body'); + return; + } + + $data = json_decode($body, true); + if (!is_array($data)) { + $this->error(400, 'Invalid JSON body'); + return; + } + + try { + $world = WorldFile::fromArray($data); + $world->save($this->worldPath($name)); + echo json_encode(['ok' => true, 'name' => $name]); + } catch (\Throwable $e) { + $this->error(500, $e->getMessage()); + } + } + + private function deleteWorld(string $name): void + { + $path = $this->worldPath($name); + if (!file_exists($path)) { + $this->error(404, "World '{$name}' not found"); + return; + } + + if (!unlink($path)) { + $this->error(500, 'Failed to delete world file'); + return; + } + + echo json_encode(['ok' => true]); + } + + // ── Entity PATCH/DELETE ───────────────────────────────────────────── + + private function patchEntity(string $worldName, string $layerId, int $entityId): void + { + $path = $this->worldPath($worldName); + if (!file_exists($path)) { + $this->error(404, "World '{$worldName}' not found"); + return; + } + + $body = file_get_contents('php://input'); + if ($body === false || $body === '') { + $this->error(400, 'Empty request body'); + return; + } + + $patch = json_decode($body, true); + if (!is_array($patch)) { + $this->error(400, 'Invalid JSON body'); + return; + } + + try { + $world = WorldFile::load($path); + $found = false; + + foreach ($world->layers as &$layer) { + if ($layer['id'] !== $layerId || ($layer['type'] ?? '') !== 'entity') { + continue; + } + foreach ($layer['entities'] as &$entity) { + if (($entity['id'] ?? 0) === $entityId) { + foreach ($patch as $key => $value) { + if ($key === 'id') continue; + $entity[$key] = $value; + } + $found = true; + break 2; + } + } + unset($entity); + } + unset($layer); + + if (!$found) { + $this->error(404, "Entity {$entityId} not found in layer '{$layerId}'"); + return; + } + + $world->save($path); + echo json_encode(['ok' => true, 'entityId' => $entityId]); + } catch (\Throwable $e) { + $this->error(500, $e->getMessage()); + } + } + + private function deleteEntity(string $worldName, string $layerId, int $entityId): void + { + $path = $this->worldPath($worldName); + if (!file_exists($path)) { + $this->error(404, "World '{$worldName}' not found"); + return; + } + + try { + $world = WorldFile::load($path); + $found = false; + + foreach ($world->layers as &$layer) { + if ($layer['id'] !== $layerId || ($layer['type'] ?? '') !== 'entity') { + continue; + } + $before = count($layer['entities'] ?? []); + $layer['entities'] = array_values(array_filter( + $layer['entities'] ?? [], + fn($e) => ($e['id'] ?? 0) !== $entityId + )); + if (count($layer['entities']) < $before) { + $found = true; + } + break; + } + unset($layer); + + if (!$found) { + $this->error(404, "Entity {$entityId} not found in layer '{$layerId}'"); + return; + } + + $world->save($path); + echo json_encode(['ok' => true]); + } catch (\Throwable $e) { + $this->error(500, $e->getMessage()); + } + } + + // ── Asset Browser ─────────────────────────────────────────────────── + + private function browseAssets(string $subDir): void + { + $subDir = str_replace(['..', "\0"], '', $subDir); + $subDir = ltrim($subDir, '/'); + + $baseDir = $this->resourcesDir; + $fullDir = $subDir !== '' ? $baseDir . '/' . $subDir : $baseDir; + + if (!is_dir($fullDir)) { + echo json_encode(['path' => $subDir, 'entries' => []]); + return; + } + + $entries = []; + $items = scandir($fullDir); + if ($items === false) { + echo json_encode(['path' => $subDir, 'entries' => []]); + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + + $itemPath = $fullDir . '/' . $item; + $relativePath = $subDir !== '' ? $subDir . '/' . $item : $item; + + if (is_dir($itemPath)) { + $entries[] = [ + 'name' => $item, + 'path' => $relativePath, + 'type' => 'directory', + ]; + } else { + $ext = strtolower(pathinfo($item, PATHINFO_EXTENSION)); + $assetType = match ($ext) { + 'png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp' => 'image', + 'json' => 'json', + 'glsl', 'vert', 'frag' => 'shader', + 'glb', 'gltf' => 'model', + 'ogg', 'wav', 'mp3' => 'audio', + 'ttf', 'otf' => 'font', + default => 'file', + }; + + $entries[] = [ + 'name' => $item, + 'path' => $relativePath, + 'type' => $assetType, + 'size' => filesize($itemPath), + ]; + } + } + + usort($entries, function ($a, $b) { + if ($a['type'] === 'directory' && $b['type'] !== 'directory') return -1; + if ($a['type'] !== 'directory' && $b['type'] === 'directory') return 1; + return strcasecmp($a['name'], $b['name']); + }); + + echo json_encode(['path' => $subDir, 'entries' => $entries]); + } + + // ── Scene API ─────────────────────────────────────────────────────── + + private function listScenes(): void + { + $scenes = []; + $scenesDir = $this->resourcesDir . '/scenes'; + + if (is_dir($scenesDir)) { + foreach (glob($scenesDir . '/*.json') ?: [] as $file) { + $name = basename($file, '.json'); + $scenes[] = [ + 'name' => $name, + 'modified' => date('c', filemtime($file) ?: 0), + 'size' => filesize($file), + ]; + } + } + + echo json_encode($scenes); + } + + private function getScene(string $name): void + { + $path = $this->resourcesDir . '/scenes/' . $name . '.json'; + if (!file_exists($path)) { + $this->error(404, "Scene '{$name}' not found"); + return; + } + + $content = file_get_contents($path); + if ($content === false) { + $this->error(500, 'Failed to read scene file'); + return; + } + + echo $content; + } + + private function saveScene(string $name): void + { + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) { + $this->error(400, 'Invalid scene name'); + return; + } + + $body = file_get_contents('php://input'); + if ($body === false || $body === '') { + $this->error(400, 'Empty request body'); + return; + } + + $data = json_decode($body, true); + if (!is_array($data)) { + $this->error(400, 'Invalid JSON body'); + return; + } + + $scenesDir = $this->resourcesDir . '/scenes'; + if (!is_dir($scenesDir)) { + mkdir($scenesDir, 0755, true); + } + + $path = $scenesDir . '/' . $name . '.json'; + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false || file_put_contents($path, $json) === false) { + $this->error(500, 'Failed to save scene'); + return; + } + + // Auto-transpile scene + $transpileResult = $this->autoTranspileScene($path); + + echo json_encode(['ok' => true, 'name' => $name, 'transpiled' => $transpileResult]); + } + + // ── UI Layout API ──────────────────────────────────────────────────── + + private function listUILayouts(): void + { + $layouts = []; + $uiDir = $this->resourcesDir . '/ui'; + + if (is_dir($uiDir)) { + foreach (glob($uiDir . '/*.json') ?: [] as $file) { + $name = basename($file, '.json'); + $layouts[] = [ + 'name' => $name, + 'modified' => date('c', filemtime($file) ?: 0), + 'size' => filesize($file), + ]; + } + } + + echo json_encode($layouts); + } + + private function getUILayout(string $name): void + { + $path = $this->resourcesDir . '/ui/' . $name . '.json'; + if (!file_exists($path)) { + $this->error(404, "UI layout '{$name}' not found"); + return; + } + + $content = file_get_contents($path); + if ($content === false) { + $this->error(500, 'Failed to read UI layout file'); + return; + } + + echo $content; + } + + private function saveUILayout(string $name): void + { + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) { + $this->error(400, 'Invalid UI layout name'); + return; + } + + $body = file_get_contents('php://input'); + if ($body === false || $body === '') { + $this->error(400, 'Empty request body'); + return; + } + + $data = json_decode($body, true); + if (!is_array($data)) { + $this->error(400, 'Invalid JSON body'); + return; + } + + $uiDir = $this->resourcesDir . '/ui'; + if (!is_dir($uiDir)) { + mkdir($uiDir, 0755, true); + } + + $path = $uiDir . '/' . $name . '.json'; + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false || file_put_contents($path, $json) === false) { + $this->error(500, 'Failed to save UI layout'); + return; + } + + // Auto-transpile UI layout + $transpileResult = $this->autoTranspileUI($path); + + echo json_encode(['ok' => true, 'name' => $name, 'transpiled' => $transpileResult]); + } + + // ── Auto-Transpile ────────────────────────────────────────────────── + + /** + * @return array{success: bool, output?: string, error?: string}|null + */ + private function autoTranspileScene(string $jsonPath): ?array + { + if ($this->cacheDir === null) { + return null; + } + + try { + $transpiler = new \VISU\Transpiler\SceneTranspiler(new \VISU\ECS\ComponentRegistry()); + $baseName = pathinfo($jsonPath, PATHINFO_FILENAME); + $className = $this->toClassName($baseName); + $outputDir = $this->cacheDir . '/transpiled/Scenes'; + + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + $outputPath = $outputDir . '/' . $className . '.php'; + $code = $transpiler->transpile($jsonPath, $className, 'VISU\\Generated\\Scenes'); + file_put_contents($outputPath, $code); + + // Update registry + $registry = new \VISU\Transpiler\TranspilerRegistry($this->cacheDir); + $registry->record($jsonPath, $outputPath); + $registry->save(); + + return ['success' => true, 'output' => $className . '.php']; + } catch (\Throwable $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * @return array{success: bool, output?: string, error?: string}|null + */ + private function autoTranspileUI(string $jsonPath): ?array + { + if ($this->cacheDir === null) { + return null; + } + + try { + $transpiler = new \VISU\Transpiler\UITranspiler(); + $baseName = pathinfo($jsonPath, PATHINFO_FILENAME); + $className = $this->toClassName($baseName); + $outputDir = $this->cacheDir . '/transpiled/UI'; + + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + $outputPath = $outputDir . '/' . $className . '.php'; + $code = $transpiler->transpile($jsonPath, $className, 'VISU\\Generated\\UI'); + file_put_contents($outputPath, $code); + + $registry = new \VISU\Transpiler\TranspilerRegistry($this->cacheDir); + $registry->record($jsonPath, $outputPath); + $registry->save(); + + return ['success' => true, 'output' => $className . '.php']; + } catch (\Throwable $e) { + return ['success' => false, 'error' => $e->getMessage()]; + } + } + + /** + * Transpile all scenes, UI layouts, and prefabs. + */ + private function transpileAll(): void + { + if ($this->cacheDir === null) { + echo json_encode(['ok' => false, 'error' => 'Cache directory not configured']); + return; + } + + $results = ['scenes' => [], 'ui' => [], 'prefabs' => []]; + + // Transpile scenes + $scenesDir = $this->resourcesDir . '/scenes'; + if (is_dir($scenesDir)) { + foreach (glob($scenesDir . '/*.json') ?: [] as $file) { + $name = basename($file, '.json'); + $results['scenes'][$name] = $this->autoTranspileScene($file); + } + } + + // Transpile UI layouts + $uiDir = $this->resourcesDir . '/ui'; + if (is_dir($uiDir)) { + foreach (glob($uiDir . '/*.json') ?: [] as $file) { + $name = basename($file, '.json'); + $results['ui'][$name] = $this->autoTranspileUI($file); + } + } + + echo json_encode(['ok' => true, 'results' => $results]); + } + + /** + * Converts a file basename to a PascalCase class name. + */ + private function toClassName(string $baseName): string + { + $cleaned = preg_replace('/[^a-zA-Z0-9]+/', ' ', $baseName) ?? $baseName; + return str_replace(' ', '', ucwords($cleaned)); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private function worldPath(string $name): string + { + return rtrim($this->worldsDir, '/') . '/' . $name . '.world.json'; + } + + private function error(int $code, string $message): void + { + http_response_code($code); + echo json_encode(['error' => $message]); + } +} diff --git a/src/WorldEditor/WebSocket/EditorBridge.php b/src/WorldEditor/WebSocket/EditorBridge.php new file mode 100644 index 0000000..f22d788 --- /dev/null +++ b/src/WorldEditor/WebSocket/EditorBridge.php @@ -0,0 +1,171 @@ + Game): + * scene.changed - A scene/world file was modified + * entity.selected - An entity was selected in the editor + * entity.updated - An entity's properties were changed + * camera.moved - The editor camera position changed + * transpile.request - Request transpilation of a file + * + * Message Types (Game -> Editor): + * game.state - Game state update (FPS, entity count, etc.) + * scene.loaded - Game loaded a scene + * error - Error notification + */ +class EditorBridge +{ + private WebSocketServer $server; + + /** @var array Last known game state */ + private array $gameState = []; + + /** @var string|null Path to the change notification file */ + private ?string $changeFilePath; + + public function __construct( + string $host = '127.0.0.1', + int $port = 8766, + ?string $changeFilePath = null, + ) { + $this->server = new WebSocketServer($host, $port); + $this->changeFilePath = $changeFilePath; + + $this->registerHandlers(); + } + + public function getServer(): WebSocketServer + { + return $this->server; + } + + /** + * Run the WebSocket bridge (blocking). + */ + public function run(): void + { + $this->server->run(); + } + + /** + * Notify all connected clients that a scene/world file changed. + */ + public function notifySceneChanged(string $name, string $type = 'world'): void + { + $this->server->broadcast([ + 'type' => 'scene.changed', + 'data' => [ + 'name' => $name, + 'fileType' => $type, + 'timestamp' => microtime(true), + ], + ]); + + // Also write to change notification file for game engine polling + $this->writeChangeNotification($name, $type); + } + + /** + * Notify that a transpilation completed. + * + * @param array $result + */ + public function notifyTranspileComplete(string $sourcePath, array $result): void + { + $this->server->broadcast([ + 'type' => 'transpile.result', + 'data' => [ + 'source' => $sourcePath, + 'success' => $result['success'] ?? false, + 'output' => $result['output'] ?? null, + 'error' => $result['error'] ?? null, + 'timestamp' => microtime(true), + ], + ]); + } + + /** + * Update the shared game state. + * + * @param array $state + */ + public function updateGameState(array $state): void + { + $this->gameState = array_merge($this->gameState, $state); + $this->server->broadcast([ + 'type' => 'game.state', + 'data' => $this->gameState, + ]); + } + + private function registerHandlers(): void + { + $this->server->on('connect', function (int $clientId, mixed $data): void { + // Send current game state to newly connected client + if (!empty($this->gameState)) { + $this->server->send($clientId, [ + 'type' => 'game.state', + 'data' => $this->gameState, + ]); + } + }); + + $this->server->on('scene.changed', function (int $clientId, mixed $data): void { + // Re-broadcast to all other clients + $this->server->broadcast([ + 'type' => 'scene.changed', + 'data' => $data, + ]); + }); + + $this->server->on('entity.selected', function (int $clientId, mixed $data): void { + $this->server->broadcast([ + 'type' => 'entity.selected', + 'data' => $data, + ]); + }); + + $this->server->on('entity.updated', function (int $clientId, mixed $data): void { + $this->server->broadcast([ + 'type' => 'entity.updated', + 'data' => $data, + ]); + }); + + $this->server->on('ping', function (int $clientId, mixed $data): void { + $this->server->send($clientId, [ + 'type' => 'pong', + 'data' => ['clients' => $this->server->getClientCount()], + ]); + }); + } + + private function writeChangeNotification(string $name, string $type): void + { + if ($this->changeFilePath === null) { + return; + } + + $dir = dirname($this->changeFilePath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $notification = json_encode([ + 'name' => $name, + 'type' => $type, + 'timestamp' => microtime(true), + ]); + + if ($notification !== false) { + file_put_contents($this->changeFilePath, $notification); + } + } +} diff --git a/src/WorldEditor/WebSocket/WebSocketServer.php b/src/WorldEditor/WebSocket/WebSocketServer.php new file mode 100644 index 0000000..d77fd0c --- /dev/null +++ b/src/WorldEditor/WebSocket/WebSocketServer.php @@ -0,0 +1,365 @@ + Connected client sockets */ + private array $clients = []; + + /** @var array> */ + private array $handlers = []; + + private bool $running = false; + + public function __construct( + private string $host = '127.0.0.1', + private int $port = 8766, + ) { + } + + /** + * Register a handler for a specific message type. + */ + public function on(string $messageType, callable $handler): void + { + $this->handlers[$messageType][] = $handler; + } + + /** + * Start the WebSocket server (blocking). + */ + public function run(): void + { + $socket = stream_socket_server( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + ); + + if ($socket === false) { + throw new \RuntimeException("Failed to start WebSocket server: [{$errno}] {$errstr}"); + } + + $this->serverSocket = $socket; + + stream_set_blocking($this->serverSocket, false); + $this->running = true; + + $this->log("WebSocket server listening on ws://{$this->host}:{$this->port}"); + + while ($this->running) { + $this->tick(); + usleep(10000); // 10ms poll interval + } + + $this->shutdown(); + } + + /** + * Perform one tick of the event loop. + */ + public function tick(): void + { + if ($this->serverSocket === null) { + return; + } + + // Check for new connections + $newClient = @stream_socket_accept($this->serverSocket, 0); + if ($newClient !== false) { + $this->handleNewConnection($newClient); + } + + // Check for data from existing clients + foreach ($this->clients as $id => $client) { + $data = @fread($client, 65536); + if ($data === false || $data === '') { + if (feof($client)) { + $this->disconnectClient($id); + } + continue; + } + $this->handleClientData($id, $data); + } + } + + /** + * Send a JSON message to all connected clients. + * + * @param array $message + */ + public function broadcast(array $message): void + { + $json = json_encode($message); + if ($json === false) { + return; + } + + $frame = $this->encodeFrame($json); + foreach ($this->clients as $id => $client) { + $written = @fwrite($client, $frame); + if ($written === false) { + $this->disconnectClient($id); + } + } + } + + /** + * Send a message to a specific client. + * + * @param array $message + */ + public function send(int $clientId, array $message): void + { + if (!isset($this->clients[$clientId])) { + return; + } + + $json = json_encode($message); + if ($json === false) { + return; + } + + $frame = $this->encodeFrame($json); + @fwrite($this->clients[$clientId], $frame); + } + + public function stop(): void + { + $this->running = false; + } + + public function getClientCount(): int + { + return count($this->clients); + } + + // ── Connection handling ────────────────────────────────────────────── + + /** + * @param resource $socket + */ + private function handleNewConnection($socket): void + { + stream_set_blocking($socket, false); + + // Read the HTTP upgrade request + $headers = ''; + $attempts = 0; + while ($attempts < 100) { + $line = @fread($socket, 4096); + if ($line !== false && $line !== '') { + $headers .= $line; + if (str_contains($headers, "\r\n\r\n")) { + break; + } + } + $attempts++; + usleep(1000); + } + + if (!str_contains($headers, 'Upgrade: websocket') && !str_contains($headers, 'Upgrade: WebSocket')) { + // Not a WebSocket request — close connection + fclose($socket); + return; + } + + // Extract Sec-WebSocket-Key + if (!preg_match('/Sec-WebSocket-Key:\s*(.+?)\r\n/i', $headers, $m)) { + fclose($socket); + return; + } + + $key = trim($m[1]); + $accept = base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-5AB5DF11BE85', true)); + + $response = "HTTP/1.1 101 Switching Protocols\r\n" + . "Upgrade: websocket\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Accept: {$accept}\r\n" + . "\r\n"; + + fwrite($socket, $response); + + $id = (int) $socket; + $this->clients[$id] = $socket; + $this->log("Client connected (id: {$id}, total: " . count($this->clients) . ')'); + + $this->dispatch('connect', $id, []); + } + + private function handleClientData(int $id, string $data): void + { + $decoded = $this->decodeFrame($data); + if ($decoded === null) { + return; + } + + // Handle close frame + if ($decoded['opcode'] === 0x08) { + $this->disconnectClient($id); + return; + } + + // Handle ping + if ($decoded['opcode'] === 0x09) { + $pong = $this->encodeFrame($decoded['payload'], 0x0A); + @fwrite($this->clients[$id], $pong); + return; + } + + // Text frame + if ($decoded['opcode'] === 0x01) { + $message = json_decode($decoded['payload'], true); + if (is_array($message) && isset($message['type'])) { + $this->dispatch((string) $message['type'], $id, $message['data'] ?? []); + } + } + } + + private function disconnectClient(int $id): void + { + if (isset($this->clients[$id])) { + @fclose($this->clients[$id]); + unset($this->clients[$id]); + $this->log("Client disconnected (id: {$id}, remaining: " . count($this->clients) . ')'); + $this->dispatch('disconnect', $id, []); + } + } + + /** + * @param array|mixed $data + */ + private function dispatch(string $type, int $clientId, mixed $data): void + { + foreach ($this->handlers[$type] ?? [] as $handler) { + try { + $handler($clientId, $data); + } catch (\Throwable $e) { + $this->log("Handler error [{$type}]: {$e->getMessage()}"); + } + } + } + + // ── WebSocket frame encoding/decoding ───────────────────────────── + + /** + * Decode a WebSocket frame from the client (masked). + * + * @return array{opcode: int, payload: string}|null + */ + private function decodeFrame(string $data): ?array + { + $len = strlen($data); + if ($len < 2) { + return null; + } + + $firstByte = ord($data[0]); + $secondByte = ord($data[1]); + $opcode = $firstByte & 0x0F; + $masked = ($secondByte & 0x80) !== 0; + $payloadLength = $secondByte & 0x7F; + $offset = 2; + + if ($payloadLength === 126) { + if ($len < 4) { + return null; + } + $payloadLength = unpack('n', substr($data, 2, 2)); + if ($payloadLength === false) { + return null; + } + $payloadLength = $payloadLength[1]; + $offset = 4; + } elseif ($payloadLength === 127) { + if ($len < 10) { + return null; + } + $payloadLength = unpack('J', substr($data, 2, 8)); + if ($payloadLength === false) { + return null; + } + $payloadLength = $payloadLength[1]; + $offset = 10; + } + + $mask = ''; + if ($masked) { + if ($len < $offset + 4) { + return null; + } + $mask = substr($data, $offset, 4); + $offset += 4; + } + + if ($len < $offset + $payloadLength) { + return null; + } + + $payload = substr($data, $offset, (int) $payloadLength); + + if ($masked && $mask !== '') { + for ($i = 0; $i < strlen($payload); $i++) { + $payload[$i] = chr(ord($payload[$i]) ^ ord($mask[$i % 4])); + } + } + + return ['opcode' => $opcode, 'payload' => $payload]; + } + + /** + * Encode a WebSocket frame (server -> client, unmasked). + */ + private function encodeFrame(string $payload, int $opcode = 0x01): string + { + $frame = chr(0x80 | $opcode); + $len = strlen($payload); + + if ($len <= 125) { + $frame .= chr($len); + } elseif ($len <= 65535) { + $frame .= chr(126) . pack('n', $len); + } else { + $frame .= chr(127) . pack('J', $len); + } + + return $frame . $payload; + } + + // ── Helpers ────────────────────────────────────────────────────── + + private function shutdown(): void + { + foreach ($this->clients as $id => $client) { + @fclose($client); + } + $this->clients = []; + + if ($this->serverSocket !== null) { + fclose($this->serverSocket); + $this->serverSocket = null; + } + + $this->log('WebSocket server stopped.'); + } + + private function log(string $message): void + { + $time = date('H:i:s'); + fwrite(STDERR, "[{$time}] [WS] {$message}\n"); + } +} diff --git a/src/WorldEditor/WebSocket/ws_server.php b/src/WorldEditor/WebSocket/ws_server.php new file mode 100644 index 0000000..fbd1789 --- /dev/null +++ b/src/WorldEditor/WebSocket/ws_server.php @@ -0,0 +1,29 @@ +run(); diff --git a/src/WorldEditor/WorldEditorRouter.php b/src/WorldEditor/WorldEditorRouter.php new file mode 100644 index 0000000..00f788e --- /dev/null +++ b/src/WorldEditor/WorldEditorRouter.php @@ -0,0 +1,80 @@ +handle($method, $path); + return true; +} + +// Serve static files from dist/ +$filePath = rtrim($distDir, '/') . $path; + +if ($path !== '/' && file_exists($filePath) && is_file($filePath)) { + $ext = pathinfo($filePath, PATHINFO_EXTENSION); + $mimeTypes = [ + 'html' => 'text/html', + 'js' => 'application/javascript', + 'css' => 'text/css', + 'json' => 'application/json', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + ]; + + if (isset($mimeTypes[$ext])) { + header('Content-Type: ' . $mimeTypes[$ext]); + } + + readfile($filePath); + return true; +} + +// SPA fallback — serve index.html +$indexFile = rtrim($distDir, '/') . '/index.html'; +if (file_exists($indexFile)) { + header('Content-Type: text/html'); + readfile($indexFile); + return true; +} + +http_response_code(404); +echo json_encode(['error' => 'Not found']); diff --git a/src/WorldEditor/WorldFile.php b/src/WorldEditor/WorldFile.php new file mode 100644 index 0000000..28555e1 --- /dev/null +++ b/src/WorldEditor/WorldFile.php @@ -0,0 +1,156 @@ + */ + public array $meta = []; + + /** @var array */ + public array $camera = []; + + /** @var array> */ + public array $layers = []; + + /** @var array> */ + public array $lights = []; + + /** @var array> */ + public array $tilesets = []; + + private function __construct() {} + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $world = new self(); + $world->version = $data['version'] ?? '1.0'; + $world->meta = $data['meta'] ?? self::defaultMeta('Untitled'); + $world->camera = $data['camera'] ?? self::defaultCamera(); + $world->layers = $data['layers'] ?? []; + $world->lights = $data['lights'] ?? []; + $world->tilesets = $data['tilesets'] ?? []; + return $world; + } + + public static function create(string $name = 'Untitled'): self + { + $world = new self(); + $world->version = '1.0'; + $world->meta = self::defaultMeta($name); + $world->camera = self::defaultCamera(); + $world->layers = [ + [ + 'id' => 'bg', + 'name' => 'Background', + 'type' => 'tile', + 'visible' => true, + 'locked' => false, + 'tiles' => (object)[], + ], + [ + 'id' => 'entities', + 'name' => 'Entities', + 'type' => 'entity', + 'visible' => true, + 'locked' => false, + 'entities' => [], + ], + ]; + $world->lights = []; + $world->tilesets = []; + return $world; + } + + public static function load(string $path): self + { + if (!file_exists($path)) { + throw new \RuntimeException("World file not found: {$path}"); + } + + $json = file_get_contents($path); + if ($json === false) { + throw new \RuntimeException("Failed to read world file: {$path}"); + } + + $data = json_decode($json, true); + if (!is_array($data)) { + throw new \RuntimeException("Invalid JSON in world file: {$path}"); + } + + return self::fromArray($data); + } + + public function save(string $path): void + { + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $this->meta['modified'] = date('c'); + + $json = json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException("Failed to encode world to JSON"); + } + + if (file_put_contents($path, $json) === false) { + throw new \RuntimeException("Failed to write world file: {$path}"); + } + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'version' => $this->version, + 'meta' => $this->meta, + 'camera' => $this->camera, + 'layers' => $this->layers, + 'lights' => $this->lights, + 'tilesets' => $this->tilesets, + ]; + } + + /** + * @return array> + */ + public function getLayers(): array + { + return $this->layers; + } + + /** + * @return array + */ + private static function defaultMeta(string $name): array + { + $now = date('c'); + return [ + 'name' => $name, + 'type' => '2d_topdown', + 'tileSize' => 32, + 'created' => $now, + 'modified' => $now, + ]; + } + + /** + * @return array + */ + private static function defaultCamera(): array + { + return [ + 'position' => ['x' => 0, 'y' => 0], + 'zoom' => 1.0, + ]; + } +} diff --git a/src/WorldEditor/WorldLoader.php b/src/WorldEditor/WorldLoader.php new file mode 100644 index 0000000..f2382cd --- /dev/null +++ b/src/WorldEditor/WorldLoader.php @@ -0,0 +1,52 @@ +registerComponent(Transform::class); + + foreach ($world->getLayers() as $layer) { + if (($layer['type'] ?? '') !== 'entity') { + continue; + } + + foreach (($layer['entities'] ?? []) as $entityData) { + $entity = $registry->create(); + + $transform = new Transform(); + + $x = (float)($entityData['position']['x'] ?? 0); + $y = (float)($entityData['position']['y'] ?? 0); + $transform->position = new Vec3($x, $y, 0.0); + + $sx = (float)($entityData['scale']['x'] ?? 1.0); + $sy = (float)($entityData['scale']['y'] ?? 1.0); + $transform->scale = new Vec3($sx, $sy, 1.0); + + $rotation = (float)($entityData['rotation'] ?? 0.0); + if ($rotation !== 0.0) { + $q = new Quat(); + $q->rotate(GLM::radians($rotation), new Vec3(0.0, 0.0, 1.0)); + $transform->orientation = $q; + } + + $registry->attach($entity, $transform); + } + } + } +} diff --git a/tests/AI/BT/DecoratorNodeTest.php b/tests/AI/BT/DecoratorNodeTest.php new file mode 100644 index 0000000..5c1fb86 --- /dev/null +++ b/tests/AI/BT/DecoratorNodeTest.php @@ -0,0 +1,78 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + return new BTContext(1, $entities, 0.016); + } + + public function testInverterSuccess(): void + { + $inv = new InverterNode(new ActionNode(fn() => BTStatus::Success)); + $this->assertSame(BTStatus::Failure, $inv->tick($this->makeContext())); + } + + public function testInverterFailure(): void + { + $inv = new InverterNode(new ActionNode(fn() => BTStatus::Failure)); + $this->assertSame(BTStatus::Success, $inv->tick($this->makeContext())); + } + + public function testInverterRunning(): void + { + $inv = new InverterNode(new ActionNode(fn() => BTStatus::Running)); + $this->assertSame(BTStatus::Running, $inv->tick($this->makeContext())); + } + + public function testRepeaterFiniteCount(): void + { + $count = 0; + $rep = new RepeaterNode( + new ActionNode(function () use (&$count) { + $count++; + return BTStatus::Success; + }), + maxRepetitions: 3, + ); + + $ctx = $this->makeContext(); + $this->assertSame(BTStatus::Running, $rep->tick($ctx)); // 1st + $this->assertSame(BTStatus::Running, $rep->tick($ctx)); // 2nd + $this->assertSame(BTStatus::Success, $rep->tick($ctx)); // 3rd, done + $this->assertEquals(3, $count); + } + + public function testRepeaterStopsOnFailure(): void + { + $rep = new RepeaterNode( + new ActionNode(fn() => BTStatus::Failure), + maxRepetitions: 5, + ); + + $this->assertSame(BTStatus::Failure, $rep->tick($this->makeContext())); + } + + public function testSucceederConvertsFailure(): void + { + $succ = new SucceederNode(new ActionNode(fn() => BTStatus::Failure)); + $this->assertSame(BTStatus::Success, $succ->tick($this->makeContext())); + } + + public function testSucceederPassesRunning(): void + { + $succ = new SucceederNode(new ActionNode(fn() => BTStatus::Running)); + $this->assertSame(BTStatus::Running, $succ->tick($this->makeContext())); + } +} diff --git a/tests/AI/BT/LeafNodeTest.php b/tests/AI/BT/LeafNodeTest.php new file mode 100644 index 0000000..71f225d --- /dev/null +++ b/tests/AI/BT/LeafNodeTest.php @@ -0,0 +1,61 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + return new BTContext(1, $entities, 0.016); + } + + public function testActionNodeReturnsCallbackResult(): void + { + $action = new ActionNode(fn() => BTStatus::Success); + $this->assertSame(BTStatus::Success, $action->tick($this->makeContext())); + } + + public function testActionNodeReceivesContext(): void + { + $action = new ActionNode(function (BTContext $ctx) { + $ctx->set('visited', true); + return BTStatus::Success; + }); + + $ctx = $this->makeContext(); + $action->tick($ctx); + $this->assertTrue($ctx->get('visited')); + } + + public function testConditionTrue(): void + { + $cond = new ConditionNode(fn() => true); + $this->assertSame(BTStatus::Success, $cond->tick($this->makeContext())); + } + + public function testConditionFalse(): void + { + $cond = new ConditionNode(fn() => false); + $this->assertSame(BTStatus::Failure, $cond->tick($this->makeContext())); + } + + public function testConditionChecksBlackboard(): void + { + $cond = new ConditionNode(fn(BTContext $ctx) => $ctx->get('health', 0) > 50); + + $ctx = $this->makeContext(); + $ctx->set('health', 100); + $this->assertSame(BTStatus::Success, $cond->tick($ctx)); + + $ctx2 = $this->makeContext(); + $ctx2->set('health', 10); + $this->assertSame(BTStatus::Failure, $cond->tick($ctx2)); + } +} diff --git a/tests/AI/BT/ParallelNodeTest.php b/tests/AI/BT/ParallelNodeTest.php new file mode 100644 index 0000000..8816612 --- /dev/null +++ b/tests/AI/BT/ParallelNodeTest.php @@ -0,0 +1,68 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + return new BTContext(1, $entities, 0.016); + } + + public function testAllSucceed(): void + { + $par = new ParallelNode([ + new ActionNode(fn() => BTStatus::Success), + new ActionNode(fn() => BTStatus::Success), + ]); + + $this->assertSame(BTStatus::Success, $par->tick($this->makeContext())); + } + + public function testPartialSuccess(): void + { + $par = new ParallelNode( + [ + new ActionNode(fn() => BTStatus::Success), + new ActionNode(fn() => BTStatus::Running), + new ActionNode(fn() => BTStatus::Running), + ], + requiredSuccesses: 1, + ); + + $this->assertSame(BTStatus::Success, $par->tick($this->makeContext())); + } + + public function testFailsWhenImpossible(): void + { + $par = new ParallelNode( + [ + new ActionNode(fn() => BTStatus::Failure), + new ActionNode(fn() => BTStatus::Failure), + new ActionNode(fn() => BTStatus::Success), + ], + requiredSuccesses: 2, + ); + + // only 1 success possible (3 - 2 failures = 1), need 2 => failure + $this->assertSame(BTStatus::Failure, $par->tick($this->makeContext())); + } + + public function testRunningWhileInProgress(): void + { + $par = new ParallelNode([ + new ActionNode(fn() => BTStatus::Success), + new ActionNode(fn() => BTStatus::Running), + ]); + + // need 2 successes, have 1 success + 1 running => still possible + $this->assertSame(BTStatus::Running, $par->tick($this->makeContext())); + } +} diff --git a/tests/AI/BT/SelectorNodeTest.php b/tests/AI/BT/SelectorNodeTest.php new file mode 100644 index 0000000..838bfdf --- /dev/null +++ b/tests/AI/BT/SelectorNodeTest.php @@ -0,0 +1,54 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + return new BTContext(1, $entities, 0.016); + } + + public function testSucceedsOnFirstSuccess(): void + { + $called = false; + $sel = new SelectorNode([ + new ActionNode(fn() => BTStatus::Failure), + new ActionNode(fn() => BTStatus::Success), + new ActionNode(function () use (&$called) { + $called = true; + return BTStatus::Success; + }), + ]); + + $this->assertSame(BTStatus::Success, $sel->tick($this->makeContext())); + $this->assertFalse($called); + } + + public function testAllFail(): void + { + $sel = new SelectorNode([ + new ActionNode(fn() => BTStatus::Failure), + new ActionNode(fn() => BTStatus::Failure), + ]); + + $this->assertSame(BTStatus::Failure, $sel->tick($this->makeContext())); + } + + public function testRunning(): void + { + $sel = new SelectorNode([ + new ActionNode(fn() => BTStatus::Failure), + new ActionNode(fn() => BTStatus::Running), + ]); + + $this->assertSame(BTStatus::Running, $sel->tick($this->makeContext())); + } +} diff --git a/tests/AI/BT/SequenceNodeTest.php b/tests/AI/BT/SequenceNodeTest.php new file mode 100644 index 0000000..19dcf36 --- /dev/null +++ b/tests/AI/BT/SequenceNodeTest.php @@ -0,0 +1,87 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + return new BTContext(1, $entities, 0.016); + } + + public function testAllSucceed(): void + { + $seq = new SequenceNode([ + new ActionNode(fn() => BTStatus::Success), + new ActionNode(fn() => BTStatus::Success), + new ActionNode(fn() => BTStatus::Success), + ]); + + $this->assertSame(BTStatus::Success, $seq->tick($this->makeContext())); + } + + public function testFailsOnFirstFailure(): void + { + $called = false; + $seq = new SequenceNode([ + new ActionNode(fn() => BTStatus::Success), + new ActionNode(fn() => BTStatus::Failure), + new ActionNode(function () use (&$called) { + $called = true; + return BTStatus::Success; + }), + ]); + + $this->assertSame(BTStatus::Failure, $seq->tick($this->makeContext())); + $this->assertFalse($called); + } + + public function testRunningPausesExecution(): void + { + $count = 0; + $seq = new SequenceNode([ + new ActionNode(function () use (&$count) { + $count++; + return BTStatus::Success; + }), + new ActionNode(fn() => BTStatus::Running), + new ActionNode(fn() => BTStatus::Success), + ]); + + $this->assertSame(BTStatus::Running, $seq->tick($this->makeContext())); + + // second tick resumes from running child + $this->assertSame(BTStatus::Running, $seq->tick($this->makeContext())); + + // first child was only called once (on first tick), sequence resumes at index 1 + $this->assertEquals(1, $count); + } + + public function testResetClearsState(): void + { + $count = 0; + $action = new ActionNode(function () use (&$count) { + $count++; + return BTStatus::Running; + }); + + $seq = new SequenceNode([ + new ActionNode(fn() => BTStatus::Success), + $action, + ]); + + $seq->tick($this->makeContext()); + $seq->reset(); + $seq->tick($this->makeContext()); + + // after reset, sequence starts from beginning again + $this->assertEquals(2, $count); + } +} diff --git a/tests/AI/BTContextTest.php b/tests/AI/BTContextTest.php new file mode 100644 index 0000000..d5c7132 --- /dev/null +++ b/tests/AI/BTContextTest.php @@ -0,0 +1,33 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + $ctx = new BTContext(42, $entities, 0.016); + + $this->assertNull($ctx->get('missing')); + $this->assertEquals('default', $ctx->get('missing', 'default')); + $this->assertFalse($ctx->has('key')); + + $ctx->set('key', 123); + $this->assertTrue($ctx->has('key')); + $this->assertEquals(123, $ctx->get('key')); + } + + public function testEntityAndDeltaTime(): void + { + $entities = $this->createMock(\VISU\ECS\EntitiesInterface::class); + $ctx = new BTContext(7, $entities, 0.033); + + $this->assertEquals(7, $ctx->entity); + $this->assertEqualsWithDelta(0.033, $ctx->deltaTime, 0.001); + $this->assertSame($entities, $ctx->entities); + } +} diff --git a/tests/AI/Pathfinding/AStarPathfinderTest.php b/tests/AI/Pathfinding/AStarPathfinderTest.php new file mode 100644 index 0000000..5e30035 --- /dev/null +++ b/tests/AI/Pathfinding/AStarPathfinderTest.php @@ -0,0 +1,131 @@ +findPath($grid, 0, 0, 4, 4); + + $this->assertNotNull($path); + $this->assertEquals(0, $path[0]->x); + $this->assertEquals(0, $path[0]->y); + $this->assertEquals(4, $path[count($path) - 1]->x); + $this->assertEquals(4, $path[count($path) - 1]->y); + + // Manhattan path should be 9 steps (4 right + 4 down + start) + $this->assertCount(9, $path); + } + + public function testDiagonalPath(): void + { + $grid = new GridGraph(5, 5, allowDiagonal: true); + $pathfinder = new AStarPathfinder(); + + $path = $pathfinder->findPath($grid, 0, 0, 4, 4); + + $this->assertNotNull($path); + // diagonal should be 5 steps (start + 4 diagonal) + $this->assertCount(5, $path); + } + + public function testPathAroundObstacle(): void + { + // 5x5 grid with a wall across the middle + $grid = new GridGraph(5, 5, allowDiagonal: false); + $grid->setWalkable(0, 2, false); + $grid->setWalkable(1, 2, false); + $grid->setWalkable(2, 2, false); + $grid->setWalkable(3, 2, false); + // gap at (4,2) + + $pathfinder = new AStarPathfinder(); + $path = $pathfinder->findPath($grid, 0, 0, 0, 4); + + $this->assertNotNull($path); + // must go around the wall through (4,2) + $found = false; + foreach ($path as $node) { + if ($node->x === 4 && $node->y === 2) { + $found = true; + break; + } + } + $this->assertTrue($found, 'Path should go through the gap at (4,2)'); + } + + public function testNoPath(): void + { + // completely blocked + $grid = new GridGraph(3, 3, allowDiagonal: false); + $grid->setWalkable(1, 0, false); + $grid->setWalkable(0, 1, false); + $grid->setWalkable(1, 1, false); + + $pathfinder = new AStarPathfinder(); + $path = $pathfinder->findPath($grid, 0, 0, 2, 2); + + $this->assertNull($path); + } + + public function testStartEqualsGoal(): void + { + $grid = new GridGraph(3, 3); + $pathfinder = new AStarPathfinder(); + + $path = $pathfinder->findPath($grid, 1, 1, 1, 1); + + $this->assertNotNull($path); + $this->assertCount(1, $path); + } + + public function testUnwalkableStart(): void + { + $grid = new GridGraph(3, 3); + $grid->setWalkable(0, 0, false); + + $pathfinder = new AStarPathfinder(); + $this->assertNull($pathfinder->findPath($grid, 0, 0, 2, 2)); + } + + public function testUnwalkableGoal(): void + { + $grid = new GridGraph(3, 3); + $grid->setWalkable(2, 2, false); + + $pathfinder = new AStarPathfinder(); + $this->assertNull($pathfinder->findPath($grid, 0, 0, 2, 2)); + } + + public function testOutOfBounds(): void + { + $grid = new GridGraph(3, 3); + $pathfinder = new AStarPathfinder(); + + $this->assertNull($pathfinder->findPath($grid, -1, 0, 2, 2)); + $this->assertNull($pathfinder->findPath($grid, 0, 0, 5, 5)); + } + + public function testNoDiagonalCornerCutting(): void + { + // 3x3 grid with walls creating a corner + $grid = new GridGraph(3, 3, allowDiagonal: true); + $grid->setWalkable(1, 0, false); + $grid->setWalkable(0, 1, false); + + $pathfinder = new AStarPathfinder(); + // (0,0) to (1,1) - diagonal should be blocked by corner cutting prevention + $path = $pathfinder->findPath($grid, 0, 0, 2, 2); + + // Should be null — (0,0) is completely surrounded by walls on the paths + $this->assertNull($path); + } +} diff --git a/tests/AI/Pathfinding/GridGraphTest.php b/tests/AI/Pathfinding/GridGraphTest.php new file mode 100644 index 0000000..bcc55e9 --- /dev/null +++ b/tests/AI/Pathfinding/GridGraphTest.php @@ -0,0 +1,97 @@ +assertEquals(10, $grid->width); + $this->assertEquals(8, $grid->height); + } + + public function testGetNode(): void + { + $grid = new GridGraph(5, 5); + $node = $grid->getNode(2, 3); + + $this->assertNotNull($node); + $this->assertEquals(2, $node->x); + $this->assertEquals(3, $node->y); + $this->assertTrue($node->walkable); + } + + public function testOutOfBounds(): void + { + $grid = new GridGraph(5, 5); + $this->assertNull($grid->getNode(-1, 0)); + $this->assertNull($grid->getNode(5, 0)); + $this->assertNull($grid->getNode(0, 5)); + } + + public function testSetWalkable(): void + { + $grid = new GridGraph(5, 5); + $grid->setWalkable(2, 2, false); + + $node = $grid->getNode(2, 2); + $this->assertNotNull($node); + $this->assertFalse($node->walkable); + } + + public function testCardinalNeighbors(): void + { + $grid = new GridGraph(3, 3, allowDiagonal: false); + $center = $grid->getNode(1, 1); + $this->assertNotNull($center); + + $neighbors = $grid->getNeighbors($center); + $this->assertCount(4, $neighbors); // N, S, W, E + } + + public function testDiagonalNeighbors(): void + { + $grid = new GridGraph(3, 3, allowDiagonal: true); + $center = $grid->getNode(1, 1); + $this->assertNotNull($center); + + $neighbors = $grid->getNeighbors($center); + $this->assertCount(8, $neighbors); // 4 cardinal + 4 diagonal + } + + public function testCornerNeighbors(): void + { + $grid = new GridGraph(3, 3, allowDiagonal: false); + $corner = $grid->getNode(0, 0); + $this->assertNotNull($corner); + + $neighbors = $grid->getNeighbors($corner); + $this->assertCount(2, $neighbors); // only right and down + } + + public function testWalkableNeighborsExcluded(): void + { + $grid = new GridGraph(3, 3, allowDiagonal: false); + $grid->setWalkable(1, 0, false); + $grid->setWalkable(0, 1, false); + + $corner = $grid->getNode(0, 0); + $this->assertNotNull($corner); + + $neighbors = $grid->getNeighbors($corner); + $this->assertCount(0, $neighbors); + } + + public function testIsInBounds(): void + { + $grid = new GridGraph(5, 5); + $this->assertTrue($grid->isInBounds(0, 0)); + $this->assertTrue($grid->isInBounds(4, 4)); + $this->assertFalse($grid->isInBounds(-1, 0)); + $this->assertFalse($grid->isInBounds(5, 0)); + } +} diff --git a/tests/AI/Pathfinding/NavMeshTest.php b/tests/AI/Pathfinding/NavMeshTest.php new file mode 100644 index 0000000..c9eb45a --- /dev/null +++ b/tests/AI/Pathfinding/NavMeshTest.php @@ -0,0 +1,151 @@ +addTriangle( + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + new Vec3(5, 0, 10), + ); + + $this->assertEquals(0, $idx); + $this->assertEquals(1, $mesh->getTriangleCount()); + } + + public function testFindTriangle(): void + { + $mesh = new NavMesh(); + $mesh->addTriangle( + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + new Vec3(5, 0, 10), + ); + + // center of triangle + $tri = $mesh->findTriangle(5.0, 3.0); + $this->assertNotNull($tri); + $this->assertEquals(0, $tri->index); + + // outside triangle + $this->assertNull($mesh->findTriangle(-5.0, -5.0)); + } + + public function testManualConnectivity(): void + { + $mesh = new NavMesh(); + $mesh->addTriangle( + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + new Vec3(5, 0, 10), + ); + $mesh->addTriangle( + new Vec3(10, 0, 0), + new Vec3(20, 0, 0), + new Vec3(5, 0, 10), + ); + + $mesh->connectTriangles(0, 1); + + $tri0 = $mesh->getTriangle(0); + $tri1 = $mesh->getTriangle(1); + $this->assertNotNull($tri0); + $this->assertNotNull($tri1); + $this->assertContains(1, $tri0->neighbors); + $this->assertContains(0, $tri1->neighbors); + } + + public function testAutoConnectivity(): void + { + $mesh = new NavMesh(); + // two triangles sharing an edge (10,0,0)-(5,0,10) + $mesh->addTriangle( + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + new Vec3(5, 0, 10), + ); + $mesh->addTriangle( + new Vec3(10, 0, 0), + new Vec3(15, 0, 10), + new Vec3(5, 0, 10), + ); + + $mesh->buildConnectivity(); + + $tri0 = $mesh->getTriangle(0); + $this->assertNotNull($tri0); + $this->assertContains(1, $tri0->neighbors); + } + + public function testFindPathSameTriangle(): void + { + $mesh = new NavMesh(); + $mesh->addTriangle( + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + new Vec3(5, 0, 10), + ); + + $start = new Vec3(3, 0, 2); + $goal = new Vec3(6, 0, 3); + + $path = $mesh->findPath($start, $goal); + $this->assertNotNull($path); + $this->assertCount(2, $path); // start + goal + } + + public function testFindPathAcrossTriangles(): void + { + $mesh = new NavMesh(); + // strip of 3 triangles + $mesh->addTriangle(new Vec3(0, 0, 0), new Vec3(10, 0, 0), new Vec3(5, 0, 10)); + $mesh->addTriangle(new Vec3(10, 0, 0), new Vec3(20, 0, 0), new Vec3(5, 0, 10)); + $mesh->addTriangle(new Vec3(20, 0, 0), new Vec3(30, 0, 0), new Vec3(25, 0, 10)); + + $mesh->connectTriangles(0, 1); + $mesh->connectTriangles(1, 2); + + $start = new Vec3(3, 0, 2); // in triangle 0 + $goal = new Vec3(25, 0, 3); // in triangle 2 + + $path = $mesh->findPath($start, $goal); + $this->assertNotNull($path); + $this->assertGreaterThanOrEqual(2, count($path)); + + // first and last should be start and goal + $this->assertEqualsWithDelta(3.0, $path[0]->x, 0.001); + $this->assertEqualsWithDelta(25.0, $path[count($path) - 1]->x, 0.001); + } + + public function testNoPathDisconnected(): void + { + $mesh = new NavMesh(); + // two separate triangles, not connected + $mesh->addTriangle(new Vec3(0, 0, 0), new Vec3(10, 0, 0), new Vec3(5, 0, 10)); + $mesh->addTriangle(new Vec3(100, 0, 0), new Vec3(110, 0, 0), new Vec3(105, 0, 10)); + + $start = new Vec3(3, 0, 2); + $goal = new Vec3(105, 0, 3); + + $this->assertNull($mesh->findPath($start, $goal)); + } + + public function testNoPathOutsideMesh(): void + { + $mesh = new NavMesh(); + $mesh->addTriangle(new Vec3(0, 0, 0), new Vec3(10, 0, 0), new Vec3(5, 0, 10)); + + $start = new Vec3(-50, 0, -50); + $goal = new Vec3(3, 0, 2); + + $this->assertNull($mesh->findPath($start, $goal)); + } +} diff --git a/tests/AI/StateMachineTest.php b/tests/AI/StateMachineTest.php new file mode 100644 index 0000000..370a3ec --- /dev/null +++ b/tests/AI/StateMachineTest.php @@ -0,0 +1,144 @@ +createMock(\VISU\ECS\EntitiesInterface::class); + return new BTContext(1, $entities, 0.016); + } + + private function createState(string $name): StateInterface + { + return new class($name) implements StateInterface { + /** @var array */ + public array $log = []; + + public function __construct(private string $name) + { + } + + public function getName(): string + { + return $this->name; + } + + public function onEnter(BTContext $context): void + { + $this->log[] = 'enter'; + } + + public function onUpdate(BTContext $context): void + { + $this->log[] = 'update'; + } + + public function onExit(BTContext $context): void + { + $this->log[] = 'exit'; + } + }; + } + + public function testInitialState(): void + { + $sm = new StateMachine(); + $idle = $this->createState('idle'); + $sm->addState($idle); + $sm->setInitialState('idle'); + + $this->assertSame('idle', $sm->getCurrentStateName()); + } + + public function testUpdateCallsCurrentState(): void + { + $sm = new StateMachine(); + $idle = $this->createState('idle'); + $sm->addState($idle); + $sm->setInitialState('idle'); + + $sm->update($this->makeContext()); + /** @phpstan-ignore-next-line */ + $this->assertEquals(['update'], $idle->log); + } + + public function testTransition(): void + { + $sm = new StateMachine(); + $idle = $this->createState('idle'); + $chase = $this->createState('chase'); + $sm->addState($idle); + $sm->addState($chase); + + $sm->addTransition(new StateTransition( + 'idle', + 'chase', + fn(BTContext $ctx) => $ctx->get('enemy_near', false) === true, + )); + + $sm->setInitialState('idle'); + + // no transition yet + $ctx = $this->makeContext(); + $sm->update($ctx); + $this->assertSame('idle', $sm->getCurrentStateName()); + + // trigger transition + $ctx2 = $this->makeContext(); + $ctx2->set('enemy_near', true); + $sm->update($ctx2); + $this->assertSame('chase', $sm->getCurrentStateName()); + + /** @phpstan-ignore-next-line */ + $this->assertContains('exit', $idle->log); + /** @phpstan-ignore-next-line */ + $this->assertContains('enter', $chase->log); + } + + public function testForceTransition(): void + { + $sm = new StateMachine(); + $idle = $this->createState('idle'); + $dead = $this->createState('dead'); + $sm->addState($idle); + $sm->addState($dead); + $sm->setInitialState('idle'); + + $sm->forceTransition('dead', $this->makeContext()); + $this->assertSame('dead', $sm->getCurrentStateName()); + + /** @phpstan-ignore-next-line */ + $this->assertContains('exit', $idle->log); + /** @phpstan-ignore-next-line */ + $this->assertContains('enter', $dead->log); + } + + public function testInvalidStateThrows(): void + { + $sm = new StateMachine(); + $this->expectException(\InvalidArgumentException::class); + $sm->setInitialState('nonexistent'); + } + + public function testForceTransitionInvalidThrows(): void + { + $sm = new StateMachine(); + $this->expectException(\InvalidArgumentException::class); + $sm->forceTransition('nonexistent', $this->makeContext()); + } + + public function testNullStateDoesNothing(): void + { + $sm = new StateMachine(); + $sm->update($this->makeContext()); // no crash + $this->assertNull($sm->getCurrentStateName()); + } +} diff --git a/tests/Audio/AudioClipDataTest.php b/tests/Audio/AudioClipDataTest.php new file mode 100644 index 0000000..177250c --- /dev/null +++ b/tests/Audio/AudioClipDataTest.php @@ -0,0 +1,27 @@ +assertSame(1024, $clip->getByteLength()); + $this->assertSame(44100, $clip->sampleRate); + $this->assertSame(2, $clip->channels); + $this->assertSame(16, $clip->bitsPerSample); + $this->assertSame('/test.wav', $clip->sourcePath); + } + + public function testEmptyClip(): void + { + $clip = new AudioClipData('', 22050, 1, 8, '/empty.wav'); + $this->assertSame(0, $clip->getByteLength()); + } +} diff --git a/tests/Audio/AudioManagerTest.php b/tests/Audio/AudioManagerTest.php new file mode 100644 index 0000000..4f9ce19 --- /dev/null +++ b/tests/Audio/AudioManagerTest.php @@ -0,0 +1,211 @@ + */ + public array $calls = []; + public int $streamQueuedReturn = 99999; + + public function loadWav(string $path): AudioClipData + { + $this->calls['loadWav'] = ($this->calls['loadWav'] ?? 0) + 1; + return new AudioClipData(str_repeat("\x00", 512), 44100, 2, 16, $path); + } + + public function play(AudioClipData $clip, float $volume = 1.0): void + { + $this->calls['play'] = ($this->calls['play'] ?? 0) + 1; + } + + public function streamStart(AudioClipData $clip): int + { + $this->calls['streamStart'] = ($this->calls['streamStart'] ?? 0) + 1; + return 1; + } + + public function streamQueued(int $handle): int + { + $this->calls['streamQueued'] = ($this->calls['streamQueued'] ?? 0) + 1; + return $this->streamQueuedReturn; + } + + public function streamEnqueue(int $handle, AudioClipData $clip): void + { + $this->calls['streamEnqueue'] = ($this->calls['streamEnqueue'] ?? 0) + 1; + } + + public function streamSetVolume(int $handle, float $volume): void + { + $this->calls['streamSetVolume'] = ($this->calls['streamSetVolume'] ?? 0) + 1; + } + + public function streamStop(int $handle): void + { + $this->calls['streamStop'] = ($this->calls['streamStop'] ?? 0) + 1; + } + + public function shutdown(): void + { + $this->calls['shutdown'] = ($this->calls['shutdown'] ?? 0) + 1; + } + + public function getName(): string + { + return 'Mock'; + } + + public static function isAvailable(): bool + { + return true; + } + }; + } + + public function testBackendName(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $this->assertSame('Mock', $manager->getBackendName()); + } + + public function testPlaySound(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->playSound('/sfx/boom.wav'); + + $this->assertSame(1, $backend->calls['loadWav']); + $this->assertSame(1, $backend->calls['play']); + } + + public function testClipCaching(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->loadClip('/sfx/boom.wav'); + $manager->loadClip('/sfx/boom.wav'); + $manager->loadClip('/sfx/boom.wav'); + + // loadWav should only be called once due to caching + $this->assertSame(1, $backend->calls['loadWav']); + } + + public function testPlayMusic(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->playMusic('/music/theme.wav'); + + $this->assertTrue($manager->isMusicPlaying()); + $this->assertSame(1, $backend->calls['streamStart']); + } + + public function testStopMusic(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->playMusic('/music/theme.wav'); + $manager->stopMusic(); + + $this->assertFalse($manager->isMusicPlaying()); + $this->assertSame(1, $backend->calls['streamStop']); + } + + public function testMusicLooping(): void + { + $backend = $this->createMockBackend(); + $backend->streamQueuedReturn = 0; // Buffer empty -> should re-enqueue + $manager = new AudioManager($backend); + + $manager->playMusic('/music/theme.wav'); + $manager->update(); + + $this->assertSame(1, $backend->calls['streamEnqueue'] ?? 0); + } + + public function testMusicNoRequeueWhenBuffered(): void + { + $backend = $this->createMockBackend(); + $backend->streamQueuedReturn = 99999; // Buffer full -> no re-enqueue + $manager = new AudioManager($backend); + + $manager->playMusic('/music/theme.wav'); + $manager->update(); + + $this->assertSame(0, $backend->calls['streamEnqueue'] ?? 0); + } + + public function testChannelVolume(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $this->assertSame(1.0, $manager->getChannelVolume(AudioChannel::SFX)); + + $manager->setChannelVolume(AudioChannel::SFX, 0.5); + $this->assertSame(0.5, $manager->getChannelVolume(AudioChannel::SFX)); + + // Clamping + $manager->setChannelVolume(AudioChannel::Music, 2.0); + $this->assertSame(1.0, $manager->getChannelVolume(AudioChannel::Music)); + + $manager->setChannelVolume(AudioChannel::Music, -1.0); + $this->assertSame(0.0, $manager->getChannelVolume(AudioChannel::Music)); + } + + public function testUnloadClip(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->loadClip('/sfx/boom.wav'); + $manager->unloadClip('/sfx/boom.wav'); + $manager->loadClip('/sfx/boom.wav'); + + // Should call loadWav twice since cache was cleared for that path + $this->assertSame(2, $backend->calls['loadWav']); + } + + public function testClearCache(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->loadClip('/sfx/a.wav'); + $manager->loadClip('/sfx/b.wav'); + $manager->clearCache(); + $manager->loadClip('/sfx/a.wav'); + + $this->assertSame(3, $backend->calls['loadWav']); + } + + public function testPlayMusicStopsPrevious(): void + { + $backend = $this->createMockBackend(); + $manager = new AudioManager($backend); + + $manager->playMusic('/music/a.wav'); + $manager->playMusic('/music/b.wav'); + + // First stream should have been stopped + $this->assertSame(1, $backend->calls['streamStop']); + // Two streams started + $this->assertSame(2, $backend->calls['streamStart']); + } +} diff --git a/tests/Audio/Mp3DecoderTest.php b/tests/Audio/Mp3DecoderTest.php new file mode 100644 index 0000000..2162bf3 --- /dev/null +++ b/tests/Audio/Mp3DecoderTest.php @@ -0,0 +1,75 @@ +getMessage()); + } + } + + private function getFixturePath(string $name): string + { + return __DIR__ . '/fixtures/' . $name; + } + + public function testDecodeSilenceMp3(): void + { + $clip = self::$decoder->decode($this->getFixturePath('silence.mp3')); + + $this->assertGreaterThan(0, $clip->sampleRate); + $this->assertGreaterThan(0, $clip->channels); + $this->assertSame(16, $clip->bitsPerSample); + $this->assertGreaterThan(0, $clip->getByteLength()); + $this->assertStringContainsString('silence.mp3', $clip->sourcePath); + } + + public function testDecodeBufferFromString(): void + { + $data = file_get_contents($this->getFixturePath('silence.mp3')); + $clip = self::$decoder->decodeBuffer($data, 'test.mp3'); + + $this->assertGreaterThan(0, $clip->sampleRate); + $this->assertGreaterThan(0, $clip->channels); + $this->assertSame(16, $clip->bitsPerSample); + $this->assertSame('test.mp3', $clip->sourcePath); + } + + public function testDecodeProducesPcmData(): void + { + $clip = self::$decoder->decode($this->getFixturePath('silence.mp3')); + + // ~0.5s of audio, MP3 codec may add padding so allow wide tolerance + $expectedBytes = $clip->sampleRate * $clip->channels * 2 * 0.5; + $this->assertGreaterThan($expectedBytes * 0.3, $clip->getByteLength()); + $this->assertLessThan($expectedBytes * 2.0, $clip->getByteLength()); + } + + public function testDecodeInvalidFileThrows(): void + { + $this->expectException(\RuntimeException::class); + self::$decoder->decode('/nonexistent/file.mp3'); + } + + public function testDecodeEmptyBufferThrows(): void + { + $this->expectException(\RuntimeException::class); + self::$decoder->decodeBuffer('', 'empty.mp3'); + } + + public function testDecodeGarbageDataThrows(): void + { + $this->expectException(\RuntimeException::class); + self::$decoder->decodeBuffer(str_repeat("\x00", 100), 'garbage.mp3'); + } +} diff --git a/tests/Audio/WavParserTest.php b/tests/Audio/WavParserTest.php new file mode 100644 index 0000000..11f326c --- /dev/null +++ b/tests/Audio/WavParserTest.php @@ -0,0 +1,106 @@ +tempDir = sys_get_temp_dir() . '/visu_wav_test_' . uniqid(); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + array_map('unlink', glob($this->tempDir . '/*') ?: []); + rmdir($this->tempDir); + } + + private function createWavFile(int $sampleRate = 44100, int $channels = 2, int $bitsPerSample = 16, int $dataBytes = 1024): string + { + $byteRate = $sampleRate * $channels * ($bitsPerSample / 8); + $blockAlign = $channels * ($bitsPerSample / 8); + $pcmData = str_repeat("\x00", $dataBytes); + + $fmt = pack('vvVVvv', + 1, // PCM format + $channels, + $sampleRate, + (int) $byteRate, + (int) $blockAlign, + $bitsPerSample + ); + + $fmtChunk = 'fmt ' . pack('V', strlen($fmt)) . $fmt; + $dataChunk = 'data' . pack('V', strlen($pcmData)) . $pcmData; + + $riff = 'RIFF' . pack('V', 4 + strlen($fmtChunk) + strlen($dataChunk)) . 'WAVE' . $fmtChunk . $dataChunk; + + $path = $this->tempDir . '/test.wav'; + file_put_contents($path, $riff); + return $path; + } + + public function testParseValidWav(): void + { + $path = $this->createWavFile(44100, 2, 16, 1024); + + // Use reflection to test the private parseWavFile method + $method = new \ReflectionMethod(OpenALAudioBackend::class, 'parseWavFile'); + $method->setAccessible(true); + + $clip = $method->invoke(null, $path); + + $this->assertSame(44100, $clip->sampleRate); + $this->assertSame(2, $clip->channels); + $this->assertSame(16, $clip->bitsPerSample); + $this->assertSame(1024, $clip->getByteLength()); + $this->assertSame($path, $clip->sourcePath); + } + + public function testParseMono8bit(): void + { + $path = $this->createWavFile(22050, 1, 8, 512); + + $method = new \ReflectionMethod(OpenALAudioBackend::class, 'parseWavFile'); + $method->setAccessible(true); + + $clip = $method->invoke(null, $path); + + $this->assertSame(22050, $clip->sampleRate); + $this->assertSame(1, $clip->channels); + $this->assertSame(8, $clip->bitsPerSample); + $this->assertSame(512, $clip->getByteLength()); + } + + public function testRejectsNonWavFile(): void + { + $path = $this->tempDir . '/bad.wav'; + file_put_contents($path, 'NOT A WAV FILE AT ALL WITH ENOUGH BYTES TO PASS SIZE CHECK!!!!'); + + $method = new \ReflectionMethod(OpenALAudioBackend::class, 'parseWavFile'); + $method->setAccessible(true); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Not a valid WAV file'); + $method->invoke(null, $path); + } + + public function testRejectsTooSmallFile(): void + { + $path = $this->tempDir . '/tiny.wav'; + file_put_contents($path, 'RIFF'); + + $method = new \ReflectionMethod(OpenALAudioBackend::class, 'parseWavFile'); + $method->setAccessible(true); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('WAV file too small'); + $method->invoke(null, $path); + } +} diff --git a/tests/Audio/fixtures/silence.mp3 b/tests/Audio/fixtures/silence.mp3 new file mode 100644 index 0000000..a743903 Binary files /dev/null and b/tests/Audio/fixtures/silence.mp3 differ diff --git a/tests/Benchmark/AnimationBench.php b/tests/Benchmark/AnimationBench.php new file mode 100644 index 0000000..24efebb --- /dev/null +++ b/tests/Benchmark/AnimationBench.php @@ -0,0 +1,81 @@ +translationChannel = new AnimationChannel( + 0, 'translation', AnimationInterpolation::Linear, $times, $values + ); + + // rotation channel with 60 keyframes + $rotValues = []; + for ($i = 0; $i < 60; $i++) { + $t = $i / 60.0; + $rotValues[] = new Quat(0.0, sin($t), 0.0, cos($t)); + } + $this->rotationChannel = new AnimationChannel( + 0, 'rotation', AnimationInterpolation::Linear, $times, $rotValues + ); + + // skeleton with 50 bones (linear chain) + $this->skeleton = new Skeleton(); + for ($i = 0; $i < 50; $i++) { + $this->skeleton->addBone(new Bone($i, "bone_{$i}", $i > 0 ? $i - 1 : -1)); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchSampleTranslation60Keyframes(): void + { + $this->translationChannel->sample(0.5); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchSampleRotationSlerp60Keyframes(): void + { + $this->rotationChannel->sample(0.5); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchSkeletonLookup50Bones(): void + { + for ($i = 0; $i < 50; $i++) { + $this->skeleton->getBoneByName("bone_{$i}"); + } + } +} diff --git a/tests/Benchmark/BehaviourTreeBench.php b/tests/Benchmark/BehaviourTreeBench.php new file mode 100644 index 0000000..9baa98f --- /dev/null +++ b/tests/Benchmark/BehaviourTreeBench.php @@ -0,0 +1,86 @@ +context = new BTContext(1, $entities, 0.016); + $this->context->set('health', 80); + $this->context->set('enemy_near', true); + $this->context->set('ammo', 10); + + // shallow tree: 10 actions in sequence + $actions = []; + for ($i = 0; $i < 10; $i++) { + $actions[] = new ActionNode(fn() => BTStatus::Success); + } + $this->shallowTree = new SequenceNode($actions); + + // deep tree: nested selectors with conditions + $this->deepTree = new SelectorNode([ + new SequenceNode([ + new ConditionNode(fn(BTContext $ctx) => $ctx->get('health') < 20), + new ActionNode(fn() => BTStatus::Success), // flee + ]), + new SequenceNode([ + new ConditionNode(fn(BTContext $ctx) => $ctx->get('enemy_near') === true), + new ConditionNode(fn(BTContext $ctx) => $ctx->get('ammo') > 0), + new ActionNode(fn(BTContext $ctx) => BTStatus::Success), // attack + ]), + new SequenceNode([ + new ConditionNode(fn(BTContext $ctx) => $ctx->get('enemy_near') === true), + new ActionNode(fn() => BTStatus::Success), // melee + ]), + new ActionNode(fn() => BTStatus::Success), // patrol + ]); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchShallowTree10Nodes(): void + { + $this->shallowTree->tick($this->context); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchDeepTreeWithConditions(): void + { + $this->deepTree->tick($this->context); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchBlackboardReadWrite(): void + { + for ($i = 0; $i < 100; $i++) { + $this->context->set('key_' . $i, $i * 1.5); + $this->context->get('key_' . $i); + } + } +} diff --git a/tests/Benchmark/CollisionBench.php b/tests/Benchmark/CollisionBench.php new file mode 100644 index 0000000..2a8e0f8 --- /dev/null +++ b/tests/Benchmark/CollisionBench.php @@ -0,0 +1,75 @@ + */ + private array $aabbs; + private Ray $ray; + private AABB $testAABB; + + public function setUp(): void + { + $this->aabbs = []; + for ($i = 0; $i < 1000; $i++) { + $x = ($i % 32) * 3.0; + $z = (int)($i / 32) * 3.0; + $this->aabbs[] = new AABB( + new Vec3($x - 1, -1, $z - 1), + new Vec3($x + 1, 1, $z + 1), + ); + } + + $this->ray = new Ray( + new Vec3(0, 0.5, 0), + new Vec3(1, 0.1, 0.5), + ); + + $this->testAABB = new AABB( + new Vec3(-1, -1, -1), + new Vec3(1, 1, 1), + ); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchAABBvsAABB1000(): void + { + foreach ($this->aabbs as $aabb) { + $this->testAABB->intersects($aabb); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchRayVsAABB1000(): void + { + foreach ($this->aabbs as $aabb) { + $aabb->intersectRayDistance($this->ray); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchAABBContainsPoint(): void + { + $point = new Vec3(0.5, 0.5, 0.5); + for ($i = 0; $i < 100; $i++) { + $this->testAABB->contains($point); + } + } +} diff --git a/tests/Benchmark/DialogueBench.php b/tests/Benchmark/DialogueBench.php new file mode 100644 index 0000000..daf571d --- /dev/null +++ b/tests/Benchmark/DialogueBench.php @@ -0,0 +1,112 @@ + */ + private array $treeData; + + public function setUp(): void + { + $this->manager = new DialogueManager(); + + // build a dialogue tree with 50 nodes, branching paths + $nodes = []; + for ($i = 0; $i < 50; $i++) { + $node = [ + 'id' => "node_{$i}", + 'speaker' => 'NPC', + 'text' => "This is dialogue node {$i} with {player_name} interpolation.", + ]; + + if ($i < 49 && $i % 5 === 0) { + // branching node with 3 choices + $node['choices'] = [ + ['text' => 'Option A', 'next' => 'node_' . ($i + 1)], + ['text' => 'Option B', 'next' => 'node_' . ($i + 2), 'condition' => 'has_key'], + ['text' => 'Option C', 'next' => 'node_' . ($i + 3), 'actions' => [['type' => 'set', 'target' => 'visited', 'value' => true]]], + ]; + } elseif ($i < 49) { + $node['next'] = 'node_' . ($i + 1); + } + + if ($i % 10 === 0) { + $node['actions'] = [['type' => 'add', 'target' => 'xp', 'value' => 10]]; + } + + $nodes[] = $node; + } + + $this->treeData = [ + 'id' => 'bench_dialogue', + 'start' => 'node_0', + 'nodes' => $nodes, + ]; + + $tree = DialogueTree::fromArray($this->treeData); + $this->manager->registerTree($tree); + $this->manager->setVariable('player_name', 'TestPlayer'); + $this->manager->setVariable('has_key', true); + $this->manager->setVariable('xp', 0); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchParseDialogueTree50Nodes(): void + { + DialogueTree::fromArray($this->treeData); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchAdvanceThroughDialogue(): void + { + $this->manager->startDialogue('bench_dialogue'); + for ($i = 0; $i < 10; $i++) { + $node = $this->manager->getActiveNode(); + if ($node === null) break; + + if (count($node->choices) > 0) { + $this->manager->selectChoice(0); + } else { + $this->manager->advance(); + } + } + $this->manager->endDialogue(); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchConditionEvaluation(): void + { + $this->manager->evaluateCondition('has_key'); + $this->manager->evaluateCondition('!missing_var'); + $this->manager->evaluateCondition('xp >= 10'); + $this->manager->evaluateCondition('player_name == TestPlayer'); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchTextInterpolation(): void + { + $this->manager->interpolateText('Hello {player_name}, you have {xp} XP and {missing} items.'); + } +} diff --git a/tests/Benchmark/ECSBench.php b/tests/Benchmark/ECSBench.php new file mode 100644 index 0000000..6be5ffb --- /dev/null +++ b/tests/Benchmark/ECSBench.php @@ -0,0 +1,61 @@ +registry = new \VISU\ECS\EntityRegistry(); + $this->registry->registerComponent(Transform::class); + + // pre-populate with 10k entities + for ($i = 0; $i < 10000; $i++) { + $entity = $this->registry->create(); + $this->registry->attach($entity, new Transform()); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchCreateEntity(): void + { + for ($i = 0; $i < 1000; $i++) { + $this->registry->create(); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchIterateComponents10k(): void + { + foreach ($this->registry->view(Transform::class) as $entity => $transform) { + $transform->markDirty(); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchAttachDetach(): void + { + $entity = $this->registry->create(); + for ($i = 0; $i < 1000; $i++) { + $this->registry->attach($entity, new Transform()); + $this->registry->detach($entity, Transform::class); + } + } +} diff --git a/tests/Benchmark/GLConetxtBenchmark.php b/tests/Benchmark/GLContextBenchmark.php similarity index 100% rename from tests/Benchmark/GLConetxtBenchmark.php rename to tests/Benchmark/GLContextBenchmark.php diff --git a/tests/Benchmark/ParticlePoolBench.php b/tests/Benchmark/ParticlePoolBench.php new file mode 100644 index 0000000..6fe3141 --- /dev/null +++ b/tests/Benchmark/ParticlePoolBench.php @@ -0,0 +1,69 @@ +pool = new ParticlePool(10000); + } + + public function setUpFull(): void + { + $this->pool = new ParticlePool(10000); + for ($i = 0; $i < 10000; $i++) { + $this->pool->emit( + (float)($i % 100), 0.0, (float)(intdiv($i, 100) % 100), + 0.0, 1.0, 0.0, + 1.0, 1.0, 1.0, 1.0, + 0.0, 0.0, 0.0, 0.0, + 1.0, 0.5, + 2.0, + ); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchEmit10kParticles(): void + { + for ($i = 0; $i < 10000; $i++) { + $this->pool->emit( + 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 1.0, 1.0, 1.0, 1.0, + 0.0, 0.0, 0.0, 0.0, + 1.0, 0.5, + 2.0, + ); + } + } + + /** + * @BeforeMethods({"setUpFull"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchSimulate10kParticles(): void + { + $this->pool->simulate(0.016, 1.0, 0.0); + } + + /** + * @BeforeMethods({"setUpFull"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchBuildInstanceBuffer10k(): void + { + $this->pool->buildInstanceBuffer(); + } +} diff --git a/tests/Benchmark/PathfindingBench.php b/tests/Benchmark/PathfindingBench.php new file mode 100644 index 0000000..0747c48 --- /dev/null +++ b/tests/Benchmark/PathfindingBench.php @@ -0,0 +1,116 @@ +pathfinder = new AStarPathfinder(); + + // 20x20 open grid + $this->smallGrid = new GridGraph(20, 20); + + // 100x100 with wall + $this->largeGrid = new GridGraph(100, 100); + for ($i = 10; $i < 90; $i++) { + $this->largeGrid->setWalkable($i, 50, false); + } + + // 50x50 maze-like pattern + $this->mazeGrid = new GridGraph(50, 50); + for ($y = 0; $y < 50; $y += 4) { + for ($x = 0; $x < 48; $x++) { + $this->mazeGrid->setWalkable($x, $y, false); + } + } + for ($y = 2; $y < 50; $y += 4) { + for ($x = 2; $x < 50; $x++) { + $this->mazeGrid->setWalkable($x, $y, false); + } + } + + // NavMesh: 10x10 grid of triangles (200 triangles) + $this->navMesh = new NavMesh(); + for ($z = 0; $z < 10; $z++) { + for ($x = 0; $x < 10; $x++) { + $fx = (float)$x * 10.0; + $fz = (float)$z * 10.0; + $this->navMesh->addTriangle( + new Vec3($fx, 0, $fz), + new Vec3($fx + 10, 0, $fz), + new Vec3($fx, 0, $fz + 10), + ); + $this->navMesh->addTriangle( + new Vec3($fx + 10, 0, $fz), + new Vec3($fx + 10, 0, $fz + 10), + new Vec3($fx, 0, $fz + 10), + ); + } + } + $this->navMesh->buildConnectivity(); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchAStarSmallGrid20x20(): void + { + $this->pathfinder->findPath($this->smallGrid, 0, 0, 19, 19); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchAStarLargeGrid100x100(): void + { + $this->pathfinder->findPath($this->largeGrid, 0, 0, 99, 99); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchAStarMaze50x50(): void + { + $this->pathfinder->findPath($this->mazeGrid, 0, 0, 49, 49); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchNavMeshFindPath(): void + { + $start = new Vec3(5, 0, 5); + $goal = new Vec3(95, 0, 95); + $this->navMesh->findPath($start, $goal); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchNavMeshFindTriangle(): void + { + $this->navMesh->findTriangle(55.0, 55.0); + } +} diff --git a/tests/Benchmark/ShaderProgramUniformMat4Bench.php b/tests/Benchmark/ShaderProgramUniformMat4Bench.php index a9ffb04..c20c499 100644 --- a/tests/Benchmark/ShaderProgramUniformMat4Bench.php +++ b/tests/Benchmark/ShaderProgramUniformMat4Bench.php @@ -69,18 +69,19 @@ public function benchUnsafeSettersBuffer() */ public function benchUnsafeSettersArray() { - $matrix = [ + $buffer = new FloatBuffer(); + $buffer->pushArray([ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, - ]; + ]); $this->shader->use(); $uniformLoc = $this->shader->getUniformLocation('somemat'); for ($i = 0; $i < 1000; $i++) { - $this->shader->unsafeSetUniformMatrix4fv("somemat", false, $matrix); + $this->shader->unsafeSetUniformMatrix4fv("somemat", false, $buffer); } } diff --git a/tests/Benchmark/ShadowBench.php b/tests/Benchmark/ShadowBench.php new file mode 100644 index 0000000..4553495 --- /dev/null +++ b/tests/Benchmark/ShadowBench.php @@ -0,0 +1,154 @@ + */ + private array $faceDirections; + + public function setUp(): void + { + // CSM data setup + $this->csmData = new ShadowMapData(); + $this->csmData->cascadeCount = 4; + $this->csmData->cascadeSplits = [10.0, 30.0, 70.0, 200.0]; + for ($i = 0; $i < 4; $i++) { + $this->csmData->lightSpaceMatrices[$i] = new Mat4(); + } + + // Point shadow data setup + $this->pointShadowData = new PointLightShadowData(); + $this->pointShadowData->resolution = 512; + $this->pointShadowData->shadowLightCount = 4; + for ($i = 0; $i < 4; $i++) { + $this->pointShadowData->cubemapTextureIds[$i] = 100 + $i; + $this->pointShadowData->farPlanes[$i] = 20.0 + $i * 10.0; + $this->pointShadowData->lightPositions[$i] = new Vec3($i * 5.0, 3.0, 0.0); + } + + // Cubemap face directions (same as PointLightShadowPass) + $this->faceDirections = [ + [new Vec3(1, 0, 0), new Vec3(0, -1, 0)], + [new Vec3(-1, 0, 0), new Vec3(0, -1, 0)], + [new Vec3(0, 1, 0), new Vec3(0, 0, 1)], + [new Vec3(0, -1, 0), new Vec3(0, 0, -1)], + [new Vec3(0, 0, 1), new Vec3(0, -1, 0)], + [new Vec3(0, 0, -1), new Vec3(0, -1, 0)], + ]; + } + + /** + * Benchmark cascade selection by view-space depth (done per-pixel in shader, simulated here). + * + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchCascadeSelection(): void + { + $splits = $this->csmData->cascadeSplits; + $cascadeCount = $this->csmData->cascadeCount; + + // Simulate 100 fragments at varying depths + for ($f = 0; $f < 100; $f++) { + $depth = $f * 2.5; // 0..250 range + $cascadeIndex = $cascadeCount - 1; + for ($i = 0; $i < $cascadeCount; $i++) { + if ($depth < $splits[$i]) { + $cascadeIndex = $i; + break; + } + } + } + } + + /** + * Benchmark cubemap face view matrix computation (done per-light per-frame). + * + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchCubemapFaceMatrices(): void + { + $lightPos = new Vec3(5.0, 3.0, 0.0); + $farPlane = 25.0; + $projection = new Mat4(); + $projection->perspective(GLM::radians(90.0), 1.0, 0.1, $farPlane); + + // 6 faces per light + for ($face = 0; $face < 6; $face++) { + $target = new Vec3( + $lightPos->x + $this->faceDirections[$face][0]->x, + $lightPos->y + $this->faceDirections[$face][0]->y, + $lightPos->z + $this->faceDirections[$face][0]->z, + ); + $view = new Mat4(); + $view->lookAt($lightPos, $target, $this->faceDirections[$face][1]); + /** @var Mat4 $lightSpace */ + $lightSpace = $projection * $view; + } + } + + /** + * Benchmark computing all face matrices for 4 shadow-casting point lights. + * + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchAllPointLightMatrices(): void + { + for ($li = 0; $li < $this->pointShadowData->shadowLightCount; $li++) { + $lightPos = $this->pointShadowData->lightPositions[$li]; + $farPlane = $this->pointShadowData->farPlanes[$li]; + + $projection = new Mat4(); + $projection->perspective(GLM::radians(90.0), 1.0, 0.1, $farPlane); + + for ($face = 0; $face < 6; $face++) { + $target = new Vec3( + $lightPos->x + $this->faceDirections[$face][0]->x, + $lightPos->y + $this->faceDirections[$face][0]->y, + $lightPos->z + $this->faceDirections[$face][0]->z, + ); + $view = new Mat4(); + $view->lookAt($lightPos, $target, $this->faceDirections[$face][1]); + /** @var Mat4 $lightSpace */ + $lightSpace = $projection * $view; + } + } + } + + /** + * Benchmark point shadow lookup simulation (finding which shadow map matches a light index). + * + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchShadowLightMapping(): void + { + // Simulate mapping 32 point lights to shadow indices (as done in shader) + $shadowMapping = [0 => 0, 1 => 3, 2 => 5, 3 => 7]; + $numShadows = 4; + + for ($i = 0; $i < 32; $i++) { + $hasShadow = false; + for ($s = 0; $s < $numShadows; $s++) { + if ($shadowMapping[$s] === $i) { + $hasShadow = true; + break; + } + } + } + } +} diff --git a/tests/Benchmark/SignalBench.php b/tests/Benchmark/SignalBench.php new file mode 100644 index 0000000..bbb3df6 --- /dev/null +++ b/tests/Benchmark/SignalBench.php @@ -0,0 +1,47 @@ +dispatcher = new Dispatcher(); + $this->signal = new Signal('test.event'); + + // register 10 handlers + for ($i = 0; $i < 10; $i++) { + $this->dispatcher->register('test.event', function (Signal $signal) { + // noop handler + }); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchDispatch10Handlers(): void + { + $this->dispatcher->dispatch('test.event', $this->signal); + } + + /** + * @Revs(10000) + * @Iterations(5) + */ + public function benchRegisterHandler(): void + { + $d = new Dispatcher(); + for ($i = 0; $i < 100; $i++) { + $d->register('event_' . $i, function () {}); + } + } +} diff --git a/tests/Benchmark/StateMachineBench.php b/tests/Benchmark/StateMachineBench.php new file mode 100644 index 0000000..d3e2101 --- /dev/null +++ b/tests/Benchmark/StateMachineBench.php @@ -0,0 +1,68 @@ +fsm = new StateMachine(); + + $states = ['idle', 'patrol', 'chase', 'attack', 'flee']; + foreach ($states as $name) { + $this->fsm->addState(new class($name) implements StateInterface { + public function __construct(private string $name) {} + public function getName(): string { return $this->name; } + public function onEnter(BTContext $context): void {} + public function onUpdate(BTContext $context): void {} + public function onExit(BTContext $context): void {} + }); + } + + // transitions that won't fire + $this->fsm->addTransition(new StateTransition('idle', 'patrol', fn(BTContext $ctx) => $ctx->get('should_patrol') === true)); + $this->fsm->addTransition(new StateTransition('patrol', 'chase', fn(BTContext $ctx) => $ctx->get('enemy_near') === true)); + $this->fsm->addTransition(new StateTransition('chase', 'attack', fn(BTContext $ctx) => $ctx->get('in_range') === true)); + $this->fsm->addTransition(new StateTransition('attack', 'flee', fn(BTContext $ctx) => $ctx->get('health', 100) < 20)); + $this->fsm->addTransition(new StateTransition('flee', 'idle', fn(BTContext $ctx) => $ctx->get('safe') === true)); + $this->fsm->setInitialState('idle'); + + $this->contextNoTransition = new BTContext(1, $entities, 0.016); + $this->contextWithTransition = new BTContext(1, $entities, 0.016); + $this->contextWithTransition->set('should_patrol', true); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchUpdateNoTransition(): void + { + $this->fsm->update($this->contextNoTransition); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(10000) + * @Iterations(5) + */ + public function benchUpdateWithTransition(): void + { + $this->fsm->update($this->contextWithTransition); + // reset back to idle for next rev + $this->fsm->setInitialState('idle'); + } +} diff --git a/tests/Benchmark/TerrainDataBench.php b/tests/Benchmark/TerrainDataBench.php new file mode 100644 index 0000000..79c8fbc --- /dev/null +++ b/tests/Benchmark/TerrainDataBench.php @@ -0,0 +1,77 @@ +smallTerrain = new TerrainData($heights, 64, 64, 100.0, 100.0, 20.0); + + // 256x256 terrain + $heights = []; + for ($i = 0; $i < 256 * 256; $i++) { + $heights[] = sin($i * 0.01) * cos($i * 0.013) * 0.5 + 0.5; + } + $this->largeTerrain = new TerrainData($heights, 256, 256, 500.0, 500.0, 50.0); + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchGetHeight64x64(): void + { + for ($i = 0; $i < 100; $i++) { + $this->smallTerrain->getHeight($i % 64, ($i * 7) % 64); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(1000) + * @Iterations(5) + */ + public function benchGetHeightAtWorld64x64(): void + { + for ($i = 0; $i < 100; $i++) { + $x = ($i * 1.37) - 50.0; + $z = ($i * 2.13) - 50.0; + $this->smallTerrain->getHeightAtWorld($x, $z); + } + } + + /** + * @BeforeMethods({"setUp"}) + * @Revs(100) + * @Iterations(5) + */ + public function benchGetHeightAtWorld256x256(): void + { + for ($i = 0; $i < 1000; $i++) { + $x = ($i * 0.5) - 250.0; + $z = ($i * 0.37) - 250.0; + $this->largeTerrain->getHeightAtWorld($x, $z); + } + } + + /** + * @Revs(10) + * @Iterations(5) + */ + public function benchCreateFlatTerrain256x256(): void + { + TerrainData::flat(256, 256, 500.0, 500.0); + } +} diff --git a/tests/Build/BuildCommandTest.php b/tests/Build/BuildCommandTest.php new file mode 100644 index 0000000..bd4d4cc --- /dev/null +++ b/tests/Build/BuildCommandTest.php @@ -0,0 +1,87 @@ +assertNotEmpty($command->getCommandShortDescription()); + } + + public function testCommandHasExpectedArguments(): void + { + $command = new BuildCommand(); + $args = $command->getExpectedArguments([]); + + $this->assertArrayHasKey('platform', $args); + $this->assertArrayHasKey('dry-run', $args); + $this->assertArrayHasKey('micro-sfx', $args); + $this->assertArrayHasKey('output', $args); + } + + public function testResolveTargetsAutoDetect(): void + { + $command = new BuildCommand(); + $ref = new \ReflectionMethod($command, 'resolveTargets'); + $ref->setAccessible(true); + + $targets = $ref->invoke($command, ''); + $this->assertCount(1, $targets); + + $target = array_values($targets)[0]; + $this->assertArrayHasKey('platform', $target); + $this->assertArrayHasKey('arch', $target); + } + + public function testResolveTargetsAll(): void + { + $command = new BuildCommand(); + $ref = new \ReflectionMethod($command, 'resolveTargets'); + $ref->setAccessible(true); + + $targets = $ref->invoke($command, 'all'); + $this->assertCount(3, $targets); + $this->assertArrayHasKey('macos-arm64', $targets); + $this->assertArrayHasKey('linux-x86_64', $targets); + $this->assertArrayHasKey('windows-x86_64', $targets); + } + + public function testResolveTargetsExact(): void + { + $command = new BuildCommand(); + $ref = new \ReflectionMethod($command, 'resolveTargets'); + $ref->setAccessible(true); + + $targets = $ref->invoke($command, 'linux-x86_64'); + $this->assertCount(1, $targets); + $this->assertArrayHasKey('linux-x86_64', $targets); + $this->assertSame('linux', $targets['linux-x86_64']['platform']); + $this->assertSame('x86_64', $targets['linux-x86_64']['arch']); + } + + public function testResolveTargetsPlatformOnly(): void + { + $command = new BuildCommand(); + $ref = new \ReflectionMethod($command, 'resolveTargets'); + $ref->setAccessible(true); + + $targets = $ref->invoke($command, 'linux'); + $this->assertCount(1, $targets); + $this->assertArrayHasKey('linux-x86_64', $targets); + } + + public function testResolveTargetsUnknownThrows(): void + { + $command = new BuildCommand(); + $ref = new \ReflectionMethod($command, 'resolveTargets'); + $ref->setAccessible(true); + + $this->expectException(\RuntimeException::class); + $ref->invoke($command, 'freebsd'); + } +} diff --git a/tests/Build/BuildConfigTest.php b/tests/Build/BuildConfigTest.php new file mode 100644 index 0000000..55d18af --- /dev/null +++ b/tests/Build/BuildConfigTest.php @@ -0,0 +1,146 @@ +tmpDir = sys_get_temp_dir() . '/visu_build_config_test_' . uniqid(); + mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + foreach (glob($this->tmpDir . '/*') ?: [] as $file) { + unlink($file); + } + if (is_dir($this->tmpDir)) { + rmdir($this->tmpDir); + } + } + + public function testDefaultValues(): void + { + $config = BuildConfig::load($this->tmpDir); + + $this->assertSame('Game', $config->name); + $this->assertSame('com.visu.game', $config->identifier); + $this->assertSame('1.0.0', $config->version); + $this->assertSame('game.php', $config->entry); + $this->assertSame('', $config->run); + $this->assertContains('glfw', $config->phpExtensions); + $this->assertContains('mbstring', $config->phpExtensions); + $this->assertNotEmpty($config->pharExclude); + $this->assertEmpty($config->additionalRequires); + $this->assertEmpty($config->externalResources); + } + + public function testLoadsFromComposerJson(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'acme/my-game', + 'version' => '2.5.0', + ])); + + $config = BuildConfig::load($this->tmpDir); + + $this->assertSame('My-game', $config->name); + $this->assertSame('2.5.0', $config->version); + } + + public function testBuildJsonOverridesDefaults(): void + { + file_put_contents($this->tmpDir . '/build.json', json_encode([ + 'name' => 'SuperGame', + 'identifier' => 'com.example.supergame', + 'version' => '3.0.0', + 'entry' => 'start.php', + 'run' => '\\App\\Game::run($container);', + 'php' => [ + 'extensions' => ['glfw', 'mbstring', 'zip', 'gd'], + 'extraLibs' => ['-lc++', '-lm'], + ], + 'phar' => [ + 'exclude' => ['**/tests'], + 'additionalRequires' => ['src/helpers.php'], + ], + 'resources' => [ + 'external' => ['resources/audio', 'resources/video'], + ], + 'platforms' => [ + 'macos' => ['minimumVersion' => '13.0'], + ], + ])); + + $config = BuildConfig::load($this->tmpDir); + + $this->assertSame('SuperGame', $config->name); + $this->assertSame('com.example.supergame', $config->identifier); + $this->assertSame('3.0.0', $config->version); + $this->assertSame('start.php', $config->entry); + $this->assertSame('\\App\\Game::run($container);', $config->run); + $this->assertSame(['glfw', 'mbstring', 'zip', 'gd'], $config->phpExtensions); + $this->assertSame(['-lc++', '-lm'], $config->phpExtraLibs); + $this->assertSame(['**/tests'], $config->pharExclude); + $this->assertSame(['src/helpers.php'], $config->additionalRequires); + $this->assertSame(['resources/audio', 'resources/video'], $config->externalResources); + $this->assertArrayHasKey('macos', $config->platforms); + } + + public function testBuildJsonOverridesComposerJson(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'acme/old-name', + 'version' => '1.0.0', + ])); + file_put_contents($this->tmpDir . '/build.json', json_encode([ + 'name' => 'NewName', + 'version' => '2.0.0', + ])); + + $config = BuildConfig::load($this->tmpDir); + + $this->assertSame('NewName', $config->name); + $this->assertSame('2.0.0', $config->version); + } + + public function testToArray(): void + { + $config = BuildConfig::load($this->tmpDir); + $arr = $config->toArray(); + + $this->assertArrayHasKey('name', $arr); + $this->assertArrayHasKey('identifier', $arr); + $this->assertArrayHasKey('version', $arr); + $this->assertArrayHasKey('entry', $arr); + $this->assertArrayHasKey('php.extensions', $arr); + $this->assertArrayHasKey('phar.exclude', $arr); + $this->assertArrayHasKey('resources.external', $arr); + } + + public function testPartialBuildJson(): void + { + file_put_contents($this->tmpDir . '/build.json', json_encode([ + 'name' => 'PartialGame', + ])); + + $config = BuildConfig::load($this->tmpDir); + + $this->assertSame('PartialGame', $config->name); + // Defaults preserved + $this->assertSame('game.php', $config->entry); + $this->assertContains('glfw', $config->phpExtensions); + } + + public function testProjectRootIsSet(): void + { + $config = BuildConfig::load($this->tmpDir); + $this->assertSame($this->tmpDir, $config->projectRoot); + } +} diff --git a/tests/Build/PharBuilderTest.php b/tests/Build/PharBuilderTest.php new file mode 100644 index 0000000..3cbfae4 --- /dev/null +++ b/tests/Build/PharBuilderTest.php @@ -0,0 +1,199 @@ +tmpDir = sys_get_temp_dir() . '/visu_phar_test_' . uniqid(); + mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->tmpDir)); + } + + private function createConfig(array $overrides = []): BuildConfig + { + $projectDir = $this->tmpDir . '/project'; + mkdir($projectDir, 0755, true); + + if (!empty($overrides)) { + file_put_contents($projectDir . '/build.json', json_encode($overrides)); + } + + return BuildConfig::load($projectDir); + } + + public function testGenerateStubContainsPathConstants(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('VISU_PATH_ROOT', $stub); + $this->assertStringContainsString('VISU_PATH_CACHE', $stub); + $this->assertStringContainsString('VISU_PATH_STORE', $stub); + $this->assertStringContainsString('VISU_PATH_RESOURCES', $stub); + $this->assertStringContainsString('VISU_PATH_VENDOR', $stub); + $this->assertStringContainsString('VISU_PATH_FRAMEWORK_RESOURCES', $stub); + } + + public function testGenerateStubHandlesMicroSapi(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + // Must handle empty PHP_BINARY in micro SAPI + $this->assertStringContainsString('PHP_BINARY ?: __FILE__', $stub); + } + + public function testGenerateStubHandlesMacOsAppBundle(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('.app/Contents/MacOS', $stub); + $this->assertStringContainsString('/Resources', $stub); + } + + public function testGenerateStubExtractsFrameworkResources(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('visu-resources', $stub); + $this->assertStringContainsString("'fonts', 'shader'", $stub); + } + + public function testGenerateStubExtractsGameResources(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('Extract game resources', $stub); + $this->assertStringContainsString('VISU_PATH_RESOURCES', $stub); + } + + public function testGenerateStubIncludesAdditionalRequires(): void + { + $config = $this->createConfig([ + 'phar' => [ + 'additionalRequires' => [ + 'src/helpers.php', + 'src/globals.php', + ], + ], + ]); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString("require_once \$pharBase . '/src/helpers.php'", $stub); + $this->assertStringContainsString("require_once \$pharBase . '/src/globals.php'", $stub); + } + + public function testGenerateStubIncludesRunCommand(): void + { + $config = $this->createConfig([ + 'run' => '\\App\\Game::run($container);', + ]); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('\\App\\Game::run($container);', $stub); + } + + public function testGenerateStubOmitsRunWhenEmpty(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + // Should end with __HALT_COMPILER without a run call + $this->assertStringContainsString('__HALT_COMPILER();', $stub); + } + + public function testGenerateStubEndsWithHaltCompiler(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringEndsWith('__HALT_COMPILER();', $stub); + } + + public function testGenerateStubExtractsAppCtn(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('app.ctn', $stub); + $this->assertStringContainsString('container_map.php', $stub); + } + + public function testGenerateStubIncludesEngineLog(): void + { + $config = $this->createConfig(); + $builder = new PharBuilder($config); + $stub = $builder->generateStub(); + + $this->assertStringContainsString('engine.log', $stub); + $this->assertStringContainsString('set_error_handler', $stub); + $this->assertStringContainsString('set_exception_handler', $stub); + $this->assertStringContainsString('register_shutdown_function', $stub); + } + + public function testStageCreatesDirectory(): void + { + $projectDir = $this->tmpDir . '/project'; + mkdir($projectDir . '/src', 0755, true); + file_put_contents($projectDir . '/src/App.php', 'tmpDir . '/staging'; + $builder->stage($stagingDir); + + $this->assertDirectoryExists($stagingDir); + $this->assertDirectoryExists($stagingDir . '/src'); + $this->assertFileExists($stagingDir . '/src/App.php'); + $this->assertFileExists($stagingDir . '/bootstrap.php'); + } + + public function testStageExcludesExternalResources(): void + { + $projectDir = $this->tmpDir . '/project'; + mkdir($projectDir . '/resources/audio', 0755, true); + mkdir($projectDir . '/resources/locales', 0755, true); + file_put_contents($projectDir . '/resources/audio/music.ogg', 'audio-data'); + file_put_contents($projectDir . '/resources/locales/en.json', '{}'); + + file_put_contents($projectDir . '/build.json', json_encode([ + 'resources' => ['external' => ['resources/audio']], + ])); + + $config = BuildConfig::load($projectDir); + $builder = new PharBuilder($config); + + $stagingDir = $this->tmpDir . '/staging'; + $builder->stage($stagingDir); + + $this->assertDirectoryDoesNotExist($stagingDir . '/resources/audio'); + $this->assertFileExists($stagingDir . '/resources/locales/en.json'); + } +} diff --git a/tests/Build/PlatformPackagerTest.php b/tests/Build/PlatformPackagerTest.php new file mode 100644 index 0000000..9d37405 --- /dev/null +++ b/tests/Build/PlatformPackagerTest.php @@ -0,0 +1,131 @@ +tmpDir = sys_get_temp_dir() . '/visu_packager_test_' . uniqid(); + mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->tmpDir)); + } + + private function createConfig(array $overrides = []): BuildConfig + { + $projectDir = $this->tmpDir . '/project'; + if (!is_dir($projectDir)) { + mkdir($projectDir, 0755, true); + } + if (!empty($overrides)) { + file_put_contents($projectDir . '/build.json', json_encode($overrides)); + } + return BuildConfig::load($projectDir); + } + + private function createFakeBinary(): string + { + $path = $this->tmpDir . '/fake-binary'; + file_put_contents($path, 'fake-executable-content'); + chmod($path, 0755); + return $path; + } + + public function testMacOsCreatesAppBundle(): void + { + $config = $this->createConfig(['name' => 'TestGame']); + $packager = new PlatformPackager($config); + $binary = $this->createFakeBinary(); + $outputDir = $this->tmpDir . '/output'; + + $result = $packager->package($binary, $outputDir, 'macos'); + + $this->assertStringEndsWith('TestGame.app', $result); + $this->assertDirectoryExists($result . '/Contents/MacOS'); + $this->assertDirectoryExists($result . '/Contents/Resources'); + $this->assertFileExists($result . '/Contents/MacOS/TestGame'); + $this->assertFileExists($result . '/Contents/Info.plist'); + } + + public function testMacOsInfoPlistContent(): void + { + $config = $this->createConfig([ + 'name' => 'MyGame', + 'identifier' => 'com.test.mygame', + 'version' => '2.1.0', + 'platforms' => [ + 'macos' => ['minimumVersion' => '13.0'], + ], + ]); + $packager = new PlatformPackager($config); + $binary = $this->createFakeBinary(); + $outputDir = $this->tmpDir . '/output'; + + $packager->package($binary, $outputDir, 'macos'); + + $plist = file_get_contents($outputDir . '/MyGame.app/Contents/Info.plist'); + $this->assertStringContainsString('com.test.mygame', $plist); + $this->assertStringContainsString('2.1.0', $plist); + $this->assertStringContainsString('MyGame', $plist); + $this->assertStringContainsString('13.0', $plist); + } + + public function testLinuxCreatesFlat(): void + { + $config = $this->createConfig(['name' => 'LinuxGame']); + $packager = new PlatformPackager($config); + $binary = $this->createFakeBinary(); + $outputDir = $this->tmpDir . '/output'; + + $result = $packager->package($binary, $outputDir, 'linux'); + + $this->assertStringEndsWith('LinuxGame', $result); + $this->assertFileExists($result . '/LinuxGame'); + } + + public function testWindowsCreatesExe(): void + { + $config = $this->createConfig(['name' => 'WinGame']); + $packager = new PlatformPackager($config); + $binary = $this->createFakeBinary(); + $outputDir = $this->tmpDir . '/output'; + + $result = $packager->package($binary, $outputDir, 'windows'); + + $this->assertFileExists($result . '/WinGame.exe'); + } + + public function testUnsupportedPlatformThrows(): void + { + $config = $this->createConfig(); + $packager = new PlatformPackager($config); + $binary = $this->createFakeBinary(); + $outputDir = $this->tmpDir . '/output'; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unsupported platform'); + $packager->package($binary, $outputDir, 'freebsd'); + } + + public function testBinaryIsExecutable(): void + { + $config = $this->createConfig(['name' => 'ExecTest']); + $packager = new PlatformPackager($config); + $binary = $this->createFakeBinary(); + $outputDir = $this->tmpDir . '/output'; + + $packager->package($binary, $outputDir, 'macos'); + + $this->assertTrue(is_executable($outputDir . '/ExecTest.app/Contents/MacOS/ExecTest')); + } +} diff --git a/tests/Build/StaticPhpResolverTest.php b/tests/Build/StaticPhpResolverTest.php new file mode 100644 index 0000000..8a21e22 --- /dev/null +++ b/tests/Build/StaticPhpResolverTest.php @@ -0,0 +1,120 @@ +assertContains($platform, ['macos', 'linux', 'windows']); + } + + public function testDetectArch(): void + { + $arch = StaticPhpResolver::detectArch(); + $this->assertContains($arch, ['arm64', 'x86_64']); + } + + public function testResolveWithExplicitPath(): void + { + $resolver = new StaticPhpResolver(); + $tmpFile = tempnam(sys_get_temp_dir(), 'micro_test_'); + file_put_contents($tmpFile, 'fake-binary'); + + try { + $result = $resolver->resolve($tmpFile, 'macos', 'arm64'); + $this->assertSame($tmpFile, $result); + } finally { + @unlink($tmpFile); + } + } + + public function testResolveWithExplicitPathNotFoundThrows(): void + { + $resolver = new StaticPhpResolver(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('not found'); + $resolver->resolve('/nonexistent/micro.sfx', 'macos', 'arm64'); + } + + public function testResolveWithoutCacheOrDownloadThrows(): void + { + // Use a custom HOME so cache is empty + $oldHome = getenv('HOME'); + $tmpHome = sys_get_temp_dir() . '/visu_resolver_test_' . uniqid(); + mkdir($tmpHome, 0755, true); + putenv("HOME={$tmpHome}"); + + try { + $resolver = new StaticPhpResolver(); + $this->expectException(\RuntimeException::class); + $resolver->resolve(null, 'fakeos', 'fakearch'); + } finally { + putenv("HOME={$oldHome}"); + @rmdir($tmpHome); + } + } + + public function testCacheAndResolve(): void + { + $tmpHome = sys_get_temp_dir() . '/visu_resolver_cache_test_' . uniqid(); + mkdir($tmpHome, 0755, true); + $oldHome = getenv('HOME'); + putenv("HOME={$tmpHome}"); + + try { + $resolver = new StaticPhpResolver(); + + // Create a fake binary + $tmpFile = tempnam(sys_get_temp_dir(), 'micro_test_'); + file_put_contents($tmpFile, 'fake-micro-sfx'); + + // Cache it + $cachedPath = $resolver->cache($tmpFile, 'testplatform', 'testarch'); + $this->assertFileExists($cachedPath); + $this->assertSame('fake-micro-sfx', file_get_contents($cachedPath)); + + // Now resolve should find it + $resolved = $resolver->resolve(null, 'testplatform', 'testarch'); + $this->assertSame($cachedPath, $resolved); + } finally { + putenv("HOME={$oldHome}"); + @unlink($tmpFile); + // Clean up cache dir + exec('rm -rf ' . escapeshellarg($tmpHome)); + } + } + + public function testLoggerIsCalled(): void + { + $tmpHome = sys_get_temp_dir() . '/visu_resolver_log_test_' . uniqid(); + mkdir($tmpHome, 0755, true); + $oldHome = getenv('HOME'); + putenv("HOME={$tmpHome}"); + + $messages = []; + try { + $resolver = new StaticPhpResolver(); + $resolver->setLogger(function (string $msg) use (&$messages) { + $messages[] = $msg; + }); + + try { + $resolver->resolve(null, 'noos', 'noarch'); + } catch (\RuntimeException) { + // Expected + } + + $this->assertNotEmpty($messages); + $this->assertStringContainsString('noos-noarch', $messages[0]); + } finally { + putenv("HOME={$oldHome}"); + @rmdir($tmpHome); + } + } +} diff --git a/tests/Command/TranspileCommandTest.php b/tests/Command/TranspileCommandTest.php new file mode 100644 index 0000000..4e28abd --- /dev/null +++ b/tests/Command/TranspileCommandTest.php @@ -0,0 +1,47 @@ +setAccessible(true); + + $this->assertSame('OfficeLevel1', $ref->invoke($command, 'office_level1')); + $this->assertSame('Hud', $ref->invoke($command, 'hud')); + $this->assertSame('MainMenu', $ref->invoke($command, 'main-menu')); + $this->assertSame('MyScene', $ref->invoke($command, 'my scene')); + $this->assertSame('Test123Scene', $ref->invoke($command, 'test123_scene')); + } + + public function testCommandHasDescription(): void + { + $registry = new ComponentRegistry(); + $command = new TranspileCommand($registry); + + $this->assertNotEmpty($command->getCommandShortDescription()); + } + + public function testCommandHasExpectedArguments(): void + { + $registry = new ComponentRegistry(); + $command = new TranspileCommand($registry); + + $args = $command->getExpectedArguments([]); + $this->assertArrayHasKey('force', $args); + $this->assertArrayHasKey('scenes', $args); + $this->assertArrayHasKey('ui', $args); + $this->assertArrayHasKey('prefabs', $args); + $this->assertArrayHasKey('output', $args); + } +} diff --git a/tests/Component/BehaviourTreeComponentTest.php b/tests/Component/BehaviourTreeComponentTest.php new file mode 100644 index 0000000..fc43963 --- /dev/null +++ b/tests/Component/BehaviourTreeComponentTest.php @@ -0,0 +1,29 @@ +assertNull($comp->root); + $this->assertEmpty($comp->blackboard); + $this->assertSame(BTStatus::Success, $comp->lastStatus); + $this->assertTrue($comp->enabled); + } + + public function testWithRoot(): void + { + $node = new ActionNode(fn() => BTStatus::Success); + $comp = new BehaviourTreeComponent($node); + + $this->assertSame($node, $comp->root); + } +} diff --git a/tests/Component/BoxCollider3DTest.php b/tests/Component/BoxCollider3DTest.php new file mode 100644 index 0000000..eeffb67 --- /dev/null +++ b/tests/Component/BoxCollider3DTest.php @@ -0,0 +1,31 @@ +assertEqualsWithDelta(0.5, $box->halfExtents->x, 0.001); + $this->assertEqualsWithDelta(0.5, $box->halfExtents->y, 0.001); + $this->assertEqualsWithDelta(0.5, $box->halfExtents->z, 0.001); + $this->assertEqualsWithDelta(0.0, $box->offset->x, 0.001); + $this->assertFalse($box->isTrigger); + $this->assertSame(1, $box->layer); + $this->assertSame(0xFFFF, $box->mask); + } + + public function testCustomExtents(): void + { + $box = new BoxCollider3D(new Vec3(2.0, 3.0, 1.5), new Vec3(0.0, 1.0, 0.0)); + $this->assertEqualsWithDelta(2.0, $box->halfExtents->x, 0.001); + $this->assertEqualsWithDelta(3.0, $box->halfExtents->y, 0.001); + $this->assertEqualsWithDelta(1.5, $box->halfExtents->z, 0.001); + $this->assertEqualsWithDelta(1.0, $box->offset->y, 0.001); + } +} diff --git a/tests/Component/Camera3DComponentTest.php b/tests/Component/Camera3DComponentTest.php new file mode 100644 index 0000000..6d619cb --- /dev/null +++ b/tests/Component/Camera3DComponentTest.php @@ -0,0 +1,84 @@ +assertSame(0.0, $comp->yaw); + $this->assertSame(-15.0, $comp->pitch); + $this->assertSame(-89.0, $comp->pitchMin); + $this->assertSame(89.0, $comp->pitchMax); + $this->assertSame(0.15, $comp->sensitivity); + $this->assertSame(0.3, $comp->moveSpeed); + $this->assertSame(3.0, $comp->sprintMultiplier); + } + + public function testOrbitDefaults(): void + { + $comp = new Camera3DComponent(); + + $this->assertSame(10.0, $comp->orbitDistance); + $this->assertSame(1.0, $comp->orbitDistanceMin); + $this->assertSame(100.0, $comp->orbitDistanceMax); + $this->assertSame(1.0, $comp->orbitZoomSpeed); + $this->assertSame(0.01, $comp->orbitPanSpeed); + $this->assertInstanceOf(Vec3::class, $comp->orbitTarget); + } + + public function testThirdPersonDefaults(): void + { + $comp = new Camera3DComponent(); + + $this->assertSame(0, $comp->followTarget); + $this->assertSame(1.5, $comp->followHeightOffset); + $this->assertSame(5.0, $comp->followDistance); + $this->assertSame(1.5, $comp->followDistanceMin); + $this->assertSame(20.0, $comp->followDistanceMax); + $this->assertSame(0.1, $comp->followDamping); + } + + public function testPitchClamping(): void + { + $comp = new Camera3DComponent(); + + // simulate pitch clamping logic as it would happen in the system + $comp->pitch = 100.0; + $clamped = max($comp->pitchMin, min($comp->pitchMax, $comp->pitch)); + $this->assertSame(89.0, $clamped); + + $comp->pitch = -100.0; + $clamped = max($comp->pitchMin, min($comp->pitchMax, $comp->pitch)); + $this->assertSame(-89.0, $clamped); + } + + public function testOrbitDistanceClamping(): void + { + $comp = new Camera3DComponent(); + + $distance = 200.0; + $clamped = max($comp->orbitDistanceMin, min($comp->orbitDistanceMax, $distance)); + $this->assertSame(100.0, $clamped); + + $distance = 0.1; + $clamped = max($comp->orbitDistanceMin, min($comp->orbitDistanceMax, $distance)); + $this->assertSame(1.0, $clamped); + } + + public function testCustomOrbitTarget(): void + { + $comp = new Camera3DComponent(); + $comp->orbitTarget = new Vec3(5.0, 2.0, -3.0); + + $this->assertEqualsWithDelta(5.0, $comp->orbitTarget->x, 0.001); + $this->assertEqualsWithDelta(2.0, $comp->orbitTarget->y, 0.001); + $this->assertEqualsWithDelta(-3.0, $comp->orbitTarget->z, 0.001); + } +} diff --git a/tests/Component/Camera3DModeTest.php b/tests/Component/Camera3DModeTest.php new file mode 100644 index 0000000..016a949 --- /dev/null +++ b/tests/Component/Camera3DModeTest.php @@ -0,0 +1,17 @@ +assertCount(3, Camera3DMode::cases()); + $this->assertSame('orbit', Camera3DMode::orbit->name); + $this->assertSame('firstPerson', Camera3DMode::firstPerson->name); + $this->assertSame('thirdPerson', Camera3DMode::thirdPerson->name); + } +} diff --git a/tests/Component/CapsuleCollider3DTest.php b/tests/Component/CapsuleCollider3DTest.php new file mode 100644 index 0000000..91eea5a --- /dev/null +++ b/tests/Component/CapsuleCollider3DTest.php @@ -0,0 +1,33 @@ +assertSame(0.3, $capsule->radius); + $this->assertSame(0.5, $capsule->halfHeight); + $this->assertEqualsWithDelta(0.0, $capsule->offset->x, 0.001); + $this->assertFalse($capsule->isTrigger); + } + + public function testCustomValues(): void + { + $capsule = new CapsuleCollider3D(0.5, 1.0); + $this->assertSame(0.5, $capsule->radius); + $this->assertSame(1.0, $capsule->halfHeight); + } + + public function testTotalHeight(): void + { + $capsule = new CapsuleCollider3D(0.3, 0.5); + // total height = 2*halfHeight + 2*radius = 1.0 + 0.6 = 1.6 + $totalHeight = 2.0 * $capsule->halfHeight + 2.0 * $capsule->radius; + $this->assertEqualsWithDelta(1.6, $totalHeight, 0.001); + } +} diff --git a/tests/Component/MeshRendererComponentTest.php b/tests/Component/MeshRendererComponentTest.php new file mode 100644 index 0000000..bb4a5bc --- /dev/null +++ b/tests/Component/MeshRendererComponentTest.php @@ -0,0 +1,39 @@ +assertEquals('cube.glb', $renderer->modelIdentifier); + $this->assertNull($renderer->materialOverride); + $this->assertTrue($renderer->castsShadows); + $this->assertTrue($renderer->receivesShadows); + } + + public function testMaterialOverride(): void + { + $renderer = new MeshRendererComponent('cube.glb'); + $override = new Material('red', new Vec4(1, 0, 0, 1), 0.0, 0.5); + $renderer->materialOverride = $override; + + $this->assertSame($override, $renderer->materialOverride); + $this->assertEquals('red', $renderer->materialOverride->name); + } + + public function testShadowFlags(): void + { + $renderer = new MeshRendererComponent('mesh'); + $renderer->castsShadows = false; + $renderer->receivesShadows = false; + $this->assertFalse($renderer->castsShadows); + $this->assertFalse($renderer->receivesShadows); + } +} diff --git a/tests/Component/ParticleEmitterComponentTest.php b/tests/Component/ParticleEmitterComponentTest.php new file mode 100644 index 0000000..c917cc6 --- /dev/null +++ b/tests/Component/ParticleEmitterComponentTest.php @@ -0,0 +1,55 @@ +assertEquals(ParticleEmitterShape::Point, $emitter->shape); + $this->assertEquals(10.0, $emitter->emissionRate); + $this->assertEquals(0, $emitter->burstCount); + $this->assertEquals(500, $emitter->maxParticles); + $this->assertTrue($emitter->looping); + $this->assertTrue($emitter->playing); + $this->assertEquals(0.0, $emitter->gravityModifier); + $this->assertFalse($emitter->additiveBlending); + $this->assertNull($emitter->texture); + } + + public function testDefaultVectors(): void + { + $emitter = new ParticleEmitterComponent(); + + $this->assertEquals(0.0, $emitter->direction->x); + $this->assertEquals(1.0, $emitter->direction->y); + $this->assertEquals(0.0, $emitter->direction->z); + + $this->assertEquals(1.0, $emitter->startColor->x); + $this->assertEquals(1.0, $emitter->startColor->w); + $this->assertEquals(0.0, $emitter->endColor->w); + } + + public function testSizeOverLifetime(): void + { + $emitter = new ParticleEmitterComponent(); + + $this->assertEquals(1.0, $emitter->startSize); + $this->assertEquals(0.0, $emitter->endSize); + } + + public function testShapeEnum(): void + { + $this->assertEquals('point', ParticleEmitterShape::Point->value); + $this->assertEquals('sphere', ParticleEmitterShape::Sphere->value); + $this->assertEquals('cone', ParticleEmitterShape::Cone->value); + $this->assertEquals('box', ParticleEmitterShape::Box->value); + } +} diff --git a/tests/Component/PointLightComponentTest.php b/tests/Component/PointLightComponentTest.php new file mode 100644 index 0000000..38abcc6 --- /dev/null +++ b/tests/Component/PointLightComponentTest.php @@ -0,0 +1,62 @@ +assertEquals(1.0, $light->color->x); + $this->assertEquals(1.0, $light->color->y); + $this->assertEquals(1.0, $light->color->z); + $this->assertEquals(1.0, $light->intensity); + $this->assertEquals(20.0, $light->range); + $this->assertFalse($light->castsShadows); + } + + public function testCustomConstructor(): void + { + $light = new PointLightComponent( + color: new Vec3(1.0, 0.5, 0.0), + intensity: 2.5, + range: 50.0, + ); + $this->assertEquals(1.0, $light->color->x); + $this->assertEquals(0.5, $light->color->y); + $this->assertEquals(0.0, $light->color->z); + $this->assertEquals(2.5, $light->intensity); + $this->assertEquals(50.0, $light->range); + } + + public function testDefaultAttenuation(): void + { + $light = new PointLightComponent(); + $this->assertEquals(1.0, $light->constantAttenuation); + $this->assertEquals(0.09, $light->linearAttenuation); + $this->assertEquals(0.032, $light->quadraticAttenuation); + } + + public function testSetAttenuationFromRange(): void + { + $light = new PointLightComponent(range: 10.0); + $light->setAttenuationFromRange(); + + $this->assertEquals(1.0, $light->constantAttenuation); + $this->assertEqualsWithDelta(0.45, $light->linearAttenuation, 0.001); + $this->assertEqualsWithDelta(0.75, $light->quadraticAttenuation, 0.001); + } + + public function testSetAttenuationFromLargeRange(): void + { + $light = new PointLightComponent(range: 100.0); + $light->setAttenuationFromRange(); + + $this->assertEqualsWithDelta(0.045, $light->linearAttenuation, 0.001); + $this->assertEqualsWithDelta(0.0075, $light->quadraticAttenuation, 0.001); + } +} diff --git a/tests/Component/PointLightShadowTest.php b/tests/Component/PointLightShadowTest.php new file mode 100644 index 0000000..3f4d164 --- /dev/null +++ b/tests/Component/PointLightShadowTest.php @@ -0,0 +1,88 @@ +assertFalse($light->castsShadows); + } + + public function testEnableShadows(): void + { + $light = new PointLightComponent(); + $light->castsShadows = true; + $this->assertTrue($light->castsShadows); + } + + public function testShadowLightWithCustomRange(): void + { + $light = new PointLightComponent( + color: new Vec3(1.0, 0.8, 0.6), + intensity: 5.0, + range: 30.0, + ); + $light->castsShadows = true; + $light->setAttenuationFromRange(); + + $this->assertTrue($light->castsShadows); + $this->assertEquals(30.0, $light->range); + $this->assertEqualsWithDelta(0.15, $light->linearAttenuation, 0.001); + } + + public function testMultipleShadowLights(): void + { + $lights = []; + $colors = [ + new Vec3(1, 0, 0), + new Vec3(0, 1, 0), + new Vec3(0, 0, 1), + new Vec3(1, 1, 0), + ]; + + foreach ($colors as $i => $color) { + $light = new PointLightComponent($color, 2.0, 15.0 + $i * 5.0); + $light->castsShadows = true; + $light->setAttenuationFromRange(); + $lights[] = $light; + } + + $this->assertCount(4, $lights); + foreach ($lights as $light) { + $this->assertTrue($light->castsShadows); + } + $this->assertEquals(15.0, $lights[0]->range); + $this->assertEquals(30.0, $lights[3]->range); + } + + public function testRangeAsFarPlane(): void + { + // The range doubles as the far plane for cubemap shadow projection + $light = new PointLightComponent(range: 50.0); + $light->castsShadows = true; + + // Far plane equals range + $this->assertEquals(50.0, $light->range); + } + + public function testMixedShadowAndNonShadowLights(): void + { + $shadowLight = new PointLightComponent(new Vec3(1, 1, 1), 3.0, 20.0); + $shadowLight->castsShadows = true; + + $noShadowLight = new PointLightComponent(new Vec3(1, 0.5, 0), 1.0, 10.0); + // castsShadows defaults to false + + $this->assertTrue($shadowLight->castsShadows); + $this->assertFalse($noShadowLight->castsShadows); + } +} diff --git a/tests/Component/RigidBody3DTest.php b/tests/Component/RigidBody3DTest.php new file mode 100644 index 0000000..5b39462 --- /dev/null +++ b/tests/Component/RigidBody3DTest.php @@ -0,0 +1,79 @@ +assertSame(1.0, $rb->mass); + $this->assertEqualsWithDelta(0.0, $rb->velocity->x, 0.001); + $this->assertEqualsWithDelta(0.0, $rb->velocity->y, 0.001); + $this->assertEqualsWithDelta(0.0, $rb->velocity->z, 0.001); + $this->assertSame(1.0, $rb->gravityScale); + $this->assertSame(0.3, $rb->restitution); + $this->assertSame(0.5, $rb->friction); + $this->assertFalse($rb->isKinematic); + } + + public function testInverseMass(): void + { + $rb = new RigidBody3D(2.0); + $this->assertEqualsWithDelta(0.5, $rb->inverseMass(), 0.001); + } + + public function testInverseMassStatic(): void + { + $rb = new RigidBody3D(0.0); + $this->assertSame(0.0, $rb->inverseMass()); + } + + public function testInverseMassKinematic(): void + { + $rb = new RigidBody3D(5.0); + $rb->isKinematic = true; + $this->assertSame(0.0, $rb->inverseMass()); + } + + public function testAddForce(): void + { + $rb = new RigidBody3D(); + $rb->addForce(new Vec3(10.0, 0.0, 0.0)); + $rb->addForce(new Vec3(0.0, 5.0, 0.0)); + $this->assertEqualsWithDelta(10.0, $rb->force->x, 0.001); + $this->assertEqualsWithDelta(5.0, $rb->force->y, 0.001); + } + + public function testAddImpulse(): void + { + $rb = new RigidBody3D(2.0); + $rb->addImpulse(new Vec3(10.0, 0.0, 0.0)); + // impulse / mass = 10 / 2 = 5 + $this->assertEqualsWithDelta(5.0, $rb->velocity->x, 0.001); + } + + public function testAddImpulseStatic(): void + { + $rb = new RigidBody3D(0.0); + $rb->addImpulse(new Vec3(10.0, 0.0, 0.0)); + // static body shouldn't move + $this->assertEqualsWithDelta(0.0, $rb->velocity->x, 0.001); + } + + public function testFreezeConstraints(): void + { + $rb = new RigidBody3D(); + $rb->freezePositionX = true; + $rb->freezePositionY = true; + $rb->freezeRotationZ = true; + $this->assertTrue($rb->freezePositionX); + $this->assertTrue($rb->freezePositionY); + $this->assertFalse($rb->freezePositionZ); + $this->assertTrue($rb->freezeRotationZ); + } +} diff --git a/tests/Component/SkeletalAnimationComponentTest.php b/tests/Component/SkeletalAnimationComponentTest.php new file mode 100644 index 0000000..af91493 --- /dev/null +++ b/tests/Component/SkeletalAnimationComponentTest.php @@ -0,0 +1,68 @@ +assertNull($comp->skeleton); + $this->assertNull($comp->currentClip); + $this->assertEquals(0.0, $comp->time); + $this->assertEquals(1.0, $comp->speed); + $this->assertTrue($comp->looping); + $this->assertTrue($comp->playing); + $this->assertEmpty($comp->boneMatrices); + } + + public function testAddAndPlayClip(): void + { + $comp = new SkeletalAnimationComponent(); + $clip = new AnimationClip('walk', 2.0); + $comp->addClip($clip); + + $comp->play('walk'); + + $this->assertEquals('walk', $comp->currentClip); + $this->assertTrue($comp->playing); + $this->assertSame($clip, $comp->getActiveClip()); + } + + public function testPlayNonexistentClipDoesNothing(): void + { + $comp = new SkeletalAnimationComponent(); + $comp->play('nonexistent'); + + $this->assertNull($comp->currentClip); + } + + public function testPlayWithRestart(): void + { + $comp = new SkeletalAnimationComponent(); + $comp->addClip(new AnimationClip('idle', 1.0)); + + $comp->play('idle'); + $comp->time = 0.5; + + $comp->play('idle', true); + $this->assertEquals(0.0, $comp->time); + } + + public function testPlayWithoutRestart(): void + { + $comp = new SkeletalAnimationComponent(); + $comp->addClip(new AnimationClip('idle', 1.0)); + + $comp->play('idle'); + $comp->time = 0.5; + + $comp->play('idle', false); + $this->assertEquals(0.5, $comp->time); + } +} diff --git a/tests/Component/SphereCollider3DTest.php b/tests/Component/SphereCollider3DTest.php new file mode 100644 index 0000000..6660502 --- /dev/null +++ b/tests/Component/SphereCollider3DTest.php @@ -0,0 +1,27 @@ +assertSame(0.5, $sphere->radius); + $this->assertEqualsWithDelta(0.0, $sphere->offset->x, 0.001); + $this->assertFalse($sphere->isTrigger); + $this->assertSame(1, $sphere->layer); + $this->assertSame(0xFFFF, $sphere->mask); + } + + public function testCustomRadius(): void + { + $sphere = new SphereCollider3D(2.0, new Vec3(1.0, 0.0, 0.0)); + $this->assertSame(2.0, $sphere->radius); + $this->assertEqualsWithDelta(1.0, $sphere->offset->x, 0.001); + } +} diff --git a/tests/Component/SpotLightComponentTest.php b/tests/Component/SpotLightComponentTest.php new file mode 100644 index 0000000..a10fcb7 --- /dev/null +++ b/tests/Component/SpotLightComponentTest.php @@ -0,0 +1,66 @@ +assertEquals(1.0, $light->color->x); + $this->assertEquals(1.0, $light->color->y); + $this->assertEquals(1.0, $light->color->z); + $this->assertEquals(1.0, $light->intensity); + $this->assertEquals(20.0, $light->range); + $this->assertEquals(0.0, $light->direction->x); + $this->assertEquals(-1.0, $light->direction->y); + $this->assertEquals(0.0, $light->direction->z); + $this->assertEquals(15.0, $light->innerAngle); + $this->assertEquals(25.0, $light->outerAngle); + $this->assertFalse($light->castsShadows); + } + + public function testCustomValues(): void + { + $light = new SpotLightComponent( + color: new Vec3(1.0, 0.0, 0.0), + intensity: 5.0, + range: 50.0, + direction: new Vec3(0.0, 0.0, -1.0), + innerAngle: 10.0, + outerAngle: 30.0, + ); + + $this->assertEquals(1.0, $light->color->x); + $this->assertEquals(0.0, $light->color->y); + $this->assertEquals(5.0, $light->intensity); + $this->assertEquals(50.0, $light->range); + $this->assertEquals(-1.0, $light->direction->z); + $this->assertEquals(10.0, $light->innerAngle); + $this->assertEquals(30.0, $light->outerAngle); + } + + public function testDefaultAttenuation(): void + { + $light = new SpotLightComponent(); + + $this->assertEquals(1.0, $light->constantAttenuation); + $this->assertEquals(0.09, $light->linearAttenuation); + $this->assertEquals(0.032, $light->quadraticAttenuation); + } + + public function testSetAttenuationFromRange(): void + { + $light = new SpotLightComponent(range: 50.0); + $light->setAttenuationFromRange(); + + $this->assertEquals(1.0, $light->constantAttenuation); + $this->assertEqualsWithDelta(4.5 / 50.0, $light->linearAttenuation, 0.0001); + $this->assertEqualsWithDelta(75.0 / 2500.0, $light->quadraticAttenuation, 0.0001); + } +} diff --git a/tests/Component/StateMachineComponentTest.php b/tests/Component/StateMachineComponentTest.php new file mode 100644 index 0000000..4e5d6da --- /dev/null +++ b/tests/Component/StateMachineComponentTest.php @@ -0,0 +1,27 @@ +assertNull($comp->stateMachine); + $this->assertEmpty($comp->blackboard); + $this->assertTrue($comp->enabled); + } + + public function testWithStateMachine(): void + { + $sm = new StateMachine(); + $comp = new StateMachineComponent($sm); + + $this->assertSame($sm, $comp->stateMachine); + } +} diff --git a/tests/Component/TerrainComponentTest.php b/tests/Component/TerrainComponentTest.php new file mode 100644 index 0000000..0caa28e --- /dev/null +++ b/tests/Component/TerrainComponentTest.php @@ -0,0 +1,29 @@ +assertNull($comp->terrain); + $this->assertNull($comp->blendMap); + $this->assertCount(4, $comp->layerTextures); + $this->assertCount(4, $comp->layerTiling); + $this->assertTrue($comp->castsShadows); + } + + public function testLayerTilingDefaults(): void + { + $comp = new TerrainComponent(); + + foreach ($comp->layerTiling as $tiling) { + $this->assertEquals(10.0, $tiling); + } + } +} diff --git a/tests/Component/TilemapTest.php b/tests/Component/TilemapTest.php new file mode 100644 index 0000000..f71a249 --- /dev/null +++ b/tests/Component/TilemapTest.php @@ -0,0 +1,160 @@ +width = 4; + $map->height = 4; + $map->tiles = array_fill(0, 16, 0); + + $map->setTile(1, 2, 5); + $this->assertSame(5, $map->getTile(1, 2)); + $this->assertSame(0, $map->getTile(0, 0)); + } + + public function testOutOfBoundsReturnsZero(): void + { + $map = new Tilemap(); + $map->width = 2; + $map->height = 2; + $map->tiles = [1, 2, 3, 4]; + + $this->assertSame(0, $map->getTile(-1, 0)); + $this->assertSame(0, $map->getTile(0, -1)); + $this->assertSame(0, $map->getTile(2, 0)); + $this->assertSame(0, $map->getTile(0, 2)); + } + + public function testGetTileUV(): void + { + $map = new Tilemap(); + $map->tileSize = 32; + $map->tilesetColumns = 4; + + // Tileset 128x128, 4 columns => tile 1 is top-left + $uv = $map->getTileUV(1, 128, 128); + $this->assertEqualsWithDelta(0.0, $uv[0], 0.001); // u + $this->assertEqualsWithDelta(0.0, $uv[1], 0.001); // v + $this->assertEqualsWithDelta(0.25, $uv[2], 0.001); // w + $this->assertEqualsWithDelta(0.25, $uv[3], 0.001); // h + + // Tile 5 is second row, first column + $uv = $map->getTileUV(5, 128, 128); + $this->assertEqualsWithDelta(0.0, $uv[0], 0.001); + $this->assertEqualsWithDelta(0.25, $uv[1], 0.001); + } + + public function testAutoTileBitmask(): void + { + // 3x3 grid, center tile is terrain 1, surrounded + $map = new Tilemap(); + $map->width = 3; + $map->height = 3; + $map->tiles = [ + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + ]; + + // Center (1,1): top=1, right=1, bottom=1, left=1 => mask = 1+2+4+8 = 15 + $this->assertSame(15, $map->getAutoTileBitmask(1, 1)); + + // Top-center (1,0): no top, right=0, bottom=1, left=0 => mask = 4 + $this->assertSame(4, $map->getAutoTileBitmask(1, 0)); + + // Left-center (0,1): top=0, right=1, bottom=0, left=0 => mask = 2 + $this->assertSame(2, $map->getAutoTileBitmask(0, 1)); + } + + public function testAutoTileBitmaskCornerCase(): void + { + // Single tile alone + $map = new Tilemap(); + $map->width = 3; + $map->height = 3; + $map->tiles = [ + 0, 0, 0, + 0, 1, 0, + 0, 0, 0, + ]; + + // No neighbors => mask = 0 + $this->assertSame(0, $map->getAutoTileBitmask(1, 1)); + } + + public function testResolveAutoTile(): void + { + $map = new Tilemap(); + $map->width = 3; + $map->height = 3; + $map->tiles = [ + 0, 1, 0, + 1, 1, 1, + 0, 1, 0, + ]; + $map->autoTile = true; + $map->autoTileMap = [ + 1 => [ + 0 => 10, // isolated + 15 => 20, // fully surrounded + 4 => 11, // only bottom neighbor + 2 => 12, // only right neighbor + ], + ]; + + // Center: fully surrounded => 20 + $this->assertSame(20, $map->resolveAutoTile(1, 1)); + + // Top-center: only bottom neighbor => 11 + $this->assertSame(11, $map->resolveAutoTile(1, 0)); + + // Left-center: only right neighbor => 12 + $this->assertSame(12, $map->resolveAutoTile(0, 1)); + + // Bottom-center: mask=1 (only top), not in map => fallback to raw tile ID (1) + $this->assertSame(1, $map->resolveAutoTile(1, 2)); + } + + public function testResolveAutoTileDisabled(): void + { + $map = new Tilemap(); + $map->width = 1; + $map->height = 1; + $map->tiles = [5]; + $map->autoTile = false; + + $this->assertSame(5, $map->resolveAutoTile(0, 0)); + } + + public function testBakeAutoTiles(): void + { + $map = new Tilemap(); + $map->width = 3; + $map->height = 1; + $map->tiles = [1, 1, 1]; + $map->autoTile = true; + $map->autoTileMap = [ + 1 => [ + 2 => 10, // only right + 10 => 11, // left + right + 8 => 12, // only left + ], + ]; + + $baked = $map->bakeAutoTiles(); + + // Tile 0: right neighbor => mask=2 => 10 + $this->assertSame(10, $baked[0]); + // Tile 1: left + right => mask=2+8=10 => 11 + $this->assertSame(11, $baked[1]); + // Tile 2: left neighbor => mask=8 => 12 + $this->assertSame(12, $baked[2]); + } +} diff --git a/tests/Dialogue/DialogueManagerTest.php b/tests/Dialogue/DialogueManagerTest.php new file mode 100644 index 0000000..c287292 --- /dev/null +++ b/tests/Dialogue/DialogueManagerTest.php @@ -0,0 +1,207 @@ + 'quest', + 'start' => 'start', + 'nodes' => [ + [ + 'id' => 'start', + 'speaker' => 'Guard', + 'text' => 'Halt! Who goes there?', + 'choices' => [ + ['text' => 'A friend.', 'next' => 'friendly'], + ['text' => 'None of your business.', 'next' => 'hostile'], + [ + 'text' => 'I have a pass.', + 'next' => 'pass', + 'condition' => 'has_pass', + ], + ], + ], + [ + 'id' => 'friendly', + 'speaker' => 'Guard', + 'text' => 'Welcome, {player_name}!', + 'next' => 'end', + 'actions' => [ + ['type' => 'set', 'target' => 'guard_disposition', 'value' => 'friendly'], + ], + ], + [ + 'id' => 'hostile', + 'speaker' => 'Guard', + 'text' => 'Move along then.', + 'actions' => [ + ['type' => 'add', 'target' => 'reputation', 'value' => -5], + ], + ], + [ + 'id' => 'pass', + 'speaker' => 'Guard', + 'text' => 'Very well, proceed.', + ], + [ + 'id' => 'end', + 'speaker' => 'Guard', + 'text' => 'Safe travels!', + ], + ], + ]); + } + + public function testStartDialogue(): void + { + $mgr = new DialogueManager(); + $mgr->registerTree($this->buildTree()); + + $node = $mgr->startDialogue('quest'); + + $this->assertNotNull($node); + $this->assertEquals('start', $node->id); + $this->assertTrue($mgr->isActive()); + } + + public function testStartNonexistentDialogue(): void + { + $mgr = new DialogueManager(); + $this->assertNull($mgr->startDialogue('missing')); + $this->assertFalse($mgr->isActive()); + } + + public function testAdvance(): void + { + $mgr = new DialogueManager(); + $mgr->registerTree($this->buildTree()); + + $mgr->startDialogue('quest'); + $node = $mgr->selectChoice(0); // "A friend." + + $this->assertNotNull($node); + $this->assertEquals('friendly', $node->id); + $this->assertEquals('friendly', $mgr->getVariable('guard_disposition')); + + // advance to 'end' + $end = $mgr->advance(); + $this->assertNotNull($end); + $this->assertEquals('end', $end->id); + + // advance past end + $null = $mgr->advance(); + $this->assertNull($null); + $this->assertFalse($mgr->isActive()); + } + + public function testHostilePathAction(): void + { + $mgr = new DialogueManager(); + $mgr->registerTree($this->buildTree()); + $mgr->setVariable('reputation', 10); + + $mgr->startDialogue('quest'); + $mgr->selectChoice(1); // "None of your business." + + $this->assertEquals(5, $mgr->getVariable('reputation')); // 10 + (-5) = 5 + } + + public function testConditionalChoice(): void + { + $mgr = new DialogueManager(); + $mgr->registerTree($this->buildTree()); + + $mgr->startDialogue('quest'); + + // without has_pass, only 2 choices available + $choices = $mgr->getAvailableChoices(); + $this->assertCount(2, $choices); + + // end and restart with pass + $mgr->endDialogue(); + $mgr->setVariable('has_pass', true); + $mgr->startDialogue('quest'); + + $choices = $mgr->getAvailableChoices(); + $this->assertCount(3, $choices); + $this->assertEquals('I have a pass.', $choices[2]->text); + } + + public function testTextInterpolation(): void + { + $mgr = new DialogueManager(); + $mgr->setVariable('player_name', 'Aragorn'); + + $result = $mgr->interpolateText('Welcome, {player_name}! You have {gold} gold.'); + $this->assertEquals('Welcome, Aragorn! You have {gold} gold.', $result); + } + + public function testConditionEvaluation(): void + { + $mgr = new DialogueManager(); + + // truthy + $mgr->setVariable('flag', true); + $this->assertTrue($mgr->evaluateCondition('flag')); + + // falsy + $mgr->setVariable('empty_flag', false); + $this->assertFalse($mgr->evaluateCondition('empty_flag')); + + // negation + $this->assertFalse($mgr->evaluateCondition('!flag')); + $this->assertTrue($mgr->evaluateCondition('!empty_flag')); + + // comparison + $mgr->setVariable('level', 10); + $this->assertTrue($mgr->evaluateCondition('level >= 5')); + $this->assertFalse($mgr->evaluateCondition('level < 5')); + $this->assertTrue($mgr->evaluateCondition('level == 10')); + $this->assertTrue($mgr->evaluateCondition('level != 99')); + $this->assertFalse($mgr->evaluateCondition('level > 10')); + $this->assertTrue($mgr->evaluateCondition('level <= 10')); + + // string comparison + $mgr->setVariable('class', 'warrior'); + $this->assertTrue($mgr->evaluateCondition('class == warrior')); + } + + public function testInvalidChoiceIndex(): void + { + $mgr = new DialogueManager(); + $mgr->registerTree($this->buildTree()); + $mgr->startDialogue('quest'); + + $this->assertNull($mgr->selectChoice(-1)); + $this->assertNull($mgr->selectChoice(99)); + } + + public function testEndDialogue(): void + { + $mgr = new DialogueManager(); + $mgr->registerTree($this->buildTree()); + $mgr->startDialogue('quest'); + + $mgr->endDialogue(); + $this->assertFalse($mgr->isActive()); + $this->assertNull($mgr->getActiveNode()); + } + + public function testVariables(): void + { + $mgr = new DialogueManager(); + $mgr->setVariable('key', 'value'); + + $this->assertEquals('value', $mgr->getVariable('key')); + $this->assertNull($mgr->getVariable('missing')); + $this->assertEquals('default', $mgr->getVariable('missing', 'default')); + $this->assertArrayHasKey('key', $mgr->getVariables()); + } +} diff --git a/tests/Dialogue/DialogueTreeTest.php b/tests/Dialogue/DialogueTreeTest.php new file mode 100644 index 0000000..e29e223 --- /dev/null +++ b/tests/Dialogue/DialogueTreeTest.php @@ -0,0 +1,90 @@ + 'test_dialogue', + 'start' => 'greeting', + 'nodes' => [ + [ + 'id' => 'greeting', + 'speaker' => 'NPC', + 'text' => 'Hello, traveler!', + 'next' => 'question', + ], + [ + 'id' => 'question', + 'speaker' => 'NPC', + 'text' => 'What brings you here?', + 'choices' => [ + ['text' => 'Adventure!', 'next' => 'adventure'], + ['text' => 'Trade', 'next' => 'trade'], + ], + ], + [ + 'id' => 'adventure', + 'speaker' => 'NPC', + 'text' => 'A brave soul!', + ], + [ + 'id' => 'trade', + 'speaker' => 'NPC', + 'text' => 'Let me show you my wares.', + 'actions' => [ + ['type' => 'set', 'target' => 'shop_open', 'value' => true], + ], + ], + ], + ]; + + $tree = DialogueTree::fromArray($data); + + $this->assertEquals('test_dialogue', $tree->id); + $this->assertEquals('greeting', $tree->startNodeId); + $this->assertTrue($tree->hasNode('greeting')); + $this->assertTrue($tree->hasNode('question')); + $this->assertTrue($tree->hasNode('adventure')); + $this->assertTrue($tree->hasNode('trade')); + $this->assertFalse($tree->hasNode('nonexistent')); + + $greeting = $tree->getNode('greeting'); + $this->assertNotNull($greeting); + $this->assertEquals('NPC', $greeting->speaker); + $this->assertEquals('Hello, traveler!', $greeting->text); + $this->assertEquals('question', $greeting->next); + + $question = $tree->getNode('question'); + $this->assertNotNull($question); + $this->assertCount(2, $question->choices); + $this->assertEquals('Adventure!', $question->choices[0]->text); + $this->assertEquals('adventure', $question->choices[0]->next); + + $trade = $tree->getNode('trade'); + $this->assertNotNull($trade); + $this->assertCount(1, $trade->actions); + $this->assertEquals('set', $trade->actions[0]->type); + $this->assertEquals('shop_open', $trade->actions[0]->target); + $this->assertTrue($trade->actions[0]->value); + } + + public function testGetNodes(): void + { + $tree = DialogueTree::fromArray([ + 'id' => 'test', + 'start' => 'a', + 'nodes' => [ + ['id' => 'a', 'text' => 'A'], + ['id' => 'b', 'text' => 'B'], + ], + ]); + + $this->assertCount(2, $tree->getNodes()); + } +} diff --git a/tests/ECS/ComponentRegistryTest.php b/tests/ECS/ComponentRegistryTest.php new file mode 100644 index 0000000..30307d4 --- /dev/null +++ b/tests/ECS/ComponentRegistryTest.php @@ -0,0 +1,91 @@ +registry = new ComponentRegistry(); + } + + public function testRegisterAndResolve(): void + { + $this->registry->register('SpriteRenderer', SpriteRenderer::class); + $this->assertSame(SpriteRenderer::class, $this->registry->resolve('SpriteRenderer')); + } + + public function testHas(): void + { + $this->assertFalse($this->registry->has('SpriteRenderer')); + $this->registry->register('SpriteRenderer', SpriteRenderer::class); + $this->assertTrue($this->registry->has('SpriteRenderer')); + } + + public function testResolveUnknownThrows(): void + { + $this->expectException(ECSException::class); + $this->registry->resolve('NonExistent'); + } + + public function testRegisterInvalidClassThrows(): void + { + $this->expectException(ECSException::class); + $this->registry->register('Fake', 'VISU\\Component\\NonExistent'); + } + + public function testCreate(): void + { + $this->registry->register('SpriteRenderer', SpriteRenderer::class); + $component = $this->registry->create('SpriteRenderer', [ + 'sprite' => 'test.png', + 'sortingLayer' => 'Foreground', + 'opacity' => 0.5, + ]); + + $this->assertInstanceOf(SpriteRenderer::class, $component); + /** @var SpriteRenderer $component */ + $this->assertSame('test.png', $component->sprite); + $this->assertSame('Foreground', $component->sortingLayer); + $this->assertSame(0.5, $component->opacity); + } + + public function testCreateIgnoresUnknownProperties(): void + { + $this->registry->register('NameComponent', NameComponent::class); + $component = $this->registry->create('NameComponent', [ + 'name' => 'Test', + 'nonExistent' => 'value', + ]); + + $this->assertInstanceOf(NameComponent::class, $component); + /** @var NameComponent $component */ + $this->assertSame('Test', $component->name); + } + + public function testGetTypeName(): void + { + $this->registry->register('SpriteRenderer', SpriteRenderer::class); + $this->assertSame('SpriteRenderer', $this->registry->getTypeName(SpriteRenderer::class)); + $this->assertNull($this->registry->getTypeName(NameComponent::class)); + } + + public function testGetRegisteredTypes(): void + { + $this->registry->register('SpriteRenderer', SpriteRenderer::class); + $this->registry->register('NameComponent', NameComponent::class); + + $types = $this->registry->getRegisteredTypes(); + $this->assertContains('SpriteRenderer', $types); + $this->assertContains('NameComponent', $types); + $this->assertCount(2, $types); + } +} diff --git a/tests/Geo/Raycast2DTest.php b/tests/Geo/Raycast2DTest.php new file mode 100644 index 0000000..45d1af5 --- /dev/null +++ b/tests/Geo/Raycast2DTest.php @@ -0,0 +1,147 @@ +entities = new EntityRegistry(); + $this->entities->registerComponent(Transform::class); + $this->entities->registerComponent(BoxCollider2D::class); + $this->entities->registerComponent(CircleCollider2D::class); + } + + private function createBoxEntity(float $x, float $y, float $hw = 16.0, float $hh = 16.0): int + { + $e = $this->entities->create(); + $t = new Transform(); + $t->position = new Vec3($x, $y, 0); + $this->entities->attach($e, $t); + + $box = new BoxCollider2D(); + $box->halfWidth = $hw; + $box->halfHeight = $hh; + $this->entities->attach($e, $box); + return $e; + } + + private function createCircleEntity(float $x, float $y, float $radius = 16.0): int + { + $e = $this->entities->create(); + $t = new Transform(); + $t->position = new Vec3($x, $y, 0); + $this->entities->attach($e, $t); + + $circle = new CircleCollider2D(); + $circle->radius = $radius; + $this->entities->attach($e, $circle); + return $e; + } + + public function testPointQueryBox(): void + { + $e = $this->createBoxEntity(50, 50, 20, 20); + + $hits = Raycast2D::pointQuery($this->entities, 55, 55); + $this->assertContains($e, $hits); + + $misses = Raycast2D::pointQuery($this->entities, 200, 200); + $this->assertEmpty($misses); + } + + public function testPointQueryCircle(): void + { + $e = $this->createCircleEntity(50, 50, 20); + + $hits = Raycast2D::pointQuery($this->entities, 55, 50); + $this->assertContains($e, $hits); + + $misses = Raycast2D::pointQuery($this->entities, 100, 100); + $this->assertEmpty($misses); + } + + public function testPointQueryMultiple(): void + { + $e1 = $this->createBoxEntity(0, 0, 50, 50); + $e2 = $this->createCircleEntity(0, 0, 30); + + $hits = Raycast2D::pointQuery($this->entities, 5, 5); + $this->assertContains($e1, $hits); + $this->assertContains($e2, $hits); + $this->assertCount(2, $hits); + } + + public function testRaycastHitsBox(): void + { + $e = $this->createBoxEntity(100, 0, 20, 20); + + $result = Raycast2D::cast($this->entities, 0, 0, 1, 0); + $this->assertNotNull($result); + $this->assertSame($e, $result->entityId); + $this->assertEqualsWithDelta(80.0, $result->distance, 0.1); + $this->assertEqualsWithDelta(80.0, $result->hitX, 0.1); + } + + public function testRaycastHitsCircle(): void + { + $e = $this->createCircleEntity(100, 0, 20); + + $result = Raycast2D::cast($this->entities, 0, 0, 1, 0); + $this->assertNotNull($result); + $this->assertSame($e, $result->entityId); + $this->assertEqualsWithDelta(80.0, $result->distance, 0.1); + } + + public function testRaycastMiss(): void + { + $this->createBoxEntity(100, 100, 10, 10); + + // Ray going right, box is at (100,100) — miss + $result = Raycast2D::cast($this->entities, 0, 0, 1, 0); + $this->assertNull($result); + } + + public function testRaycastClosest(): void + { + $e1 = $this->createBoxEntity(50, 0, 10, 10); // closer + $e2 = $this->createBoxEntity(150, 0, 10, 10); // farther + + $result = Raycast2D::cast($this->entities, 0, 0, 1, 0); + $this->assertNotNull($result); + $this->assertSame($e1, $result->entityId); + } + + public function testRaycastMaxDistance(): void + { + $this->createBoxEntity(200, 0, 10, 10); + + $result = Raycast2D::cast($this->entities, 0, 0, 1, 0, 100.0); + $this->assertNull($result); // Beyond max distance + } + + public function testPointQueryLayerFilter(): void + { + $e = $this->createBoxEntity(0, 0, 20, 20); + $box = $this->entities->get($e, BoxCollider2D::class); + $box->layer = 4; + + // Query with mask that doesn't include layer 4 + $hits = Raycast2D::pointQuery($this->entities, 5, 5, 2); + $this->assertEmpty($hits); + + // Query with mask that includes layer 4 + $hits = Raycast2D::pointQuery($this->entities, 5, 5, 4); + $this->assertContains($e, $hits); + } +} diff --git a/tests/Geo/Raycast3DTest.php b/tests/Geo/Raycast3DTest.php new file mode 100644 index 0000000..d9d99e5 --- /dev/null +++ b/tests/Geo/Raycast3DTest.php @@ -0,0 +1,100 @@ +assertNotNull($t); + $this->assertEqualsWithDelta(4.0, $t, 0.001); + } + + public function testRaySphereMiss(): void + { + $origin = new Vec3(0.0, 5.0, -5.0); + $dir = new Vec3(0.0, 0.0, 1.0); + $center = new Vec3(0.0, 0.0, 0.0); + + $t = Raycast3D::raySphereIntersect($origin, $dir, $center, 1.0); + $this->assertNull($t); + } + + public function testRaySphereInsideHit(): void + { + // origin inside sphere + $origin = new Vec3(0.0, 0.0, 0.0); + $dir = new Vec3(0.0, 0.0, 1.0); + $center = new Vec3(0.0, 0.0, 0.0); + + $t = Raycast3D::raySphereIntersect($origin, $dir, $center, 2.0); + $this->assertNotNull($t); + $this->assertEqualsWithDelta(2.0, $t, 0.001); + } + + public function testRayCapsuleHitCylinder(): void + { + $origin = new Vec3(-5.0, 0.0, 0.0); + $dir = new Vec3(1.0, 0.0, 0.0); + $center = new Vec3(0.0, 0.0, 0.0); + + $t = Raycast3D::rayCapsuleIntersect($origin, $dir, $center, 1.0, 0.5); + $this->assertNotNull($t); + $this->assertEqualsWithDelta(4.5, $t, 0.001); + } + + public function testRayCapsuleHitTopCap(): void + { + // shoot down at top hemisphere + $origin = new Vec3(0.0, 5.0, 0.0); + $dir = new Vec3(0.0, -1.0, 0.0); + $center = new Vec3(0.0, 0.0, 0.0); + $halfHeight = 1.0; + $radius = 0.5; + + $t = Raycast3D::rayCapsuleIntersect($origin, $dir, $center, $halfHeight, $radius); + $this->assertNotNull($t); + // top of capsule is at y = halfHeight + radius = 1.5 + $this->assertEqualsWithDelta(3.5, $t, 0.001); + } + + public function testRayCapsuleMiss(): void + { + $origin = new Vec3(-5.0, 10.0, 0.0); + $dir = new Vec3(1.0, 0.0, 0.0); + $center = new Vec3(0.0, 0.0, 0.0); + + $t = Raycast3D::rayCapsuleIntersect($origin, $dir, $center, 1.0, 0.5); + $this->assertNull($t); + } + + public function testRaySphereZeroDirection(): void + { + $origin = new Vec3(0.0, 0.0, 0.0); + $dir = new Vec3(0.0, 0.0, 0.0); + $center = new Vec3(1.0, 0.0, 0.0); + + $t = Raycast3D::raySphereIntersect($origin, $dir, $center, 0.5); + $this->assertNull($t); + } + + public function testRaySphereBehind(): void + { + // sphere is behind the ray + $origin = new Vec3(0.0, 0.0, 5.0); + $dir = new Vec3(0.0, 0.0, 1.0); + $center = new Vec3(0.0, 0.0, 0.0); + + $t = Raycast3D::raySphereIntersect($origin, $dir, $center, 1.0); + $this->assertNull($t); + } +} diff --git a/tests/Graphics/Animation/AnimationChannelTest.php b/tests/Graphics/Animation/AnimationChannelTest.php new file mode 100644 index 0000000..2cb3177 --- /dev/null +++ b/tests/Graphics/Animation/AnimationChannelTest.php @@ -0,0 +1,98 @@ +times = [0.0, 1.0]; + $channel->values = [ + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + ]; + + /** @var Vec3 $result */ + $result = $channel->sample(0.5); + $this->assertInstanceOf(Vec3::class, $result); + $this->assertEqualsWithDelta(5.0, $result->x, 0.001); + } + + public function testStepInterpolation(): void + { + $channel = new AnimationChannel(0, 'translation'); + $channel->interpolation = AnimationInterpolation::Step; + $channel->times = [0.0, 1.0]; + $channel->values = [ + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + ]; + + /** @var Vec3 $result */ + $result = $channel->sample(0.5); + $this->assertEqualsWithDelta(0.0, $result->x, 0.001); + } + + public function testClampBeforeFirstKeyframe(): void + { + $channel = new AnimationChannel(0, 'translation'); + $channel->times = [1.0, 2.0]; + $channel->values = [ + new Vec3(5, 0, 0), + new Vec3(10, 0, 0), + ]; + + /** @var Vec3 $result */ + $result = $channel->sample(0.0); + $this->assertEqualsWithDelta(5.0, $result->x, 0.001); + } + + public function testClampAfterLastKeyframe(): void + { + $channel = new AnimationChannel(0, 'translation'); + $channel->times = [0.0, 1.0]; + $channel->values = [ + new Vec3(0, 0, 0), + new Vec3(10, 0, 0), + ]; + + /** @var Vec3 $result */ + $result = $channel->sample(5.0); + $this->assertEqualsWithDelta(10.0, $result->x, 0.001); + } + + public function testRotationSlerp(): void + { + $channel = new AnimationChannel(0, 'rotation'); + $channel->times = [0.0, 1.0]; + $channel->values = [ + new Quat(1, 0, 0, 0), // identity + new Quat(0, 0, 1, 0), // 180° around Y + ]; + + $result = $channel->sample(0.5); + $this->assertInstanceOf(Quat::class, $result); + } + + public function testEmptyChannelReturnsDefault(): void + { + $channel = new AnimationChannel(0, 'translation'); + + $result = $channel->sample(0.0); + $this->assertInstanceOf(Vec3::class, $result); + } + + public function testEmptyRotationChannelReturnsQuat(): void + { + $channel = new AnimationChannel(0, 'rotation'); + + $result = $channel->sample(0.0); + $this->assertInstanceOf(Quat::class, $result); + } +} diff --git a/tests/Graphics/Animation/SkeletonTest.php b/tests/Graphics/Animation/SkeletonTest.php new file mode 100644 index 0000000..2d627b7 --- /dev/null +++ b/tests/Graphics/Animation/SkeletonTest.php @@ -0,0 +1,40 @@ +addBone($bone); + + $this->assertEquals(1, $skeleton->boneCount()); + $this->assertSame($bone, $skeleton->getBoneByName('root')); + $this->assertEquals(0, $skeleton->getBoneIndex('root')); + } + + public function testBoneHierarchy(): void + { + $skeleton = new Skeleton(); + $skeleton->addBone(new Bone(0, 'root', -1)); + $skeleton->addBone(new Bone(1, 'spine', 0)); + $skeleton->addBone(new Bone(2, 'head', 1)); + + $this->assertEquals(3, $skeleton->boneCount()); + $this->assertEquals(0, $skeleton->bones[1]->parentIndex); + $this->assertEquals(1, $skeleton->bones[2]->parentIndex); + } + + public function testUnknownBoneReturnsNull(): void + { + $skeleton = new Skeleton(); + $this->assertNull($skeleton->getBoneByName('nonexistent')); + $this->assertEquals(-1, $skeleton->getBoneIndex('nonexistent')); + } +} diff --git a/tests/Graphics/GLValidationTest.php b/tests/Graphics/GLValidationTest.php new file mode 100644 index 0000000..c4959a6 --- /dev/null +++ b/tests/Graphics/GLValidationTest.php @@ -0,0 +1,494 @@ +createWindow(); + $this->gl = self::$glstate; + + // reset GL state and GLState cache to prevent cross-test pollution + glUseProgram(0); + $this->gl->currentProgram = 0; + glBindVertexArray(0); + $this->gl->currentVertexArray = 0; + glBindBuffer(GL_ARRAY_BUFFER, 0); + $this->gl->currentVertexArrayBuffer = 0; + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + $this->gl->currentIndexBuffer = 0; + glBindFramebuffer(GL_FRAMEBUFFER, 0); + + GLValidator::drainErrors(); + } + + public function testNoErrorsOnCleanState(): void + { + $errors = GLValidator::drainErrors(); + $this->assertEmpty($errors, 'Expected no GL errors on a clean context'); + } + + public function testShaderCompilationAndLinking(): void + { + $shaders = new ShaderCollection($this->gl, VISU_PATH_FRAMEWORK_RESOURCES_SHADER); + $shaders->enableVISUIncludes(); + $shaders->addVISUShaders(); + + $failedShaders = []; + $shaders->loadAll(function (string $name) use (&$failedShaders) { + $errors = GLValidator::drainErrors(); + if (!empty($errors)) { + $names = array_map(fn($e) => $e['name'] . ' (' . $e['hex'] . ')', $errors); + $failedShaders[$name] = implode(', ', $names); + } + }); + + $this->assertEmpty( + $failedShaders, + 'GL errors during shader compilation: ' . print_r($failedShaders, true) + ); + } + + public function testFullscreenQuadDrawNoErrors(): void + { + $this->doFullscreenQuadDraw(); + $this->assertTrue(true); + } + + private function doFullscreenQuadDraw(): void + { + $shader = new ShaderProgram($this->gl); + $shader->attach(new ShaderStage(ShaderStage::VERTEX, <<<'GLSL' +#version 330 core +layout (location = 0) in vec3 a_position; +layout (location = 1) in vec2 a_uv; +out vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 1.0); +} +GLSL)); + $shader->attach(new ShaderStage(ShaderStage::FRAGMENT, <<<'GLSL' +#version 330 core +in vec2 v_uv; +out vec4 fragment_color; +void main() { + fragment_color = vec4(v_uv, 0.0, 1.0); +} +GLSL)); + $shader->link(); + + $quad = new QuadVertexArray($this->gl); + + GLValidator::drainErrors(); + $shader->use(); + $quad->draw(); + GLValidator::check('Fullscreen quad draw'); + + // $shader and $quad destructors run here, freeing GL resources + } + + public function testFramebufferCreationAndRendering(): void + { + $fbo = 0; + glGenFramebuffers(1, $fbo); + glBindFramebuffer(GL_FRAMEBUFFER, $fbo); + + $colorTex = 0; + glGenTextures(1, $colorTex); + glBindTexture(GL_TEXTURE_2D, $colorTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, $colorTex, 0); + + $depthTex = 0; + glGenTextures(1, $depthTex); + glBindTexture(GL_TEXTURE_2D, $depthTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, 64, 64, 0, GL_DEPTH_COMPONENT, GL_FLOAT, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, $depthTex, 0); + + glDrawBuffers(1, GL_COLOR_ATTACHMENT0); + + $status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + $this->assertEquals(GL_FRAMEBUFFER_COMPLETE, $status, 'Framebuffer not complete, status: 0x' . dechex($status)); + + GLValidator::check('FBO creation'); + + glViewport(0, 0, 64, 64); + glClearColor(1.0, 0.0, 0.0, 1.0); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + GLValidator::check('FBO clear'); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, $fbo); + glDeleteTextures(1, $colorTex); + glDeleteTextures(1, $depthTex); + } + + public function testMultipleColorAttachments(): void + { + $fbo = 0; + glGenFramebuffers(1, $fbo); + glBindFramebuffer(GL_FRAMEBUFFER, $fbo); + + $textures = []; + $attachments = []; + for ($i = 0; $i < 4; $i++) { + $tex = 0; + glGenTextures(1, $tex); + glBindTexture(GL_TEXTURE_2D, $tex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, 64, 64, 0, GL_RGBA, GL_FLOAT, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + $i, GL_TEXTURE_2D, $tex, 0); + $textures[] = $tex; + $attachments[] = GL_COLOR_ATTACHMENT0 + $i; + } + + glDrawBuffers(count($attachments), ...$attachments); + + $status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + $this->assertEquals(GL_FRAMEBUFFER_COMPLETE, $status, 'Multi-attachment FBO not complete, status: 0x' . dechex($status)); + + GLValidator::check('Multi-attachment FBO'); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, $fbo); + foreach ($textures as $tex) { + glDeleteTextures(1, $tex); + } + } + + /** + * Tests the dummy texture pattern used in PBRDeferredLightPass. + * Verifies that binding 1x1 dummy textures to unused sampler slots + * prevents GL_INVALID_OPERATION on macOS. + */ + public function testDummyTextureFixesSamplerValidation(): void + { + $shader = new ShaderProgram($this->gl); + $shader->attach(new ShaderStage(ShaderStage::VERTEX, <<<'GLSL' +#version 330 core +layout (location = 0) in vec3 a_position; +layout (location = 1) in vec2 a_uv; +out vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 1.0); +} +GLSL)); + $shader->attach(new ShaderStage(ShaderStage::FRAGMENT, <<<'GLSL' +#version 330 core +in vec2 v_uv; +out vec4 fragment_color; +uniform sampler2D tex_used; +uniform sampler2D tex_shadow_0; +uniform sampler2D tex_shadow_1; +uniform sampler2D tex_shadow_2; +uniform sampler2D tex_shadow_3; +uniform int num_shadows; +void main() { + vec4 color = texture(tex_used, v_uv); + float shadow = 1.0; + if (num_shadows > 0) shadow *= texture(tex_shadow_0, v_uv).r; + if (num_shadows > 1) shadow *= texture(tex_shadow_1, v_uv).r; + if (num_shadows > 2) shadow *= texture(tex_shadow_2, v_uv).r; + if (num_shadows > 3) shadow *= texture(tex_shadow_3, v_uv).r; + fragment_color = color * shadow; +} +GLSL)); + $shader->link(); + + // create main texture on unit 0 + $mainTex = 0; + glGenTextures(1, $mainTex); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, $mainTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + + // create a 1x1 dummy texture for unused sampler slots + $dummyTex = 0; + glGenTextures(1, $dummyTex); + + // bind dummy to all shadow sampler slots (units 1-4) + for ($i = 0; $i < 4; $i++) { + $unit = 1 + $i; + glActiveTexture(GL_TEXTURE0 + $unit); + glBindTexture(GL_TEXTURE_2D, $dummyTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, 1, 1, 0, GL_RED, GL_UNSIGNED_BYTE, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + } + + $shader->setUniform1i('tex_used', 0); + for ($i = 0; $i < 4; $i++) { + $shader->setUniform1i("tex_shadow_{$i}", 1 + $i); + } + $shader->setUniform1i('num_shadows', 0); + + // create quad and drain any errors from setup + $quad = new QuadVertexArray($this->gl); + GLValidator::drainErrors(); + + $shader->use(); + GLValidator::drainErrors(); + + $quad->draw(); + + $errors = GLValidator::drainErrors(); + $this->assertEmpty( + $errors, + 'GL errors during draw with dummy textures: ' . json_encode($errors) + ); + + glDeleteTextures(1, $mainTex); + glDeleteTextures(1, $dummyTex); + } + + /** + * Tests cubemap texture creation and binding (used for point light shadows). + */ + public function testCubemapTextureCreationAndBinding(): void + { + $cubemap = 0; + glGenTextures(1, $cubemap); + glBindTexture(GL_TEXTURE_CUBE_MAP, $cubemap); + + for ($face = 0; $face < 6; $face++) { + glTexImage2D( + GL_TEXTURE_CUBE_MAP_POSITIVE_X + $face, + 0, GL_DEPTH_COMPONENT, 64, 64, 0, + GL_DEPTH_COMPONENT, GL_FLOAT, null + ); + } + + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + + GLValidator::check('Cubemap depth texture creation'); + + $fbo = 0; + glGenFramebuffers(1, $fbo); + glBindFramebuffer(GL_FRAMEBUFFER, $fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_CUBE_MAP_POSITIVE_X, $cubemap, 0); + glDrawBuffers(1, GL_NONE); + glReadBuffer(GL_NONE); + + $status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + $this->assertEquals(GL_FRAMEBUFFER_COMPLETE, $status, 'Cubemap FBO not complete, status: 0x' . dechex($status)); + + GLValidator::check('Cubemap FBO'); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, $fbo); + glDeleteTextures(1, $cubemap); + } + + /** + * Tests that a cubemap sampler with a dummy texture doesn't cause errors on draw. + */ + public function testDummyCubemapSamplerValidation(): void + { + $shader = new ShaderProgram($this->gl); + $shader->attach(new ShaderStage(ShaderStage::VERTEX, <<<'GLSL' +#version 330 core +layout (location = 0) in vec3 a_position; +layout (location = 1) in vec2 a_uv; +out vec2 v_uv; +void main() { + v_uv = a_uv; + gl_Position = vec4(a_position, 1.0); +} +GLSL)); + $shader->attach(new ShaderStage(ShaderStage::FRAGMENT, <<<'GLSL' +#version 330 core +in vec2 v_uv; +out vec4 fragment_color; +uniform samplerCube point_shadow_map_0; +uniform samplerCube point_shadow_map_1; +uniform int num_point_shadows; +void main() { + fragment_color = vec4(v_uv, 0.0, 1.0); + if (num_point_shadows > 0) { + fragment_color.r *= texture(point_shadow_map_0, vec3(v_uv, 0.0)).r; + } + if (num_point_shadows > 1) { + fragment_color.g *= texture(point_shadow_map_1, vec3(v_uv, 0.0)).r; + } +} +GLSL)); + $shader->link(); + + // create dummy 1x1 cubemaps + $cubemapIds = []; + for ($s = 0; $s < 2; $s++) { + $cubemap = 0; + glGenTextures(1, $cubemap); + glActiveTexture(GL_TEXTURE0 + $s); + glBindTexture(GL_TEXTURE_CUBE_MAP, $cubemap); + + for ($face = 0; $face < 6; $face++) { + glTexImage2D( + GL_TEXTURE_CUBE_MAP_POSITIVE_X + $face, + 0, GL_R8, 1, 1, 0, + GL_RED, GL_UNSIGNED_BYTE, null + ); + } + + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); + + $cubemapIds[] = $cubemap; + } + + $shader->setUniform1i('point_shadow_map_0', 0); + $shader->setUniform1i('point_shadow_map_1', 1); + $shader->setUniform1i('num_point_shadows', 0); + + // draw into own FBO (use texture unit 2 to avoid clobbering cubemap bindings) + $fbo = 0; + glGenFramebuffers(1, $fbo); + glBindFramebuffer(GL_FRAMEBUFFER, $fbo); + $colorTex = 0; + glGenTextures(1, $colorTex); + glActiveTexture(GL_TEXTURE0 + 2); + glBindTexture(GL_TEXTURE_2D, $colorTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 64, 64, 0, GL_RGBA, GL_UNSIGNED_BYTE, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, $colorTex, 0); + glDrawBuffers(1, GL_COLOR_ATTACHMENT0); + glViewport(0, 0, 64, 64); + + $quad = new QuadVertexArray($this->gl); + GLValidator::drainErrors(); + + $shader->use(); + GLValidator::drainErrors(); + + $quad->draw(); + + $errors = GLValidator::drainErrors(); + $this->assertEmpty( + $errors, + 'GL errors during draw with dummy cubemap textures: ' . json_encode($errors) + ); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, $fbo); + glDeleteTextures(1, $colorTex); + foreach ($cubemapIds as $id) { + glDeleteTextures(1, $id); + } + } + + /** + * Tests GBuffer-style FBO with multiple render targets (MRT). + */ + public function testGBufferMRTSetup(): void + { + $fbo = 0; + glGenFramebuffers(1, $fbo); + glBindFramebuffer(GL_FRAMEBUFFER, $fbo); + + $formats = [ + ['internal' => GL_RGB16F, 'format' => GL_RGB, 'type' => GL_FLOAT], + ['internal' => GL_RGB16F, 'format' => GL_RGB, 'type' => GL_FLOAT], + ['internal' => GL_RGBA8, 'format' => GL_RGBA, 'type' => GL_UNSIGNED_BYTE], + ['internal' => GL_RG8, 'format' => GL_RG, 'type' => GL_UNSIGNED_BYTE], + ['internal' => GL_RGB16F, 'format' => GL_RGB, 'type' => GL_FLOAT], + ['internal' => GL_R8, 'format' => GL_RED, 'type' => GL_UNSIGNED_BYTE], + ]; + + $textures = []; + $attachments = []; + + foreach ($formats as $i => $fmt) { + $tex = 0; + glGenTextures(1, $tex); + glBindTexture(GL_TEXTURE_2D, $tex); + glTexImage2D(GL_TEXTURE_2D, 0, $fmt['internal'], 128, 128, 0, $fmt['format'], $fmt['type'], null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + $i, GL_TEXTURE_2D, $tex, 0); + $textures[] = $tex; + $attachments[] = GL_COLOR_ATTACHMENT0 + $i; + } + + $depthTex = 0; + glGenTextures(1, $depthTex); + glBindTexture(GL_TEXTURE_2D, $depthTex); + glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, 128, 128, 0, GL_DEPTH_COMPONENT, GL_FLOAT, null); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, $depthTex, 0); + + glDrawBuffers(count($attachments), ...$attachments); + + $status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + $this->assertEquals(GL_FRAMEBUFFER_COMPLETE, $status, 'GBuffer FBO not complete, status: 0x' . dechex($status)); + + GLValidator::check('GBuffer MRT setup'); + + glViewport(0, 0, 128, 128); + glClearColor(0.0, 0.0, 0.0, 0.0); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + GLValidator::check('GBuffer clear'); + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, $fbo); + foreach ($textures as $tex) { + glDeleteTextures(1, $tex); + } + glDeleteTextures(1, $depthTex); + } + + public function testGLValidatorCollectorIntegration(): void + { + $validator = new GLValidator(); + + glEnable(99999); // generate GL_INVALID_ENUM + + $validator->collect('test context'); + $this->assertTrue($validator->hasErrors()); + + $formatted = $validator->formatErrors(); + $this->assertStringContainsString('GL_INVALID_ENUM', $formatted); + $this->assertStringContainsString('test context', $formatted); + + $validator->clear(); + $this->assertFalse($validator->hasErrors()); + } +} diff --git a/tests/Graphics/Loader/GltfLoaderTest.php b/tests/Graphics/Loader/GltfLoaderTest.php new file mode 100644 index 0000000..432efea --- /dev/null +++ b/tests/Graphics/Loader/GltfLoaderTest.php @@ -0,0 +1,219 @@ +createMock(\VISU\Graphics\GLState::class); + return new GltfLoader($gl); + } + + public function testLoadThrowsForMissingFile(): void + { + $loader = $this->createLoader(); + $this->expectException(VISUException::class); + $this->expectExceptionMessage('file not found'); + $loader->load('/nonexistent/file.glb'); + } + + public function testLoadThrowsForInvalidGlbMagic(): void + { + $loader = $this->createLoader(); + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_glb_') . '.glb'; + // write data that's long enough for header parsing but has wrong magic + file_put_contents($tmpFile, str_repeat("\x00", 64)); + + try { + $this->expectException(VISUException::class); + $this->expectExceptionMessage('Invalid GLB magic'); + $loader->load($tmpFile); + } finally { + @unlink($tmpFile); + } + } + + public function testLoadThrowsForInvalidGltfJson(): void + { + $loader = $this->createLoader(); + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_gltf_') . '.gltf'; + file_put_contents($tmpFile, '{not valid json!!!}'); + + try { + $this->expectException(VISUException::class); + $this->expectExceptionMessage('Failed to parse'); + $loader->load($tmpFile); + } finally { + @unlink($tmpFile); + } + } + + public function testLoadThrowsForGltfWithoutScene(): void + { + $loader = $this->createLoader(); + + $gltf = [ + 'asset' => ['version' => '2.0'], + // no scenes defined + ]; + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_gltf_') . '.gltf'; + file_put_contents($tmpFile, json_encode($gltf)); + + try { + $this->expectException(VISUException::class); + $this->expectExceptionMessage('No scene'); + $loader->load($tmpFile); + } finally { + @unlink($tmpFile); + } + } + + public function testGlbVersionValidation(): void + { + $loader = $this->createLoader(); + + // build GLB with version 1 (unsupported) + $jsonStr = json_encode(['asset' => ['version' => '2.0']]); + while (strlen($jsonStr) % 4 !== 0) $jsonStr .= ' '; + + $totalLength = 12 + 8 + strlen($jsonStr); + $glb = pack('VVV', 0x46546C67, 1, $totalLength); // version 1 + $glb .= pack('VV', strlen($jsonStr), 0x4E4F534A) . $jsonStr; + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_glb_') . '.glb'; + file_put_contents($tmpFile, $glb); + + try { + $this->expectException(VISUException::class); + $this->expectExceptionMessage('Unsupported GLB version'); + $loader->load($tmpFile); + } finally { + @unlink($tmpFile); + } + } + + public function testValidGlbHeaderParsing(): void + { + $loader = $this->createLoader(); + + // Build valid GLB with empty scene (will fail at mesh construction, not at parsing) + $gltf = [ + 'asset' => ['version' => '2.0'], + 'scene' => 0, + 'scenes' => [['nodes' => []]], + ]; + + $jsonStr = json_encode($gltf); + while (strlen($jsonStr) % 4 !== 0) $jsonStr .= ' '; + + $totalLength = 12 + 8 + strlen($jsonStr); + $glb = pack('VVV', 0x46546C67, 2, $totalLength); + $glb .= pack('VV', strlen($jsonStr), 0x4E4F534A) . $jsonStr; + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_glb_') . '.glb'; + file_put_contents($tmpFile, $glb); + + try { + // empty scene with no nodes should return a model with no meshes + // This tests GLB header parsing + JSON chunk extraction + empty scene handling + // Will segfault if GL calls are made, but empty scene shouldn't trigger any + $model = $loader->load($tmpFile); + $this->assertEquals(basename($tmpFile), $model->name); + $this->assertEmpty($model->meshes); + } finally { + @unlink($tmpFile); + } + } + + public function testValidGltfEmptyScene(): void + { + $loader = $this->createLoader(); + + $gltf = [ + 'asset' => ['version' => '2.0'], + 'scene' => 0, + 'scenes' => [['nodes' => []]], + ]; + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_gltf_') . '.gltf'; + file_put_contents($tmpFile, json_encode($gltf)); + + try { + $model = $loader->load($tmpFile); + $this->assertEquals(basename($tmpFile), $model->name); + $this->assertEmpty($model->meshes); + } finally { + @unlink($tmpFile); + } + } + + public function testGltfNodeWithoutMeshIsSkipped(): void + { + $loader = $this->createLoader(); + + $gltf = [ + 'asset' => ['version' => '2.0'], + 'scene' => 0, + 'scenes' => [['nodes' => [0]]], + 'nodes' => [['name' => 'EmptyNode']], // no mesh reference + ]; + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_gltf_') . '.gltf'; + file_put_contents($tmpFile, json_encode($gltf)); + + try { + $model = $loader->load($tmpFile); + $this->assertEmpty($model->meshes); + } finally { + @unlink($tmpFile); + } + } + + public function testGltfMaterialParsing(): void + { + $loader = $this->createLoader(); + + // Build glTF with a material but no geometry that references it. + // We test that loading succeeds and material is parsed. + $gltf = [ + 'asset' => ['version' => '2.0'], + 'scene' => 0, + 'scenes' => [['nodes' => []]], + 'materials' => [[ + 'name' => 'TestMaterial', + 'pbrMetallicRoughness' => [ + 'baseColorFactor' => [1.0, 0.0, 0.0, 1.0], + 'metallicFactor' => 0.8, + 'roughnessFactor' => 0.2, + ], + 'emissiveFactor' => [0.5, 0.3, 0.1], + 'doubleSided' => true, + ]], + ]; + + $tmpFile = tempnam(sys_get_temp_dir(), 'test_gltf_') . '.gltf'; + file_put_contents($tmpFile, json_encode($gltf)); + + try { + // Materials are parsed but not directly accessible from Model3D + // (they're attached to meshes). Empty scene should load fine. + $model = $loader->load($tmpFile); + $this->assertEmpty($model->meshes); + } finally { + @unlink($tmpFile); + } + } +} diff --git a/tests/Graphics/MaterialTest.php b/tests/Graphics/MaterialTest.php new file mode 100644 index 0000000..eb56c0c --- /dev/null +++ b/tests/Graphics/MaterialTest.php @@ -0,0 +1,83 @@ +assertEquals('default', $mat->name); + $this->assertEquals(0.0, $mat->metallic); + $this->assertEquals(1.0, $mat->roughness); + $this->assertEquals(1.0, $mat->albedoColor->x); + $this->assertEquals(1.0, $mat->albedoColor->y); + $this->assertEquals(1.0, $mat->albedoColor->z); + $this->assertEquals(1.0, $mat->albedoColor->w); + $this->assertEquals(0.0, $mat->emissiveColor->x); + } + + public function testCustomConstructor(): void + { + $mat = new Material( + name: 'gold', + albedoColor: new Vec4(1.0, 0.766, 0.336, 1.0), + metallic: 1.0, + roughness: 0.3, + ); + $this->assertEquals('gold', $mat->name); + $this->assertEquals(1.0, $mat->metallic); + $this->assertEquals(0.3, $mat->roughness); + $this->assertEqualsWithDelta(0.766, $mat->albedoColor->y, 0.001); + } + + public function testHasTexturesReturnsFalseByDefault(): void + { + $mat = new Material(); + $this->assertFalse($mat->hasTextures()); + } + + public function testGetTextureFlagsDefault(): void + { + $mat = new Material(); + $this->assertEquals(0, $mat->getTextureFlags()); + } + + public function testAlphaMode(): void + { + $mat = new Material(); + $this->assertEquals('OPAQUE', $mat->alphaMode); + $this->assertEquals(0.5, $mat->alphaCutoff); + $this->assertFalse($mat->doubleSided); + } + + public function testFlagConstants(): void + { + $this->assertEquals(1, Material::FLAG_ALBEDO_MAP); + $this->assertEquals(2, Material::FLAG_NORMAL_MAP); + $this->assertEquals(4, Material::FLAG_METALLIC_ROUGHNESS_MAP); + $this->assertEquals(8, Material::FLAG_AO_MAP); + $this->assertEquals(16, Material::FLAG_EMISSIVE_MAP); + } + + public function testEmissiveColorDefault(): void + { + $mat = new Material(); + $this->assertEquals(0.0, $mat->emissiveColor->x); + $this->assertEquals(0.0, $mat->emissiveColor->y); + $this->assertEquals(0.0, $mat->emissiveColor->z); + } + + public function testEmissiveColorCustom(): void + { + $mat = new Material(); + $mat->emissiveColor = new Vec3(1.0, 0.5, 0.0); + $this->assertEquals(1.0, $mat->emissiveColor->x); + $this->assertEquals(0.5, $mat->emissiveColor->y); + } +} diff --git a/tests/Graphics/MeshFactoryTest.php b/tests/Graphics/MeshFactoryTest.php new file mode 100644 index 0000000..08a66a6 --- /dev/null +++ b/tests/Graphics/MeshFactoryTest.php @@ -0,0 +1,226 @@ +assertEquals('__primitive_cube', PrimitiveShape::cube->modelId()); + $this->assertEquals('__primitive_sphere', PrimitiveShape::sphere->modelId()); + $this->assertEquals('__primitive_torus', PrimitiveShape::torus->modelId()); + } + + public function testPrimitiveShapeFromString(): void + { + $this->assertSame(PrimitiveShape::cube, PrimitiveShape::from('cube')); + $this->assertSame(PrimitiveShape::capsule, PrimitiveShape::from('capsule')); + } + + public function testAllPrimitiveShapesHaveUniqueModelIds(): void + { + $ids = []; + foreach (PrimitiveShape::cases() as $shape) { + $ids[] = $shape->modelId(); + } + $this->assertCount(count(array_unique($ids)), $ids); + } + + // ----------------------------------------------------------------------- + // Geometry generation (no GL required) + // ----------------------------------------------------------------------- + + /** + * @dataProvider primitiveGeneratorProvider + */ + public function testGeneratedGeometryHasValidVertexStride(MeshGeometry $geometry, string $name): void + { + $this->assertEquals(0, $geometry->vertices->size() % Mesh3D::STRIDE, "{$name}: vertex buffer size must be multiple of stride (12)"); + } + + /** + * @dataProvider primitiveGeneratorProvider + */ + public function testGeneratedGeometryHasValidIndices(MeshGeometry $geometry, string $name): void + { + $this->assertEquals(0, $geometry->indices->size() % 3, "{$name}: index count must be multiple of 3"); + $this->assertTrue($geometry->validate(), "{$name}: indices must be within vertex bounds"); + } + + /** + * @dataProvider primitiveGeneratorProvider + */ + public function testGeneratedGeometryHasVerticesAndIndices(MeshGeometry $geometry, string $name): void + { + $this->assertGreaterThan(0, $geometry->getVertexCount(), "{$name}: must have vertices"); + $this->assertGreaterThan(0, $geometry->getIndexCount(), "{$name}: must have indices"); + $this->assertGreaterThan(0, $geometry->getTriangleCount(), "{$name}: must have triangles"); + } + + /** + * @dataProvider primitiveGeneratorProvider + */ + public function testGeneratedGeometryAABBIsValid(MeshGeometry $geometry, string $name): void + { + $this->assertLessThanOrEqual($geometry->aabb->max->x, $geometry->aabb->min->x, "{$name}: AABB min.x <= max.x"); + $this->assertLessThanOrEqual($geometry->aabb->max->y, $geometry->aabb->min->y, "{$name}: AABB min.y <= max.y"); + $this->assertLessThanOrEqual($geometry->aabb->max->z, $geometry->aabb->min->z, "{$name}: AABB min.z <= max.z"); + } + + /** + * @dataProvider primitiveGeneratorProvider + */ + public function testNormalsAreNormalized(MeshGeometry $geometry, string $name): void + { + $vertexCount = $geometry->getVertexCount(); + for ($i = 0; $i < $vertexCount; $i++) { + $offset = $i * Mesh3D::STRIDE; + $nx = $geometry->vertices[$offset + 3]; + $ny = $geometry->vertices[$offset + 4]; + $nz = $geometry->vertices[$offset + 5]; + $length = sqrt($nx * $nx + $ny * $ny + $nz * $nz); + $this->assertEqualsWithDelta(1.0, $length, 0.01, "{$name}: normal at vertex {$i} not normalized (length={$length})"); + } + } + + // ----------------------------------------------------------------------- + // Specific shape tests + // ----------------------------------------------------------------------- + + public function testCubeGeometry(): void + { + $geo = MeshFactory::generateCube(1.0); + // 6 faces * 4 vertices = 24 vertices + $this->assertEquals(24, $geo->getVertexCount()); + // 6 faces * 2 triangles = 12 triangles = 36 indices + $this->assertEquals(36, $geo->getIndexCount()); + } + + public function testCubeCustomSize(): void + { + $geo = MeshFactory::generateCube(2.0); + $this->assertEqualsWithDelta(1.0, $geo->aabb->max->x, 0.001); + $this->assertEqualsWithDelta(-1.0, $geo->aabb->min->x, 0.001); + } + + public function testSphereGeometry(): void + { + $geo = MeshFactory::generateSphere(0.5, 16, 8); + // (8+1) * (16+1) = 153 vertices + $this->assertEquals(153, $geo->getVertexCount()); + // 8 * 16 * 2 = 256 triangles + $this->assertEquals(256, $geo->getTriangleCount()); + } + + public function testSphereCustomRadius(): void + { + $geo = MeshFactory::generateSphere(1.0); + $this->assertEqualsWithDelta(1.0, $geo->aabb->max->x, 0.001); + $this->assertEqualsWithDelta(-1.0, $geo->aabb->min->x, 0.001); + } + + public function testPlaneGeometry(): void + { + $geo = MeshFactory::generatePlane(1.0); + $this->assertEquals(4, $geo->getVertexCount()); + $this->assertEquals(2, $geo->getTriangleCount()); + // Y should be 0 + $this->assertEqualsWithDelta(0.0, $geo->aabb->min->y, 0.001); + $this->assertEqualsWithDelta(0.0, $geo->aabb->max->y, 0.001); + } + + public function testQuadGeometry(): void + { + $geo = MeshFactory::generateQuad(1.0); + $this->assertEquals(4, $geo->getVertexCount()); + $this->assertEquals(2, $geo->getTriangleCount()); + // Z should be 0 + $this->assertEqualsWithDelta(0.0, $geo->aabb->min->z, 0.001); + $this->assertEqualsWithDelta(0.0, $geo->aabb->max->z, 0.001); + } + + public function testCylinderGeometry(): void + { + $geo = MeshFactory::generateCylinder(0.5, 2.0, 16); + $this->assertEqualsWithDelta(1.0, $geo->aabb->max->y, 0.001); + $this->assertEqualsWithDelta(-1.0, $geo->aabb->min->y, 0.001); + $this->assertTrue($geo->validate()); + } + + public function testConeGeometry(): void + { + $geo = MeshFactory::generateCone(0.5, 1.0, 16); + $this->assertEqualsWithDelta(0.5, $geo->aabb->max->y, 0.001); + $this->assertEqualsWithDelta(-0.5, $geo->aabb->min->y, 0.001); + $this->assertTrue($geo->validate()); + } + + public function testCapsuleGeometry(): void + { + $geo = MeshFactory::generateCapsule(0.25, 1.0, 16, 4); + $this->assertEqualsWithDelta(0.5, $geo->aabb->max->y, 0.001); + $this->assertEqualsWithDelta(-0.5, $geo->aabb->min->y, 0.001); + $this->assertTrue($geo->validate()); + } + + public function testCapsuleMinimumHeight(): void + { + // Height smaller than diameter — cylinder section should be 0 + $geo = MeshFactory::generateCapsule(0.5, 0.5, 16, 4); + $this->assertTrue($geo->validate()); + } + + public function testTorusGeometry(): void + { + $geo = MeshFactory::generateTorus(1.0, 0.3, 16, 8); + $outerR = 1.3; + $this->assertEqualsWithDelta($outerR, $geo->aabb->max->x, 0.001); + $this->assertEqualsWithDelta(-$outerR, $geo->aabb->min->x, 0.001); + $this->assertEqualsWithDelta(0.3, $geo->aabb->max->y, 0.001); + $this->assertTrue($geo->validate()); + } + + // ----------------------------------------------------------------------- + // MeshGeometry helper methods + // ----------------------------------------------------------------------- + + public function testMeshGeometryTriangleCount(): void + { + $geo = MeshFactory::generateCube(); + $this->assertEquals($geo->getIndexCount() / 3, $geo->getTriangleCount()); + } + + // ----------------------------------------------------------------------- + // Data providers + // ----------------------------------------------------------------------- + + /** + * @return array + */ + public static function primitiveGeneratorProvider(): array + { + return [ + 'cube' => [MeshFactory::generateCube(), 'cube'], + 'sphere' => [MeshFactory::generateSphere(0.5, 16, 8), 'sphere'], + 'plane' => [MeshFactory::generatePlane(), 'plane'], + 'quad' => [MeshFactory::generateQuad(), 'quad'], + 'cylinder' => [MeshFactory::generateCylinder(0.5, 1.0, 16), 'cylinder'], + 'cone' => [MeshFactory::generateCone(0.5, 1.0, 16), 'cone'], + 'capsule' => [MeshFactory::generateCapsule(0.25, 1.0, 16, 4), 'capsule'], + 'torus' => [MeshFactory::generateTorus(0.35, 0.15, 16, 8), 'torus'], + ]; + } +} diff --git a/tests/Graphics/Model3DTest.php b/tests/Graphics/Model3DTest.php new file mode 100644 index 0000000..36711a1 --- /dev/null +++ b/tests/Graphics/Model3DTest.php @@ -0,0 +1,27 @@ +assertEquals('test_model', $model->name); + $this->assertEmpty($model->meshes); + $this->assertInstanceOf(AABB::class, $model->aabb); + } + + public function testRecalculateAABBEmpty(): void + { + $model = new Model3D('empty'); + $model->recalculateAABB(); + $this->assertEquals(0.0, $model->aabb->min->x); + $this->assertEquals(0.0, $model->aabb->max->x); + } +} diff --git a/tests/Graphics/ModelCollectionTest.php b/tests/Graphics/ModelCollectionTest.php new file mode 100644 index 0000000..9433628 --- /dev/null +++ b/tests/Graphics/ModelCollectionTest.php @@ -0,0 +1,42 @@ +add($model); + + $this->assertTrue($collection->has('cube')); + $this->assertSame($model, $collection->get('cube')); + } + + public function testHasReturnsFalseForMissing(): void + { + $collection = new ModelCollection(); + $this->assertFalse($collection->has('nonexistent')); + } + + public function testGetThrowsForMissing(): void + { + $collection = new ModelCollection(); + $this->expectException(VISUException::class); + $collection->get('nonexistent'); + } + + public function testAddDuplicateThrows(): void + { + $collection = new ModelCollection(); + $collection->add(new Model3D('cube')); + $this->expectException(VISUException::class); + $collection->add(new Model3D('cube')); + } +} diff --git a/tests/Graphics/Particles/ParticlePoolTest.php b/tests/Graphics/Particles/ParticlePoolTest.php new file mode 100644 index 0000000..912028c --- /dev/null +++ b/tests/Graphics/Particles/ParticlePoolTest.php @@ -0,0 +1,147 @@ +assertEquals(0, $pool->aliveCount); + + $result = $pool->emit( + 1.0, 2.0, 3.0, // position + 0.0, 1.0, 0.0, // velocity + 1.0, 1.0, 1.0, 1.0, // start color + 1.0, 0.0, 0.0, 0.0, // end color + 1.0, 0.5, // start/end size + 2.0, // lifetime + ); + + $this->assertTrue($result); + $this->assertEquals(1, $pool->aliveCount); + } + + public function testMaxCapacity(): void + { + $pool = new ParticlePool(3); + + for ($i = 0; $i < 3; $i++) { + $this->assertTrue($pool->emit(0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1)); + } + + // 4th should fail + $this->assertFalse($pool->emit(0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1)); + $this->assertEquals(3, $pool->aliveCount); + } + + public function testSimulateAgesParticles(): void + { + $pool = new ParticlePool(10); + $pool->emit(0, 0, 0, 1.0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 1.0); + + $pool->simulate(0.5, 0.0, 0.0); + + $this->assertEquals(1, $pool->aliveCount); + $this->assertEqualsWithDelta(0.5, $pool->age[0], 0.001); + $this->assertEqualsWithDelta(0.5, $pool->posX[0], 0.001); // moved by velocity + } + + public function testSimulateKillsDeadParticles(): void + { + $pool = new ParticlePool(10); + $pool->emit(0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0.5); + + $pool->simulate(0.6, 0.0, 0.0); // exceeds lifetime + + $this->assertEquals(0, $pool->aliveCount); + } + + public function testSimulateGravity(): void + { + $pool = new ParticlePool(10); + $pool->emit(0, 10.0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 5.0); + + $pool->simulate(1.0, 1.0, 0.0); // 1 second, gravity modifier 1.0 + + // velocity should be -9.81 after 1s, position should decrease + $this->assertLessThan(10.0, $pool->posY[0]); + $this->assertLessThan(0.0, $pool->velY[0]); + } + + public function testSimulateDrag(): void + { + $pool = new ParticlePool(10); + $pool->emit(0, 0, 0, 10.0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 5.0); + + $pool->simulate(0.1, 0.0, 2.0); // drag over short step + + // velocity should be reduced but still positive + $this->assertLessThan(10.0, $pool->velX[0]); + $this->assertGreaterThan(0.0, $pool->velX[0]); + } + + public function testSwapAndPopPreservesLiveParticles(): void + { + $pool = new ParticlePool(10); + + // particle A: short lifetime + $pool->emit(1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.1); + // particle B: long lifetime + $pool->emit(2, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 10.0); + // particle C: long lifetime + $pool->emit(3, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 10.0); + + $this->assertEquals(3, $pool->aliveCount); + + // kill particle A (age > 0.1) + $pool->simulate(0.2, 0.0, 0.0); + + $this->assertEquals(2, $pool->aliveCount); + + // remaining particles should be B and C (posX = 2 and 3, order may change due to swap) + $positions = [$pool->posX[0], $pool->posX[1]]; + sort($positions); + $this->assertEqualsWithDelta(2.0, $positions[0], 0.1); + $this->assertEqualsWithDelta(3.0, $positions[1], 0.1); + } + + public function testBuildInstanceBuffer(): void + { + $pool = new ParticlePool(10); + $pool->emit(1, 2, 3, 0, 0, 0, 1.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0); + + $buffer = $pool->buildInstanceBuffer(); + + // 8 floats per particle + $this->assertEquals(8, $buffer->size()); + + // at t=0: color = startColor, size = startSize + $this->assertEqualsWithDelta(1.0, $buffer[0], 0.001); // posX + $this->assertEqualsWithDelta(2.0, $buffer[1], 0.001); // posY + $this->assertEqualsWithDelta(3.0, $buffer[2], 0.001); // posZ + $this->assertEqualsWithDelta(1.0, $buffer[3], 0.001); // R (start) + $this->assertEqualsWithDelta(0.5, $buffer[4], 0.001); // G (start) + $this->assertEqualsWithDelta(2.0, $buffer[7], 0.001); // size (start) + } + + public function testBuildInstanceBufferInterpolatesOverLifetime(): void + { + $pool = new ParticlePool(10); + $pool->emit(0, 0, 0, 0, 0, 0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.0, 2.0); + + // simulate to half lifetime + $pool->simulate(1.0, 0.0, 0.0); + + $buffer = $pool->buildInstanceBuffer(); + + // at t=0.5: color should be (0.5, 0.5, 0, 0.5), size = 1.0 + $this->assertEqualsWithDelta(0.5, $buffer[3], 0.001); // R + $this->assertEqualsWithDelta(0.5, $buffer[4], 0.001); // G + $this->assertEqualsWithDelta(0.5, $buffer[6], 0.001); // A + $this->assertEqualsWithDelta(1.0, $buffer[7], 0.001); // size + } +} diff --git a/tests/Graphics/Rendering/Pass/BloomPassTest.php b/tests/Graphics/Rendering/Pass/BloomPassTest.php new file mode 100644 index 0000000..852a86d --- /dev/null +++ b/tests/Graphics/Rendering/Pass/BloomPassTest.php @@ -0,0 +1,15 @@ +assertInstanceOf(PostProcessData::class, $data); + } +} diff --git a/tests/Graphics/Rendering/Pass/PointLightShadowDataTest.php b/tests/Graphics/Rendering/Pass/PointLightShadowDataTest.php new file mode 100644 index 0000000..215137f --- /dev/null +++ b/tests/Graphics/Rendering/Pass/PointLightShadowDataTest.php @@ -0,0 +1,43 @@ +assertEquals(512, $data->resolution); + $this->assertEquals(0, $data->shadowLightCount); + $this->assertEmpty($data->cubemapTextureIds); + $this->assertEmpty($data->farPlanes); + $this->assertEmpty($data->lightPositions); + } + + public function testStoreShadowLightData(): void + { + $data = new PointLightShadowData(); + $data->shadowLightCount = 2; + $data->resolution = 1024; + $data->cubemapTextureIds = [42, 43]; + $data->farPlanes = [20.0, 50.0]; + $data->lightPositions = [ + new Vec3(1.0, 2.0, 3.0), + new Vec3(4.0, 5.0, 6.0), + ]; + + $this->assertEquals(2, $data->shadowLightCount); + $this->assertEquals(1024, $data->resolution); + $this->assertCount(2, $data->cubemapTextureIds); + $this->assertEquals(42, $data->cubemapTextureIds[0]); + $this->assertEquals(20.0, $data->farPlanes[0]); + $this->assertEquals(50.0, $data->farPlanes[1]); + $this->assertEquals(1.0, $data->lightPositions[0]->x); + $this->assertEquals(5.0, $data->lightPositions[1]->y); + } +} diff --git a/tests/Graphics/Rendering/Pass/PointLightShadowPassTest.php b/tests/Graphics/Rendering/Pass/PointLightShadowPassTest.php new file mode 100644 index 0000000..d29369a --- /dev/null +++ b/tests/Graphics/Rendering/Pass/PointLightShadowPassTest.php @@ -0,0 +1,97 @@ +assertSame(4, PointLightShadowPass::MAX_SHADOW_POINT_LIGHTS); + } + + public function testDefaultResolutionConstant(): void + { + $this->assertSame(512, PointLightShadowPass::DEFAULT_RESOLUTION); + } + + public function testShadowDataDefaults(): void + { + $data = new PointLightShadowData(); + $this->assertSame(512, $data->resolution); + $this->assertSame(0, $data->shadowLightCount); + $this->assertEmpty($data->cubemapTextureIds); + $this->assertEmpty($data->farPlanes); + $this->assertEmpty($data->lightPositions); + } + + public function testShadowDataMaxLights(): void + { + $data = new PointLightShadowData(); + + for ($i = 0; $i < PointLightShadowPass::MAX_SHADOW_POINT_LIGHTS; $i++) { + $data->cubemapTextureIds[$i] = 100 + $i; + $data->farPlanes[$i] = 10.0 + $i * 5.0; + $data->lightPositions[$i] = new Vec3((float)$i, 2.0, 0.0); + } + $data->shadowLightCount = PointLightShadowPass::MAX_SHADOW_POINT_LIGHTS; + + $this->assertSame(4, $data->shadowLightCount); + $this->assertCount(4, $data->cubemapTextureIds); + $this->assertCount(4, $data->farPlanes); + $this->assertCount(4, $data->lightPositions); + } + + public function testShadowDataCustomResolution(): void + { + $data = new PointLightShadowData(); + $data->resolution = 1024; + $this->assertSame(1024, $data->resolution); + } + + public function testShadowDataFarPlanesMatchRange(): void + { + $data = new PointLightShadowData(); + $ranges = [15.0, 25.0, 50.0, 100.0]; + + foreach ($ranges as $i => $range) { + $data->farPlanes[$i] = $range; + } + + $this->assertSame(15.0, $data->farPlanes[0]); + $this->assertSame(100.0, $data->farPlanes[3]); + } + + public function testShadowDataPositionStorage(): void + { + $data = new PointLightShadowData(); + $pos = new Vec3(5.0, 3.0, -7.0); + $data->lightPositions[0] = $pos; + + $this->assertEqualsWithDelta(5.0, $data->lightPositions[0]->x, 0.001); + $this->assertEqualsWithDelta(3.0, $data->lightPositions[0]->y, 0.001); + $this->assertEqualsWithDelta(-7.0, $data->lightPositions[0]->z, 0.001); + } + + public function testShadowDataResetBetweenFrames(): void + { + $data = new PointLightShadowData(); + $data->shadowLightCount = 3; + $data->cubemapTextureIds = [1, 2, 3]; + $data->farPlanes = [10.0, 20.0, 30.0]; + $data->lightPositions = [new Vec3(0, 0, 0), new Vec3(1, 1, 1), new Vec3(2, 2, 2)]; + + // Simulate frame reset (as PointLightShadowPass::execute does) + $data->shadowLightCount = 0; + $data->cubemapTextureIds = []; + $data->farPlanes = []; + $data->lightPositions = []; + + $this->assertSame(0, $data->shadowLightCount); + $this->assertEmpty($data->cubemapTextureIds); + } +} diff --git a/tests/Graphics/Rendering/Pass/PostProcessDataTest.php b/tests/Graphics/Rendering/Pass/PostProcessDataTest.php new file mode 100644 index 0000000..2c12a43 --- /dev/null +++ b/tests/Graphics/Rendering/Pass/PostProcessDataTest.php @@ -0,0 +1,15 @@ +assertInstanceOf(PostProcessData::class, $data); + } +} diff --git a/tests/Graphics/Rendering/Pass/ShadowMapDataTest.php b/tests/Graphics/Rendering/Pass/ShadowMapDataTest.php new file mode 100644 index 0000000..9f47eca --- /dev/null +++ b/tests/Graphics/Rendering/Pass/ShadowMapDataTest.php @@ -0,0 +1,51 @@ +assertSame(4, $data->cascadeCount); + $this->assertSame(2048, $data->resolution); + $this->assertEmpty($data->renderTargets); + $this->assertEmpty($data->depthTextures); + $this->assertEmpty($data->lightSpaceMatrices); + $this->assertEmpty($data->cascadeSplits); + } + + public function testCascadeSplitsStorage(): void + { + $data = new ShadowMapData(); + $data->cascadeSplits = [10.0, 30.0, 70.0, 200.0]; + + $this->assertCount(4, $data->cascadeSplits); + $this->assertSame(10.0, $data->cascadeSplits[0]); + $this->assertSame(200.0, $data->cascadeSplits[3]); + } + + public function testLightSpaceMatricesStorage(): void + { + $data = new ShadowMapData(); + $mat = new Mat4(); + $data->lightSpaceMatrices[0] = $mat; + + $this->assertCount(1, $data->lightSpaceMatrices); + $this->assertSame($mat, $data->lightSpaceMatrices[0]); + } + + public function testCustomResolution(): void + { + $data = new ShadowMapData(); + $data->cascadeCount = 2; + $data->resolution = 4096; + + $this->assertSame(2, $data->cascadeCount); + $this->assertSame(4096, $data->resolution); + } +} diff --git a/tests/Graphics/Rendering/PostProcessStackTest.php b/tests/Graphics/Rendering/PostProcessStackTest.php new file mode 100644 index 0000000..7ed90a1 --- /dev/null +++ b/tests/Graphics/Rendering/PostProcessStackTest.php @@ -0,0 +1,16 @@ +assertTrue(true, 'PostProcessStack requires GL context for construction'); + } +} diff --git a/tests/Graphics/Terrain/TerrainDataTest.php b/tests/Graphics/Terrain/TerrainDataTest.php new file mode 100644 index 0000000..61428a5 --- /dev/null +++ b/tests/Graphics/Terrain/TerrainDataTest.php @@ -0,0 +1,67 @@ +assertEquals(10, $data->width); + $this->assertEquals(10, $data->depth); + $this->assertEquals(100.0, $data->sizeX); + $this->assertEquals(100.0, $data->sizeZ); + $this->assertEquals(0.0, $data->getHeight(5, 5)); + } + + public function testGetHeightClampsToGrid(): void + { + $heights = [0.0, 0.5, 1.0, 0.0, 0.5, 1.0, 0.0, 0.5, 1.0]; + $data = new TerrainData($heights, 3, 3, 10.0, 10.0, 10.0); + + // within bounds + $this->assertEqualsWithDelta(5.0, $data->getHeight(1, 0), 0.001); + $this->assertEqualsWithDelta(10.0, $data->getHeight(2, 0), 0.001); + + // clamped to bounds + $this->assertEqualsWithDelta(0.0, $data->getHeight(-1, 0), 0.001); + $this->assertEqualsWithDelta(10.0, $data->getHeight(99, 0), 0.001); + } + + public function testGetHeightAtWorldCenter(): void + { + $heights = array_fill(0, 9, 0.5); + $data = new TerrainData($heights, 3, 3, 10.0, 10.0, 20.0); + + // center of terrain + $this->assertEqualsWithDelta(10.0, $data->getHeightAtWorld(0.0, 0.0), 0.001); + } + + public function testGetHeightAtWorldInterpolation(): void + { + // 2x2 grid: corners have different heights + $heights = [0.0, 1.0, 0.0, 1.0]; + $data = new TerrainData($heights, 2, 2, 10.0, 10.0, 10.0); + + // center should average to 5.0 + $this->assertEqualsWithDelta(5.0, $data->getHeightAtWorld(0.0, 0.0), 0.001); + + // left edge center + $this->assertEqualsWithDelta(0.0, $data->getHeightAtWorld(-5.0, 0.0), 0.001); + + // right edge center + $this->assertEqualsWithDelta(10.0, $data->getHeightAtWorld(5.0, 0.0), 0.001); + } + + public function testGetRawHeights(): void + { + $heights = [0.1, 0.2, 0.3, 0.4]; + $data = new TerrainData($heights, 2, 2); + + $this->assertEquals($heights, $data->getRawHeights()); + } +} diff --git a/tests/Locale/LocaleManagerTest.php b/tests/Locale/LocaleManagerTest.php new file mode 100644 index 0000000..f854cb8 --- /dev/null +++ b/tests/Locale/LocaleManagerTest.php @@ -0,0 +1,428 @@ +createManager(); + $this->assertSame('en', $manager->getCurrentLocale()); + $this->assertSame('en', $manager->getFallbackLocale()); + } + + public function testSetAndGetLocale(): void + { + $manager = $this->createManager(); + $manager->setLocale('de'); + $this->assertSame('de', $manager->getCurrentLocale()); + } + + public function testSetFallbackLocale(): void + { + $manager = $this->createManager(); + $manager->setFallbackLocale('fr'); + $this->assertSame('fr', $manager->getFallbackLocale()); + } + + // --- Loading translations --- + + public function testLoadArray(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'menu' => [ + 'start' => 'Start Game', + 'quit' => 'Quit', + ], + ]); + $manager->setLocale('en'); + + $this->assertSame('Start Game', $manager->get('menu.start')); + $this->assertSame('Quit', $manager->get('menu.quit')); + } + + public function testLoadArrayFlat(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'greeting' => 'Hello', + ]); + $manager->setLocale('en'); + + $this->assertSame('Hello', $manager->get('greeting')); + } + + public function testLoadArrayDeepNesting(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'game' => [ + 'ui' => [ + 'hud' => [ + 'health' => 'HP', + ], + ], + ], + ]); + $manager->setLocale('en'); + + $this->assertSame('HP', $manager->get('game.ui.hud.health')); + } + + public function testLoadArrayMergesExisting(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['a' => 'first']); + $manager->loadArray('en', ['b' => 'second']); + $manager->setLocale('en'); + + $this->assertSame('first', $manager->get('a')); + $this->assertSame('second', $manager->get('b')); + } + + public function testLoadFile(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'locale_') . '.json'; + file_put_contents($tmpFile, json_encode([ + 'menu' => ['play' => 'Play'], + ])); + + try { + $manager = $this->createManager(); + $manager->loadFile('en', $tmpFile); + $manager->setLocale('en'); + + $this->assertSame('Play', $manager->get('menu.play')); + } finally { + unlink($tmpFile); + } + } + + public function testLoadFileMissing(): void + { + $manager = $this->createManager(); + $this->expectException(\RuntimeException::class); + $manager->loadFile('en', '/nonexistent/path.json'); + } + + public function testLoadFileInvalidJson(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'locale_') . '.json'; + file_put_contents($tmpFile, 'not json'); + + try { + $manager = $this->createManager(); + $this->expectException(\RuntimeException::class); + $manager->loadFile('en', $tmpFile); + } finally { + unlink($tmpFile); + } + } + + public function testLoadDirectory(): void + { + $tmpDir = sys_get_temp_dir() . '/visu_locale_test_' . uniqid(); + mkdir($tmpDir); + file_put_contents($tmpDir . '/en.json', json_encode(['hello' => 'Hello'])); + file_put_contents($tmpDir . '/de.json', json_encode(['hello' => 'Hallo'])); + + try { + $manager = $this->createManager(); + $manager->loadDirectory($tmpDir); + + $manager->setLocale('en'); + $this->assertSame('Hello', $manager->get('hello')); + + $manager->setLocale('de'); + $this->assertSame('Hallo', $manager->get('hello')); + } finally { + unlink($tmpDir . '/en.json'); + unlink($tmpDir . '/de.json'); + rmdir($tmpDir); + } + } + + public function testLoadDirectoryMissing(): void + { + $manager = $this->createManager(); + $this->expectException(\RuntimeException::class); + $manager->loadDirectory('/nonexistent/dir'); + } + + // --- Translation resolution --- + + public function testGetReturnsKeyWhenMissing(): void + { + $manager = $this->createManager(); + $this->assertSame('missing.key', $manager->get('missing.key')); + } + + public function testGetFallsBackToFallbackLocale(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['greeting' => 'Hello']); + $manager->setFallbackLocale('en'); + $manager->setLocale('de'); + + $this->assertSame('Hello', $manager->get('greeting')); + } + + public function testGetPrefersCurrentLocale(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['greeting' => 'Hello']); + $manager->loadArray('de', ['greeting' => 'Hallo']); + $manager->setFallbackLocale('en'); + $manager->setLocale('de'); + + $this->assertSame('Hallo', $manager->get('greeting')); + } + + // --- Parameter interpolation --- + + public function testParameterInterpolation(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'welcome' => 'Welcome, :name!', + ]); + $manager->setLocale('en'); + + $this->assertSame('Welcome, Alice!', $manager->get('welcome', ['name' => 'Alice'])); + } + + public function testMultipleParameters(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'info' => ':name has :count items', + ]); + $manager->setLocale('en'); + + $this->assertSame('Alice has 5 items', $manager->get('info', ['name' => 'Alice', 'count' => 5])); + } + + public function testParameterNotFound(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['msg' => 'Hello :name']); + $manager->setLocale('en'); + + $this->assertSame('Hello :name', $manager->get('msg')); + } + + // --- Pluralization --- + + public function testChoiceSimpleTwoForms(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'items' => ':count item|:count items', + ]); + $manager->setLocale('en'); + + $this->assertSame('1 item', $manager->choice('items', 1)); + $this->assertSame('5 items', $manager->choice('items', 5)); + $this->assertSame('0 items', $manager->choice('items', 0)); + } + + public function testChoiceThreeForms(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'apples' => 'No apples|One apple|:count apples', + ]); + $manager->setLocale('en'); + + $this->assertSame('No apples', $manager->choice('apples', 0)); + $this->assertSame('One apple', $manager->choice('apples', 1)); + $this->assertSame('3 apples', $manager->choice('apples', 3)); + } + + public function testChoiceExplicitCounts(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'items' => '{0} Nothing|{1} One item|[2,*] :count items', + ]); + $manager->setLocale('en'); + + $this->assertSame('Nothing', $manager->choice('items', 0)); + $this->assertSame('One item', $manager->choice('items', 1)); + $this->assertSame('99 items', $manager->choice('items', 99)); + } + + public function testChoiceExplicitRange(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'score' => '{0} No score|[1,3] Low|[4,7] Medium|[8,*] High', + ]); + $manager->setLocale('en'); + + $this->assertSame('No score', $manager->choice('score', 0)); + $this->assertSame('Low', $manager->choice('score', 2)); + $this->assertSame('Medium', $manager->choice('score', 5)); + $this->assertSame('High', $manager->choice('score', 10)); + } + + public function testChoiceSingleForm(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['msg' => ':count things']); + $manager->setLocale('en'); + + $this->assertSame('3 things', $manager->choice('msg', 3)); + } + + // --- has() --- + + public function testHas(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['exists' => 'yes']); + $manager->setLocale('en'); + + $this->assertTrue($manager->has('exists')); + $this->assertFalse($manager->has('missing')); + } + + public function testHasChecksCurrentAndFallback(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['only_en' => 'English']); + $manager->setFallbackLocale('en'); + $manager->setLocale('de'); + + $this->assertTrue($manager->has('only_en')); + } + + // --- Available locales --- + + public function testGetAvailableLocales(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['a' => 'b']); + $manager->loadArray('de', ['a' => 'b']); + $manager->loadArray('fr', ['a' => 'b']); + + $locales = $manager->getAvailableLocales(); + sort($locales); + $this->assertSame(['de', 'en', 'fr'], $locales); + } + + // --- resolveTranslations() --- + + public function testResolveTranslationsSimple(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['menu.start' => 'Start Game']); + $manager->setLocale('en'); + + $this->assertSame('Start Game', $manager->resolveTranslations('{t:menu.start}')); + } + + public function testResolveTranslationsWithParams(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['welcome' => 'Welcome, :name!']); + $manager->setLocale('en'); + + $this->assertSame('Welcome, Alice!', $manager->resolveTranslations('{t:welcome|name=Alice}')); + } + + public function testResolveTranslationsMultipleInString(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', [ + 'hp' => 'HP', + 'mp' => 'MP', + ]); + $manager->setLocale('en'); + + $this->assertSame('HP / MP', $manager->resolveTranslations('{t:hp} / {t:mp}')); + } + + public function testResolveTranslationsMixedWithData(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['label' => 'Gold']); + $manager->setLocale('en'); + + // This tests the string after translation resolution still contains {data.binding} + $result = $manager->resolveTranslations('{t:label}: {economy.gold}'); + $this->assertSame('Gold: {economy.gold}', $result); + } + + // --- Signal dispatch --- + + public function testLocaleChangedSignal(): void + { + $dispatcher = new Dispatcher(); + $manager = new LocaleManager($dispatcher); + + $received = null; + $dispatcher->register('locale.changed', function (LocaleChangedSignal $signal) use (&$received): void { + $received = $signal; + }); + + $manager->setLocale('de'); + + $this->assertNotNull($received); + $this->assertSame('en', $received->previousLocale); + $this->assertSame('de', $received->newLocale); + } + + public function testNoSignalWhenLocaleUnchanged(): void + { + $dispatcher = new Dispatcher(); + $manager = new LocaleManager($dispatcher); + + $callCount = 0; + $dispatcher->register('locale.changed', function () use (&$callCount): void { + $callCount++; + }); + + $manager->setLocale('en'); // same as default + $this->assertSame(0, $callCount); + } + + // --- UIDataContext integration --- + + public function testUIDataContextTranslationIntegration(): void + { + $manager = $this->createManager(); + $manager->loadArray('en', ['ui.health' => 'Health']); + $manager->setLocale('en'); + + $ctx = new \VISU\UI\UIDataContext(); + $ctx->setLocaleManager($manager); + $ctx->set('player.hp', 100); + + $result = $ctx->resolveBindings('{t:ui.health}: {player.hp}'); + $this->assertSame('Health: 100', $result); + } + + public function testUIDataContextWithoutLocaleManager(): void + { + $ctx = new \VISU\UI\UIDataContext(); + $ctx->set('val', 42); + + // {t:...} expressions remain unresolved without locale manager + $result = $ctx->resolveBindings('{t:some.key} = {val}'); + $this->assertSame('{t:some.key} = 42', $result); + } +} diff --git a/tests/OS/InputFullscreenTest.php b/tests/OS/InputFullscreenTest.php new file mode 100644 index 0000000..29a9aff --- /dev/null +++ b/tests/OS/InputFullscreenTest.php @@ -0,0 +1,240 @@ +input = new Input($this->createWindow(), new VoidDispatcher); + } + + // -- Callback-tracked state -------------------------------------------------- + + public function testMouseButtonStateTrackedFromCallbacks(): void + { + $window = $this->createWindow(); + + // initially released + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + + // simulate press callback + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertSame(GLFW_PRESS, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + + // simulate release callback + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_RELEASE, 0); + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + } + + public function testKeyStateUsesGlfwPolling(): void + { + // getKeyState() uses glfwGetKey() polling (not callback-tracked). + // This is intentional: callback events + polling cross-check detects phantom keys. + $this->assertSame(GLFW_RELEASE, $this->input->getKeyState(GLFW_KEY_F6)); + } + + // -- suppressInputEvents() --------------------------------------------------- + + public function testSuppressInputEventsDropsMouseCallbacks(): void + { + $window = $this->createWindow(); + + $this->input->suppressInputEvents(2); + + // phantom press during suppression — should be ignored + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertFalse($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + } + + public function testSuppressInputEventsDropsKeyCallbacks(): void + { + $window = $this->createWindow(); + + $this->input->suppressInputEvents(2); + + // phantom key during suppression — callback event should be ignored + $this->input->handleWindowKey($window, GLFW_KEY_F1, 0, GLFW_PRESS, 0); + $this->assertFalse($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_F1)); + // getKeyState() uses GLFW polling (independent of callbacks), so we only check callback-based state + } + + public function testSuppressionExpiresAfterFrames(): void + { + $window = $this->createWindow(); + + $this->input->suppressInputEvents(2, 0.0); // 0s time-suppression so only frames matter + + // frame 1 — still suppressed + $this->input->endFrame(); + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertFalse($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + + // frame 2 — counter reaches 0, suppression ends but post-suppression guard activates + $this->input->endFrame(); + // Post-suppression guard blocks PRESS events (phantom protection). + // A RELEASE on any button disables the guard immediately so the next real click works. + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_RIGHT, GLFW_RELEASE, 0); + + // Now a real click should work + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertTrue($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + } + + public function testSuppressionExpiresAfterTime(): void + { + $window = $this->createWindow(); + + // 0 frames but time-based suppression of 0.05s + $this->input->suppressInputEvents(0, 0.05); + + // immediately — still within time window + $this->input->handleWindowKey($window, GLFW_KEY_A, 0, GLFW_PRESS, 0); + $this->assertFalse($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_A)); + + // wait for suppression to expire + usleep(60_000); // 60ms > 50ms + + $this->input->endFrame(); + $this->input->handleWindowKey($window, GLFW_KEY_A, 0, GLFW_PRESS, 0); + $this->assertTrue($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_A)); + } + + // -- State preservation during suppression ----------------------------------- + + public function testSuppressionClearsExistingMouseState(): void + { + $window = $this->createWindow(); + + // user is holding left mouse button before fullscreen toggle + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertSame(GLFW_PRESS, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + + // fullscreen toggle — suppress events + // Suppression intentionally clears all mouse button states to prevent + // stuck buttons (the release callback would be dropped during suppression). + $this->input->suppressInputEvents(3); + + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + } + + public function testSuppressionPreservesExistingKeyEvents(): void + { + $window = $this->createWindow(); + + // key event recorded before suppression + $this->input->handleWindowKey($window, GLFW_KEY_W, 0, GLFW_PRESS, 0); + $this->assertTrue($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_W)); + + $this->input->suppressInputEvents(3); + + // suppression clears pending events + $this->assertFalse($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_W)); + // getKeyState() uses GLFW polling — independent of callback tracking + } + + // -- Clearing event arrays but not states ------------------------------------ + + public function testSuppressionClearsPendingEventsButNotStates(): void + { + $window = $this->createWindow(); + + // record some events in the current frame + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_RIGHT, GLFW_PRESS, 0); + $this->input->handleWindowKey($window, GLFW_KEY_SPACE, 0, GLFW_PRESS, 0); + + $this->assertTrue($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_RIGHT)); + $this->assertTrue($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_SPACE)); + + // suppress clears pending events + $this->input->suppressInputEvents(1); + + $this->assertFalse($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_RIGHT)); + $this->assertFalse($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_SPACE)); + + // mouse button state is also cleared (prevents stuck buttons when release is dropped) + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_RIGHT)); + } + + // -- No phantom artifacts after rapid fullscreen toggle ---------------------- + + public function testNoPhantomClickAfterFullscreenToggle(): void + { + $window = $this->createWindow(); + + // simulate: user presses F11 for fullscreen, OS generates phantom mouse press + $this->input->suppressInputEvents(3, 0.0); + + // phantom PRESS arrives + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + // phantom RELEASE arrives + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_RELEASE, 0); + + // none of this should be visible + $this->assertFalse($this->input->hasMouseButtonBeenPressed(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertFalse($this->input->hasMouseButtonBeenReleased(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertFalse($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertFalse($this->input->hasMouseButtonBeenReleasedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + } + + public function testNoPhantomKeyAfterFullscreenToggle(): void + { + $window = $this->createWindow(); + + $this->input->suppressInputEvents(3, 0.0); + + // phantom F6 press/release + $this->input->handleWindowKey($window, GLFW_KEY_F6, 0, GLFW_PRESS, 0); + $this->input->handleWindowKey($window, GLFW_KEY_F6, 0, GLFW_RELEASE, 0); + + $this->assertFalse($this->input->hasKeyBeenPressed(GLFW_KEY_F6)); + $this->assertFalse($this->input->hasKeyBeenReleased(GLFW_KEY_F6)); + $this->assertFalse($this->input->hasKeyBeenPressedThisFrame(GLFW_KEY_F6)); + $this->assertFalse($this->input->hasKeyBeenReleasedThisFrame(GLFW_KEY_F6)); + $this->assertSame(GLFW_RELEASE, $this->input->getKeyState(GLFW_KEY_F6)); + } + + // -- Normal operation after suppression ends --------------------------------- + + public function testNormalOperationResumesAfterSuppression(): void + { + $window = $this->createWindow(); + + $this->input->suppressInputEvents(1, 0.0); + + // suppression active — ignored + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertFalse($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + + // end frame — suppression expires, post-suppression guard activates + $this->input->endFrame(); + + // Post-suppression guard blocks PRESS but allows RELEASE. + // Send a RELEASE (on right button to avoid click-distance logic) to clear the guard. + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_RIGHT, GLFW_RELEASE, 0); + + // real click — should work normally now + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, 0); + $this->assertTrue($this->input->hasMouseButtonBeenPressedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertSame(GLFW_PRESS, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + + $this->input->handleWindowMouseButton($window, GLFW_MOUSE_BUTTON_LEFT, GLFW_RELEASE, 0); + $this->assertTrue($this->input->hasMouseButtonBeenReleasedThisFrame(GLFW_MOUSE_BUTTON_LEFT)); + $this->assertSame(GLFW_RELEASE, $this->input->getMouseButtonState(GLFW_MOUSE_BUTTON_LEFT)); + } +} diff --git a/tests/Save/SaveManagerTest.php b/tests/Save/SaveManagerTest.php new file mode 100644 index 0000000..aaaab77 --- /dev/null +++ b/tests/Save/SaveManagerTest.php @@ -0,0 +1,287 @@ +tmpDir = sys_get_temp_dir() . '/visu_save_test_' . uniqid(); + $this->manager = new SaveManager($this->tmpDir); + } + + protected function tearDown(): void + { + // Clean up + foreach (glob($this->tmpDir . '/*.json') ?: [] as $file) { + unlink($file); + } + if (is_dir($this->tmpDir)) { + rmdir($this->tmpDir); + } + } + + public function testSaveAndLoad(): void + { + $state = ['money' => 5000, 'level' => 3, 'name' => 'TestCorp']; + $slot = $this->manager->save('slot1', $state, 120.5, 'Test save'); + + $this->assertSame('slot1', $slot->name); + $this->assertSame($state, $slot->gameState); + $this->assertEqualsWithDelta(120.5, $slot->playTime, 0.01); + $this->assertSame('Test save', $slot->description); + + $loaded = $this->manager->load('slot1'); + $this->assertSame($state, $loaded->gameState); + $this->assertSame('slot1', $loaded->name); + $this->assertEqualsWithDelta(120.5, $loaded->playTime, 0.01); + } + + public function testSaveWithSceneData(): void + { + $state = ['score' => 100]; + $scene = ['entities' => [['name' => 'Player', 'transform' => []]]]; + + $slot = $this->manager->save('slot_scene', $state, sceneData: $scene); + + $loaded = $this->manager->load('slot_scene'); + $this->assertSame($scene, $loaded->sceneData); + $this->assertSame($state, $loaded->gameState); + } + + public function testExists(): void + { + $this->assertFalse($this->manager->exists('nonexistent')); + + $this->manager->save('exists_test', ['data' => true]); + $this->assertTrue($this->manager->exists('exists_test')); + } + + public function testDelete(): void + { + $this->manager->save('to_delete', ['data' => true]); + $this->assertTrue($this->manager->exists('to_delete')); + + $result = $this->manager->delete('to_delete'); + $this->assertTrue($result); + $this->assertFalse($this->manager->exists('to_delete')); + } + + public function testDeleteNonexistent(): void + { + $this->assertFalse($this->manager->delete('ghost')); + } + + public function testListSlots(): void + { + $this->manager->save('alpha', ['a' => 1], 10.0, 'First'); + usleep(1000); // Ensure different timestamps + $this->manager->save('beta', ['b' => 2], 20.0, 'Second'); + + $slots = $this->manager->listSlots(); + $this->assertCount(2, $slots); + + // Newest first + $this->assertSame('beta', $slots[0]->name); + $this->assertSame('alpha', $slots[1]->name); + + $this->assertInstanceOf(SaveSlotInfo::class, $slots[0]); + $this->assertSame('Second', $slots[0]->description); + $this->assertEqualsWithDelta(20.0, $slots[0]->playTime, 0.01); + } + + public function testListSlotsEmpty(): void + { + $slots = $this->manager->listSlots(); + $this->assertEmpty($slots); + } + + public function testLoadNonexistentThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Save slot not found'); + $this->manager->load('doesnt_exist'); + } + + public function testSchemaVersion(): void + { + $this->manager->setSchemaVersion(2); + $slot = $this->manager->save('versioned', ['x' => 1]); + $this->assertSame(2, $slot->version); + + $loaded = $this->manager->load('versioned'); + $this->assertSame(2, $loaded->version); + } + + public function testMigration(): void + { + // Save at version 1 + $this->manager->setSchemaVersion(1); + $this->manager->save('migrate_test', ['old_field' => 'value']); + + // Create a new manager at version 2 with migration + $manager2 = new SaveManager($this->tmpDir); + $manager2->setSchemaVersion(2); + $manager2->registerMigration(1, function (int $fromVersion, array $data): array { + $data['gameState']['new_field'] = 'migrated_' . ($data['gameState']['old_field'] ?? ''); + return $data; + }); + + $loaded = $manager2->load('migrate_test'); + $this->assertSame(2, $loaded->version); + $this->assertSame('migrated_value', $loaded->gameState['new_field']); + $this->assertSame('value', $loaded->gameState['old_field']); + } + + public function testMultiStepMigration(): void + { + $this->manager->setSchemaVersion(1); + $this->manager->save('multi_migrate', ['v1' => true]); + + $manager3 = new SaveManager($this->tmpDir); + $manager3->setSchemaVersion(3); + $manager3->registerMigration(1, function (int $v, array $data): array { + $data['gameState']['v2'] = true; + return $data; + }); + $manager3->registerMigration(2, function (int $v, array $data): array { + $data['gameState']['v3'] = true; + return $data; + }); + + $loaded = $manager3->load('multi_migrate'); + $this->assertSame(3, $loaded->version); + $this->assertTrue($loaded->gameState['v1']); + $this->assertTrue($loaded->gameState['v2']); + $this->assertTrue($loaded->gameState['v3']); + } + + public function testMissingMigrationThrows(): void + { + $this->manager->setSchemaVersion(1); + $this->manager->save('no_migration', ['data' => true]); + + $manager2 = new SaveManager($this->tmpDir); + $manager2->setSchemaVersion(2); + // No migration registered + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No migration registered'); + $manager2->load('no_migration'); + } + + public function testAutosaveDisabled(): void + { + $this->manager->setAutosaveInterval(0); + $result = $this->manager->updateAutosave(999.0, ['data' => true]); + $this->assertNull($result); + } + + public function testAutosaveTriggers(): void + { + $this->manager->setAutosaveInterval(5.0); + + // Not enough time + $result = $this->manager->updateAutosave(3.0, ['money' => 100], 60.0); + $this->assertNull($result); + $this->assertFalse($this->manager->exists('autosave')); + + // Enough time accumulated + $result = $this->manager->updateAutosave(3.0, ['money' => 200], 66.0); + $this->assertNotNull($result); + $this->assertSame('autosave', $result->name); + $this->assertSame(['money' => 200], $result->gameState); + $this->assertTrue($this->manager->exists('autosave')); + } + + public function testCustomAutosaveSlot(): void + { + $this->manager->setAutosaveInterval(1.0); + $this->manager->setAutosaveSlot('quick_save'); + + $this->manager->updateAutosave(2.0, ['x' => 1]); + $this->assertTrue($this->manager->exists('quick_save')); + } + + public function testSignalDispatched(): void + { + $dispatcher = new Dispatcher(); + $manager = new SaveManager($this->tmpDir, $dispatcher); + + $signals = []; + $dispatcher->register('save.completed', function (SaveSignal $s) use (&$signals) { + $signals[] = $s; + }); + $dispatcher->register('save.loaded', function (SaveSignal $s) use (&$signals) { + $signals[] = $s; + }); + $dispatcher->register('save.deleted', function (SaveSignal $s) use (&$signals) { + $signals[] = $s; + }); + + $manager->save('signal_test', ['data' => true]); + $this->assertCount(1, $signals); + $this->assertSame('signal_test', $signals[0]->slotName); + $this->assertSame(SaveSignal::SAVE, $signals[0]->action); + + $manager->load('signal_test'); + $this->assertCount(2, $signals); + $this->assertSame(SaveSignal::LOAD, $signals[1]->action); + + $manager->delete('signal_test'); + $this->assertCount(3, $signals); + $this->assertSame(SaveSignal::DELETE, $signals[2]->action); + } + + public function testSlotNameSanitization(): void + { + // Slot names with special characters should be sanitized + $this->manager->save('save../../../etc', ['hack' => false]); + $this->assertTrue($this->manager->exists('save../../../etc')); + + $loaded = $this->manager->load('save../../../etc'); + $this->assertSame(['hack' => false], $loaded->gameState); + } + + public function testOverwriteSlot(): void + { + $this->manager->save('overwrite', ['version' => 1]); + $this->manager->save('overwrite', ['version' => 2]); + + $loaded = $this->manager->load('overwrite'); + $this->assertSame(['version' => 2], $loaded->gameState); + } + + public function testSaveSlotFromArray(): void + { + $data = [ + 'name' => 'test', + 'version' => 1, + 'timestamp' => 1234567890.0, + 'playTime' => 42.0, + 'description' => 'A test', + 'gameState' => ['key' => 'value'], + ]; + + $slot = SaveSlot::fromArray($data); + $this->assertSame('test', $slot->name); + $this->assertSame(42.0, $slot->playTime); + $this->assertSame(['key' => 'value'], $slot->gameState); + $this->assertNull($slot->sceneData); + + // Round-trip + $restored = SaveSlot::fromArray($slot->toArray()); + $this->assertSame($slot->name, $restored->name); + $this->assertSame($slot->gameState, $restored->gameState); + } +} diff --git a/tests/Scene/MilestoneTest.php b/tests/Scene/MilestoneTest.php new file mode 100644 index 0000000..6555e00 --- /dev/null +++ b/tests/Scene/MilestoneTest.php @@ -0,0 +1,165 @@ +componentRegistry = new ComponentRegistry(); + $this->componentRegistry->register('SpriteRenderer', SpriteRenderer::class); + $this->componentRegistry->register('SpriteAnimator', SpriteAnimator::class); + + $this->loader = new SceneLoader($this->componentRegistry); + $this->saver = new SceneSaver($this->componentRegistry); + $this->prefabManager = new PrefabManager( + $this->loader, + __DIR__ . '/../../examples/office_demo' + ); + + $this->entities = new EntityRegistry(); + $this->entities->registerComponent(Transform::class); + $this->entities->registerComponent(NameComponent::class); + $this->entities->registerComponent(SpriteRenderer::class); + $this->entities->registerComponent(SpriteAnimator::class); + } + + public function testLoadOfficeSceneCreates50PlusEntities(): void + { + $scenePath = __DIR__ . '/../../examples/office_demo/scenes/office_level1.json'; + $entityIds = $this->loader->loadFile($scenePath, $this->entities); + + // Milestone: 50+ entities loaded from JSON + $this->assertGreaterThanOrEqual(50, count($entityIds), + 'Milestone requires 50+ entities. Got: ' . count($entityIds)); + } + + public function testSceneHasEntityHierarchy(): void + { + $scenePath = __DIR__ . '/../../examples/office_demo/scenes/office_level1.json'; + $this->loader->loadFile($scenePath, $this->entities); + + // Find the "Office" root entity + $officeEntity = null; + foreach ($this->entities->view(NameComponent::class) as $id => $name) { + if ($name->name === 'Office') { + $officeEntity = $id; + break; + } + } + $this->assertNotNull($officeEntity, 'Root entity "Office" should exist'); + + // Office should have no parent + $officeTransform = $this->entities->get($officeEntity, Transform::class); + $this->assertNull($officeTransform->parent); + + // Find a child entity and verify its parent chain + $deskEntity = null; + foreach ($this->entities->view(NameComponent::class) as $id => $name) { + if ($name->name === 'Desk_A1') { + $deskEntity = $id; + break; + } + } + $this->assertNotNull($deskEntity, 'Child entity "Desk_A1" should exist'); + + $deskTransform = $this->entities->get($deskEntity, Transform::class); + $this->assertNotNull($deskTransform->parent, 'Desk should have a parent'); + } + + public function testAllEntitiesHaveSpriteRenderers(): void + { + $scenePath = __DIR__ . '/../../examples/office_demo/scenes/office_level1.json'; + $this->loader->loadFile($scenePath, $this->entities); + + // All leaf entities (those with SpriteRenderer) should have valid sprite paths + $spriteCount = 0; + foreach ($this->entities->view(SpriteRenderer::class) as $id => $sprite) { + $this->assertNotEmpty($sprite->sprite, "Entity {$id} has empty sprite path"); + $this->assertNotEmpty($sprite->sortingLayer, "Entity {$id} has empty sorting layer"); + $spriteCount++; + } + + $this->assertGreaterThan(30, $spriteCount, 'Should have 30+ sprite renderers'); + } + + public function testPrefabInstantiation(): void + { + $entityIds = $this->prefabManager->instantiate('prefabs/employee.json', $this->entities); + + $this->assertNotEmpty($entityIds); + $entityId = $entityIds[0]; + + // Should have name, transform, sprite renderer, sprite animator + $this->assertTrue($this->entities->has($entityId, NameComponent::class)); + $this->assertTrue($this->entities->has($entityId, Transform::class)); + $this->assertTrue($this->entities->has($entityId, SpriteRenderer::class)); + $this->assertTrue($this->entities->has($entityId, SpriteAnimator::class)); + + $name = $this->entities->get($entityId, NameComponent::class); + $this->assertSame('Employee', $name->name); + } + + public function testPrefabWithOverrides(): void + { + $entityIds = $this->prefabManager->instantiate('prefabs/employee.json', $this->entities, [ + 'name' => 'Senior Dev', + 'transform' => ['position' => [100, 200, 0]], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'sprites/senior_dev.png'], + ], + ]); + + $entityId = $entityIds[0]; + + $name = $this->entities->get($entityId, NameComponent::class); + $this->assertSame('Senior Dev', $name->name); + + $transform = $this->entities->get($entityId, Transform::class); + $this->assertEqualsWithDelta(100.0, $transform->position->x, 0.001); + + $sprite = $this->entities->get($entityId, SpriteRenderer::class); + $this->assertSame('sprites/senior_dev.png', $sprite->sprite); + } + + public function testSceneRoundTrip(): void + { + // Load scene + $scenePath = __DIR__ . '/../../examples/office_demo/scenes/office_level1.json'; + $entityIds = $this->loader->loadFile($scenePath, $this->entities); + $originalCount = count($entityIds); + + // Save to array + $savedData = $this->saver->toArray($this->entities); + + // Create new registry and reload + $entities2 = new EntityRegistry(); + $entities2->registerComponent(Transform::class); + $entities2->registerComponent(NameComponent::class); + $entities2->registerComponent(SpriteRenderer::class); + $entities2->registerComponent(SpriteAnimator::class); + + $entityIds2 = $this->loader->loadArray($savedData, $entities2); + + // Should have same number of entities after round-trip + $this->assertSame($originalCount, count($entityIds2), + 'Round-trip should preserve entity count'); + } +} diff --git a/tests/Scene/SceneLoaderFallbackTest.php b/tests/Scene/SceneLoaderFallbackTest.php new file mode 100644 index 0000000..f557b71 --- /dev/null +++ b/tests/Scene/SceneLoaderFallbackTest.php @@ -0,0 +1,146 @@ +tmpDir = sys_get_temp_dir() . '/visu_loader_test_' . uniqid(); + mkdir($this->tmpDir . '/Scenes', 0755, true); + } + + protected function tearDown(): void + { + // Clean up temp files + $this->removeDir($this->tmpDir); + } + + public function testLoadsJsonWhenNoTranspiledDirSet(): void + { + $registry = new ComponentRegistry(); + $loader = new SceneLoader($registry); + + $jsonPath = $this->tmpDir . '/test_scene.json'; + file_put_contents($jsonPath, json_encode([ + 'entities' => [ + ['name' => 'TestEntity', 'transform' => ['position' => [1, 2, 3]]], + ], + ])); + + // Create a mock entities interface + $entities = $this->createMockEntities(); + + $ids = $loader->loadFile($jsonPath, $entities); + $this->assertNotEmpty($ids); + } + + public function testFallsBackToJsonWhenTranspiledNotFound(): void + { + $registry = new ComponentRegistry(); + $loader = new SceneLoader($registry); + $loader->setTranspiledDir($this->tmpDir); + + $jsonPath = $this->tmpDir . '/missing_factory.json'; + file_put_contents($jsonPath, json_encode([ + 'entities' => [ + ['name' => 'FallbackEntity', 'transform' => []], + ], + ])); + + $entities = $this->createMockEntities(); + $ids = $loader->loadFile($jsonPath, $entities); + $this->assertNotEmpty($ids); + } + + public function testUsesTranspiledFactoryWhenAvailable(): void + { + $registry = new ComponentRegistry(); + $loader = new SceneLoader($registry); + $loader->setTranspiledDir($this->tmpDir); + + // Create a transpiled factory PHP file + $factoryCode = <<<'PHP' + */ + public static function load(EntitiesInterface $entities): array + { + $ids = []; + $ids[] = $entities->create(); + $ids[] = $entities->create(); + return $ids; + } +} +PHP; + file_put_contents($this->tmpDir . '/Scenes/MyTestScene.php', $factoryCode); + + // The JSON file that matches (my_test_scene.json -> MyTestScene) + $jsonPath = $this->tmpDir . '/my_test_scene.json'; + file_put_contents($jsonPath, '{"entities":[]}'); + + $entities = $this->createMockEntities(); + $ids = $loader->loadFile($jsonPath, $entities); + + // The factory creates 2 entities + $this->assertCount(2, $ids); + } + + public function testToClassNameMapping(): void + { + $registry = new ComponentRegistry(); + $loader = new SceneLoader($registry); + + $ref = new \ReflectionMethod($loader, 'toClassName'); + $ref->setAccessible(true); + + $this->assertSame('OfficeLevel1', $ref->invoke($loader, 'office_level1')); + $this->assertSame('MainMenu', $ref->invoke($loader, 'main-menu')); + $this->assertSame('Hud', $ref->invoke($loader, 'hud')); + } + + private function createMockEntities(): \VISU\ECS\EntitiesInterface + { + $nextId = 1; + $mock = $this->createMock(\VISU\ECS\EntitiesInterface::class); + $mock->method('create')->willReturnCallback(function () use (&$nextId) { + return $nextId++; + }); + $mock->method('attach')->willReturnCallback(function (int $entity, object $component) { + return $component; + }); + + return $mock; + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/Scene/SceneLoaderTest.php b/tests/Scene/SceneLoaderTest.php new file mode 100644 index 0000000..1d579bb --- /dev/null +++ b/tests/Scene/SceneLoaderTest.php @@ -0,0 +1,126 @@ +componentRegistry = new ComponentRegistry(); + $this->componentRegistry->register('SpriteRenderer', SpriteRenderer::class); + + $this->loader = new SceneLoader($this->componentRegistry); + $this->entities = new EntityRegistry(); + $this->entities->registerComponent(Transform::class); + $this->entities->registerComponent(NameComponent::class); + $this->entities->registerComponent(SpriteRenderer::class); + } + + public function testLoadArrayCreatesEntities(): void + { + $data = [ + 'entities' => [ + [ + 'name' => 'Player', + 'transform' => [ + 'position' => [10, 20, 0], + 'scale' => [2, 2, 1], + ], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'player.png'], + ], + ], + [ + 'name' => 'Enemy', + 'transform' => [ + 'position' => [50, 50, 0], + ], + ], + ], + ]; + + $entityIds = $this->loader->loadArray($data, $this->entities); + + $this->assertCount(2, $entityIds); + + // Check first entity + $playerId = $entityIds[0]; + $name = $this->entities->get($playerId, NameComponent::class); + $this->assertSame('Player', $name->name); + + $transform = $this->entities->get($playerId, Transform::class); + $this->assertEqualsWithDelta(10.0, $transform->position->x, 0.001); + $this->assertEqualsWithDelta(20.0, $transform->position->y, 0.001); + $this->assertEqualsWithDelta(2.0, $transform->scale->x, 0.001); + + $sprite = $this->entities->get($playerId, SpriteRenderer::class); + $this->assertSame('player.png', $sprite->sprite); + + // Check second entity + $enemyId = $entityIds[1]; + $enemyName = $this->entities->get($enemyId, NameComponent::class); + $this->assertSame('Enemy', $enemyName->name); + } + + public function testLoadArrayWithChildren(): void + { + $data = [ + 'entities' => [ + [ + 'name' => 'Parent', + 'transform' => ['position' => [0, 0, 0]], + 'children' => [ + [ + 'name' => 'Child', + 'transform' => ['position' => [5, 5, 0]], + ], + ], + ], + ], + ]; + + $entityIds = $this->loader->loadArray($data, $this->entities); + + $this->assertCount(2, $entityIds); + + $parentId = $entityIds[0]; + $childId = $entityIds[1]; + + $childTransform = $this->entities->get($childId, Transform::class); + $this->assertSame($parentId, $childTransform->parent); + } + + public function testLoadArrayWithNamedKeys(): void + { + $data = [ + 'entities' => [ + [ + 'name' => 'Test', + 'transform' => [ + 'position' => ['x' => 1, 'y' => 2, 'z' => 3], + 'scale' => ['x' => 4, 'y' => 5, 'z' => 6], + ], + ], + ], + ]; + + $entityIds = $this->loader->loadArray($data, $this->entities); + $transform = $this->entities->get($entityIds[0], Transform::class); + + $this->assertEqualsWithDelta(1.0, $transform->position->x, 0.001); + $this->assertEqualsWithDelta(2.0, $transform->position->y, 0.001); + $this->assertEqualsWithDelta(3.0, $transform->position->z, 0.001); + } +} diff --git a/tests/Scene/SceneSaverTest.php b/tests/Scene/SceneSaverTest.php new file mode 100644 index 0000000..f498fb2 --- /dev/null +++ b/tests/Scene/SceneSaverTest.php @@ -0,0 +1,115 @@ +componentRegistry = new ComponentRegistry(); + $this->componentRegistry->register('SpriteRenderer', SpriteRenderer::class); + + $this->saver = new SceneSaver($this->componentRegistry); + $this->loader = new SceneLoader($this->componentRegistry); + $this->entities = new EntityRegistry(); + $this->entities->registerComponent(Transform::class); + $this->entities->registerComponent(NameComponent::class); + $this->entities->registerComponent(SpriteRenderer::class); + } + + public function testToArraySerializesEntities(): void + { + $entity = $this->entities->create(); + $this->entities->attach($entity, new NameComponent('TestEntity')); + + $transform = new Transform(); + $transform->position = new Vec3(10, 20, 0); + $this->entities->attach($entity, $transform); + + $sprite = new SpriteRenderer(); + $sprite->sprite = 'test.png'; + $this->entities->attach($entity, $sprite); + + $data = $this->saver->toArray($this->entities); + + $this->assertArrayHasKey('entities', $data); + $this->assertCount(1, $data['entities']); + + $entityData = $data['entities'][0]; + $this->assertSame('TestEntity', $entityData['name']); + $this->assertEqualsWithDelta(10.0, $entityData['transform']['position'][0], 0.01); + $this->assertEqualsWithDelta(20.0, $entityData['transform']['position'][1], 0.01); + + $this->assertCount(1, $entityData['components']); + $this->assertSame('SpriteRenderer', $entityData['components'][0]['type']); + $this->assertSame('test.png', $entityData['components'][0]['sprite']); + } + + public function testRoundTripPreservesData(): void + { + $inputData = [ + 'entities' => [ + [ + 'name' => 'Player', + 'transform' => [ + 'position' => [10, 20, 0], + 'rotation' => [0, 0, 0], + 'scale' => [1, 1, 1], + ], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'player.png', 'sortingLayer' => 'Default'], + ], + ], + ], + ]; + + // Load + $this->loader->loadArray($inputData, $this->entities); + + // Save + $outputData = $this->saver->toArray($this->entities); + + // Verify round-trip + $this->assertSame('Player', $outputData['entities'][0]['name']); + $this->assertEqualsWithDelta(10.0, $outputData['entities'][0]['transform']['position'][0], 0.01); + $this->assertSame('player.png', $outputData['entities'][0]['components'][0]['sprite']); + } + + public function testSerializesChildEntities(): void + { + $parent = $this->entities->create(); + $this->entities->attach($parent, new NameComponent('Parent')); + $this->entities->attach($parent, new Transform()); + + $child = $this->entities->create(); + $this->entities->attach($child, new NameComponent('Child')); + $childTransform = new Transform(); + $childTransform->setParent($this->entities, $parent); + $this->entities->attach($child, $childTransform); + + $data = $this->saver->toArray($this->entities); + + // Should have only 1 root entity + $this->assertCount(1, $data['entities']); + $this->assertSame('Parent', $data['entities'][0]['name']); + + // Child should be nested + $this->assertCount(1, $data['entities'][0]['children']); + $this->assertSame('Child', $data['entities'][0]['children'][0]['name']); + } +} diff --git a/tests/Setup/ProjectSetupTest.php b/tests/Setup/ProjectSetupTest.php new file mode 100644 index 0000000..e148419 --- /dev/null +++ b/tests/Setup/ProjectSetupTest.php @@ -0,0 +1,220 @@ +tmpDir = sys_get_temp_dir() . '/visu_setup_test_' . uniqid(); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + // Recursively remove temp directory + $this->removeDir($this->tmpDir); + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . DIRECTORY_SEPARATOR . $item; + if (is_dir($path)) { + $this->removeDir($path); + } else { + unlink($path); + } + } + rmdir($dir); + } + + private function createSetup(bool $interactive = false): ProjectSetup + { + $output = []; + return new ProjectSetup( + projectRoot: $this->tmpDir, + interactive: $interactive, + output: function (string $line) use (&$output): void { + $output[] = $line; + }, + confirm: function (string $question): bool { + return true; // always confirm in tests + }, + ); + } + + public function testCreatesRequiredDirectories(): void + { + $setup = $this->createSetup(); + $setup->run(); + + $this->assertDirectoryExists($this->tmpDir . '/var/cache'); + $this->assertDirectoryExists($this->tmpDir . '/var/store'); + $this->assertDirectoryExists($this->tmpDir . '/resources'); + $this->assertDirectoryExists($this->tmpDir . '/resources/shader'); + } + + public function testCreatesAppCtn(): void + { + $setup = $this->createSetup(); + $setup->run(); + + $file = $this->tmpDir . '/app.ctn'; + $this->assertFileExists($file); + $this->assertStringContainsString('container configuration', file_get_contents($file) ?: ''); + } + + public function testCreatesGamePhp(): void + { + $setup = $this->createSetup(); + $setup->run(); + + $file = $this->tmpDir . '/game.php'; + $this->assertFileExists($file); + $this->assertStringContainsString('VISU_PATH_ROOT', file_get_contents($file) ?: ''); + } + + public function testCreatesClaudeMd(): void + { + $setup = $this->createSetup(); + $setup->run(); + + $file = $this->tmpDir . '/CLAUDE.md'; + $this->assertFileExists($file); + $this->assertStringContainsString('VISU', file_get_contents($file) ?: ''); + } + + public function testCreatesGitignore(): void + { + $setup = $this->createSetup(); + $setup->run(); + + $file = $this->tmpDir . '/.gitignore'; + $this->assertFileExists($file); + $content = file_get_contents($file) ?: ''; + $this->assertStringContainsString('/vendor/', $content); + $this->assertStringContainsString('/var/', $content); + } + + public function testDoesNotOverwriteExistingAppCtn(): void + { + $file = $this->tmpDir . '/app.ctn'; + file_put_contents($file, 'my custom config'); + + $setup = $this->createSetup(); + $setup->run(); + + $this->assertSame('my custom config', file_get_contents($file)); + } + + public function testDoesNotOverwriteExistingGamePhp(): void + { + $file = $this->tmpDir . '/game.php'; + file_put_contents($file, 'createSetup(); + $setup->run(); + + $this->assertSame('tmpDir . '/CLAUDE.md'; + file_put_contents($file, '# My Project'); + + $setup = $this->createSetup(); + $setup->run(); + + $this->assertSame('# My Project', file_get_contents($file)); + } + + public function testAppendsToExistingGitignore(): void + { + $file = $this->tmpDir . '/.gitignore'; + file_put_contents($file, "*.log\n"); + + $setup = $this->createSetup(); + $setup->run(); + + $content = file_get_contents($file) ?: ''; + $this->assertStringContainsString('*.log', $content); + $this->assertStringContainsString('/vendor/', $content); + $this->assertStringContainsString('/var/', $content); + } + + public function testGitignoreSkipsExistingEntries(): void + { + $file = $this->tmpDir . '/.gitignore'; + file_put_contents($file, "/vendor/\n/var/\n.DS_Store\n"); + + $setup = $this->createSetup(); + $setup->run(); + + // Should not duplicate entries + $content = file_get_contents($file) ?: ''; + $this->assertSame(1, substr_count($content, '/vendor/')); + } + + public function testSecondRunCreatesNothing(): void + { + $setup1 = $this->createSetup(); + $setup1->run(); + + $setup2 = $this->createSetup(); + $result = $setup2->run(); + + $this->assertFalse($result); + $this->assertEmpty($setup2->getCreated()); + } + + public function testReturnsCreatedAndSkippedLists(): void + { + $setup = $this->createSetup(); + $setup->run(); + + $this->assertNotEmpty($setup->getCreated()); + + // Run again + $setup2 = $this->createSetup(); + $setup2->run(); + + $this->assertNotEmpty($setup2->getSkipped()); + $this->assertEmpty($setup2->getCreated()); + } + + public function testNonInteractiveCreatesEverything(): void + { + $setup = new ProjectSetup( + projectRoot: $this->tmpDir, + interactive: false, + output: function (string $line): void {}, + confirm: function (string $question): bool { + throw new \RuntimeException('Should not prompt in non-interactive mode'); + }, + ); + + $setup->run(); + + $this->assertFileExists($this->tmpDir . '/app.ctn'); + $this->assertFileExists($this->tmpDir . '/game.php'); + $this->assertFileExists($this->tmpDir . '/CLAUDE.md'); + $this->assertFileExists($this->tmpDir . '/.gitignore'); + } +} diff --git a/tests/Signals/ECS/Collision3DSignalTest.php b/tests/Signals/ECS/Collision3DSignalTest.php new file mode 100644 index 0000000..1b0dd02 --- /dev/null +++ b/tests/Signals/ECS/Collision3DSignalTest.php @@ -0,0 +1,23 @@ +assertSame(10, $signal->entityA); + $this->assertSame(20, $signal->entityB); + $this->assertEqualsWithDelta(1.0, $signal->contactPoint->x, 0.001); + $this->assertEqualsWithDelta(1.0, $signal->contactNormal->y, 0.001); + $this->assertSame(0.5, $signal->penetration); + } +} diff --git a/tests/System/Camera2DSystemTest.php b/tests/System/Camera2DSystemTest.php new file mode 100644 index 0000000..0bbf6ad --- /dev/null +++ b/tests/System/Camera2DSystemTest.php @@ -0,0 +1,161 @@ +entities = new EntityRegistry(); + $this->entities->registerComponent(Transform::class); + $this->camera = new Camera2DSystem(); + $this->camera->register($this->entities); + } + + public function testInitialPosition(): void + { + $this->assertEqualsWithDelta(0.0, $this->camera->cameraData->x, 0.001); + $this->assertEqualsWithDelta(0.0, $this->camera->cameraData->y, 0.001); + $this->assertEqualsWithDelta(1.0, $this->camera->cameraData->zoom, 0.001); + } + + public function testSetPosition(): void + { + $this->camera->setPosition(100.0, 200.0); + $this->assertEqualsWithDelta(100.0, $this->camera->cameraData->x, 0.001); + $this->assertEqualsWithDelta(200.0, $this->camera->cameraData->y, 0.001); + } + + public function testSetZoom(): void + { + $this->camera->setZoom(2.5); + $this->assertEqualsWithDelta(2.5, $this->camera->cameraData->zoom, 0.001); + } + + public function testZoomLimits(): void + { + $this->camera->setZoomLimits(0.5, 3.0); + $this->camera->setZoom(0.1); + $this->assertEqualsWithDelta(0.5, $this->camera->cameraData->zoom, 0.001); + + $this->camera->setZoom(5.0); + $this->assertEqualsWithDelta(3.0, $this->camera->cameraData->zoom, 0.001); + } + + public function testZoomDelta(): void + { + $this->camera->setZoom(1.0); + $this->camera->zoom(0.5); + $this->assertEqualsWithDelta(1.5, $this->camera->cameraData->zoom, 0.001); + } + + public function testBoundsClamp(): void + { + $this->camera->setBounds(-100, -100, 100, 100); + $this->camera->setPosition(200, 200); + $this->assertEqualsWithDelta(100.0, $this->camera->cameraData->x, 0.001); + $this->assertEqualsWithDelta(100.0, $this->camera->cameraData->y, 0.001); + + $this->camera->setPosition(-200, -200); + $this->assertEqualsWithDelta(-100.0, $this->camera->cameraData->x, 0.001); + $this->assertEqualsWithDelta(-100.0, $this->camera->cameraData->y, 0.001); + } + + public function testFollowTarget(): void + { + $e = $this->entities->create(); + $t = new Transform(); + $t->position = new Vec3(100, 50, 0); + $this->entities->attach($e, $t); + + $this->camera->setFollowTarget($e, 0.0); // 0 damping = instant follow + + $this->camera->update($this->entities); + $this->assertEqualsWithDelta(100.0, $this->camera->cameraData->x, 1.0); + $this->assertEqualsWithDelta(50.0, $this->camera->cameraData->y, 1.0); + } + + public function testFollowTargetWithDamping(): void + { + $e = $this->entities->create(); + $t = new Transform(); + $t->position = new Vec3(100, 0, 0); + $this->entities->attach($e, $t); + + $this->camera->setFollowTarget($e, 0.5); // 50% damping + + $this->camera->update($this->entities); + // With damping 0.5, t = 0.5, moves halfway: 0 + (100-0)*0.5 = 50 + $this->assertEqualsWithDelta(50.0, $this->camera->cameraData->x, 1.0); + } + + public function testShakeStartsAndStops(): void + { + $this->assertFalse($this->camera->isShaking()); + + $this->camera->shake(0.5, 0.3); + $this->assertTrue($this->camera->isShaking()); + + // Run enough frames to exhaust shake duration (0.3s at ~60fps = ~18 frames) + for ($i = 0; $i < 30; $i++) { + $this->camera->update($this->entities); + } + + $this->assertFalse($this->camera->isShaking()); + } + + public function testShakeMovesCamera(): void + { + $this->camera->setPosition(100, 100); + $this->camera->shake(1.0, 1.0, 20.0); + + // Run a few frames and check the camera moved + $positions = []; + for ($i = 0; $i < 5; $i++) { + $this->camera->update($this->entities); + $positions[] = [$this->camera->cameraData->x, $this->camera->cameraData->y]; + } + + // At least one position should differ from the base (100, 100) + $moved = false; + foreach ($positions as [$x, $y]) { + if (abs($x - 100.0) > 0.001 || abs($y - 100.0) > 0.001) { + $moved = true; + break; + } + } + $this->assertTrue($moved, 'Camera should move during shake'); + } + + public function testShakeResetsAfterDuration(): void + { + $this->camera->setPosition(50, 50); + $this->camera->shake(1.0, 0.1); + + // Exhaust shake + for ($i = 0; $i < 20; $i++) { + $this->camera->update($this->entities); + } + + // After shake ends, camera should return close to base position + $this->assertEqualsWithDelta(50.0, $this->camera->cameraData->x, 0.1); + $this->assertEqualsWithDelta(50.0, $this->camera->cameraData->y, 0.1); + } + + public function testUpdateWithoutFollowTarget(): void + { + $this->camera->setPosition(42, 24); + $this->camera->update($this->entities); + $this->assertEqualsWithDelta(42.0, $this->camera->cameraData->x, 0.001); + $this->assertEqualsWithDelta(24.0, $this->camera->cameraData->y, 0.001); + } +} diff --git a/tests/System/Collision2DSystemTest.php b/tests/System/Collision2DSystemTest.php new file mode 100644 index 0000000..affd625 --- /dev/null +++ b/tests/System/Collision2DSystemTest.php @@ -0,0 +1,217 @@ +entities = new EntityRegistry(); + $this->entities->registerComponent(Transform::class); + $this->dispatcher = new Dispatcher(); + $this->system = new Collision2DSystem($this->dispatcher); + $this->system->register($this->entities); + } + + private function createBoxEntity(float $x, float $y, float $hw = 16.0, float $hh = 16.0, bool $isTrigger = false): int + { + $e = $this->entities->create(); + $t = new Transform(); + $t->position = new Vec3($x, $y, 0); + $this->entities->attach($e, $t); + + $box = new BoxCollider2D(); + $box->halfWidth = $hw; + $box->halfHeight = $hh; + $box->isTrigger = $isTrigger; + $this->entities->attach($e, $box); + return $e; + } + + private function createCircleEntity(float $x, float $y, float $radius = 16.0, bool $isTrigger = false): int + { + $e = $this->entities->create(); + $t = new Transform(); + $t->position = new Vec3($x, $y, 0); + $this->entities->attach($e, $t); + + $circle = new CircleCollider2D(); + $circle->radius = $radius; + $circle->isTrigger = $isTrigger; + $this->entities->attach($e, $circle); + return $e; + } + + public function testBoxBoxCollision(): void + { + $this->createBoxEntity(0, 0, 16, 16); + $this->createBoxEntity(20, 0, 16, 16); // overlapping (distance=20, combined hw=32) + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(1, $collisions); + } + + public function testBoxBoxNoCollision(): void + { + $this->createBoxEntity(0, 0, 16, 16); + $this->createBoxEntity(100, 0, 16, 16); // far apart + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(0, $collisions); + } + + public function testCircleCircleCollision(): void + { + $this->createCircleEntity(0, 0, 20); + $this->createCircleEntity(30, 0, 20); // overlapping (distance=30, combined r=40) + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(1, $collisions); + } + + public function testCircleCircleNoCollision(): void + { + $this->createCircleEntity(0, 0, 10); + $this->createCircleEntity(30, 0, 10); // not overlapping (distance=30, combined r=20) + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(0, $collisions); + } + + public function testBoxCircleCollision(): void + { + $this->createBoxEntity(0, 0, 16, 16); + $this->createCircleEntity(20, 0, 10); // overlapping + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(1, $collisions); + } + + public function testTriggerSignal(): void + { + $this->createBoxEntity(0, 0, 16, 16, true); // trigger + $this->createBoxEntity(10, 0, 16, 16); + + $triggers = []; + $this->dispatcher->register('collision.trigger', function (TriggerSignal $s) use (&$triggers) { + $triggers[] = $s; + }); + + // First frame: ENTER + $this->system->update($this->entities); + $this->assertCount(1, $triggers); + $this->assertSame(TriggerSignal::ENTER, $triggers[0]->phase); + + // Second frame: STAY + $triggers = []; + $this->system->update($this->entities); + $this->assertCount(1, $triggers); + $this->assertSame(TriggerSignal::STAY, $triggers[0]->phase); + } + + public function testTriggerExit(): void + { + $eA = $this->createBoxEntity(0, 0, 16, 16, true); + $eB = $this->createBoxEntity(10, 0, 16, 16); + + $triggers = []; + $this->dispatcher->register('collision.trigger', function (TriggerSignal $s) use (&$triggers) { + $triggers[] = $s; + }); + + // Frame 1: ENTER + $this->system->update($this->entities); + $this->assertSame(TriggerSignal::ENTER, $triggers[0]->phase); + + // Move B far away + $tB = $this->entities->get($eB, Transform::class); + $tB->position = new Vec3(200, 0, 0); + + // Frame 2: EXIT + $triggers = []; + $this->system->update($this->entities); + $this->assertCount(1, $triggers); + $this->assertSame(TriggerSignal::EXIT, $triggers[0]->phase); + } + + public function testLayerFiltering(): void + { + $eA = $this->createBoxEntity(0, 0, 16, 16); + $eB = $this->createBoxEntity(10, 0, 16, 16); + + // Set different layers + $boxA = $this->entities->get($eA, BoxCollider2D::class); + $boxA->layer = 1; + $boxA->mask = 2; // only collides with layer 2 + + $boxB = $this->entities->get($eB, BoxCollider2D::class); + $boxB->layer = 4; // not in mask of A + $boxB->mask = 1; + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(0, $collisions); // filtered by layer + } + + public function testContactPoint(): void + { + $this->createBoxEntity(0, 0, 16, 16); + $this->createBoxEntity(20, 0, 16, 16); + + $collisions = []; + $this->dispatcher->register('collision', function (CollisionSignal $s) use (&$collisions) { + $collisions[] = $s; + }); + + $this->system->update($this->entities); + $this->assertCount(1, $collisions); + // Contact point should be midpoint between centers + $this->assertEqualsWithDelta(10.0, $collisions[0]->contactX, 0.1); + $this->assertEqualsWithDelta(0.0, $collisions[0]->contactY, 0.1); + } +} diff --git a/tests/Testing/FakeInputTest.php b/tests/Testing/FakeInputTest.php new file mode 100644 index 0000000..0a781b9 --- /dev/null +++ b/tests/Testing/FakeInputTest.php @@ -0,0 +1,193 @@ +dispatcher = new Dispatcher(); + $this->input = new FakeInput($this->dispatcher, 1280, 720); + } + + // ── Cursor ─────────────────────────────────────────────────────────── + + public function testInitialCursorPositionIsOrigin(): void + { + $pos = $this->input->getCursorPosition(); + $this->assertSame(0.0, $pos->x); + $this->assertSame(0.0, $pos->y); + } + + public function testSimulateCursorPosUpdatesCursorPosition(): void + { + $this->input->simulateCursorPos(320.0, 240.0); + + $pos = $this->input->getCursorPosition(); + $this->assertSame(320.0, $pos->x); + $this->assertSame(240.0, $pos->y); + } + + public function testGetLastCursorPositionReturnsPositionBeforeLastMove(): void + { + $this->input->simulateCursorPos(100.0, 50.0); + $this->input->simulateCursorPos(200.0, 100.0); + + $this->assertSame(100.0, $this->input->getLastCursorPosition()->x); + $this->assertSame(50.0, $this->input->getLastCursorPosition()->y); + } + + public function testNormalizedCursorPositionCenterIsZero(): void + { + $this->input->simulateCursorPos(640.0, 360.0); // exact center of 1280×720 + $norm = $this->input->getNormalizedCursorPosition(); + + $this->assertEqualsWithDelta(0.0, $norm->x, 0.01); + $this->assertEqualsWithDelta(0.0, $norm->y, 0.01); + } + + // ── Mouse buttons ───────────────────────────────────────────────────── + + public function testMouseButtonInitiallyReleased(): void + { + $this->assertFalse($this->input->isMouseButtonPressed(MouseButton::LEFT)); + $this->assertTrue($this->input->isMouseButtonReleased(MouseButton::LEFT)); + } + + public function testSimulateMouseButtonPressRegistersState(): void + { + $this->input->simulateMouseButton(MouseButton::LEFT, true); + + $this->assertTrue($this->input->isMouseButtonPressed(MouseButton::LEFT)); + $this->assertFalse($this->input->isMouseButtonReleased(MouseButton::LEFT)); + $this->assertTrue($this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)); + $this->assertTrue($this->input->hasMouseButtonBeenPressedThisFrame(MouseButton::LEFT)); + } + + public function testSimulateMouseButtonReleaseRegistersState(): void + { + $this->input->simulateMouseButton(MouseButton::LEFT, true); + $this->input->simulateMouseButton(MouseButton::LEFT, false); + + $this->assertFalse($this->input->isMouseButtonPressed(MouseButton::LEFT)); + $this->assertTrue($this->input->hasMouseButtonBeenReleased(MouseButton::LEFT)); + $this->assertTrue($this->input->hasMouseButtonBeenReleasedThisFrame(MouseButton::LEFT)); + } + + public function testEndFrameClearsPerFrameMouseState(): void + { + $this->input->simulateMouseButton(MouseButton::LEFT, true); + $this->input->endFrame(); + + $this->assertFalse($this->input->hasMouseButtonBeenPressedThisFrame(MouseButton::LEFT)); + $this->assertFalse($this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)); + // Button is still held down (state persists across frames) + $this->assertTrue($this->input->isMouseButtonPressed(MouseButton::LEFT)); + } + + public function testSimulateClickPressesAndReleasesButton(): void + { + $this->input->simulateClick(MouseButton::LEFT); + + $this->assertTrue($this->input->hasMouseButtonBeenPressed(MouseButton::LEFT)); + $this->assertTrue($this->input->hasMouseButtonBeenReleased(MouseButton::LEFT)); + $this->assertFalse($this->input->isMouseButtonPressed(MouseButton::LEFT)); + } + + // ── Keys ───────────────────────────────────────────────────────────── + + public function testKeyInitiallyReleased(): void + { + $this->assertFalse($this->input->isKeyPressed(Key::ESCAPE)); + $this->assertTrue($this->input->isKeyReleased(Key::ESCAPE)); + } + + public function testSimulateKeyPressRegistersState(): void + { + $this->input->simulateKeyPress(Key::ESCAPE); + + $this->assertTrue($this->input->isKeyPressed(Key::ESCAPE)); + $this->assertTrue($this->input->hasKeyBeenPressed(Key::ESCAPE)); + $this->assertTrue($this->input->hasKeyBeenPressedThisFrame(Key::ESCAPE)); + $this->assertContains(Key::ESCAPE, $this->input->getKeyPresses()); + $this->assertContains(Key::ESCAPE, $this->input->getKeyPressesThisFrame()); + } + + public function testSimulateKeyReleaseRegistersState(): void + { + $this->input->simulateKeyPress(Key::ESCAPE); + $this->input->simulateKeyRelease(Key::ESCAPE); + + $this->assertFalse($this->input->isKeyPressed(Key::ESCAPE)); + $this->assertTrue($this->input->hasKeyBeenReleased(Key::ESCAPE)); + $this->assertTrue($this->input->hasKeyBeenReleasedThisFrame(Key::ESCAPE)); + } + + public function testEndFrameClearsPerFrameKeyState(): void + { + $this->input->simulateKeyPress(Key::ESCAPE); + $this->input->endFrame(); + + $this->assertFalse($this->input->hasKeyBeenPressedThisFrame(Key::ESCAPE)); + $this->assertFalse($this->input->hasKeyBeenPressed(Key::ESCAPE)); + $this->assertEmpty($this->input->getKeyPressesThisFrame()); + } + + // ── Input context ───────────────────────────────────────────────────── + + public function testContextInitiallyUnclaimed(): void + { + $this->assertTrue($this->input->isContextUnclaimed()); + $this->assertNull($this->input->getCurrentContext()); + } + + public function testClaimAndReleaseContext(): void + { + $this->input->claimContext('ui'); + + $this->assertFalse($this->input->isContextUnclaimed()); + $this->assertTrue($this->input->isClaimedContext('ui')); + $this->assertSame('ui', $this->input->getCurrentContext()); + + $this->input->releaseContext('ui'); + + $this->assertTrue($this->input->isContextUnclaimed()); + } + + public function testReleaseWrongContextDoesNothing(): void + { + $this->input->claimContext('ui'); + $this->input->releaseContext('game'); // wrong context + + $this->assertFalse($this->input->isContextUnclaimed()); + $this->assertSame('ui', $this->input->getCurrentContext()); + } + + // ── Cursor mode ─────────────────────────────────────────────────────── + + public function testCursorModeDefaultsToNormal(): void + { + $this->assertSame(CursorMode::NORMAL, $this->input->getCursorMode()); + } + + public function testSetCursorModeUpdatesMode(): void + { + $this->input->setCursorMode(CursorMode::HIDDEN); + $this->assertSame(CursorMode::HIDDEN, $this->input->getCursorMode()); + } + +} diff --git a/tests/Testing/SnapshotComparatorTest.php b/tests/Testing/SnapshotComparatorTest.php new file mode 100644 index 0000000..3f0b228 --- /dev/null +++ b/tests/Testing/SnapshotComparatorTest.php @@ -0,0 +1,82 @@ +createSolidPng(10, 10, 128, 64, 200); + + $this->assertSame(0.0, SnapshotComparator::compare($png, $png)); + } + + public function testCompletelyDifferentImagesReturnHighValue(): void + { + $black = $this->createSolidPng(10, 10, 0, 0, 0); + $white = $this->createSolidPng(10, 10, 255, 255, 255); + + $this->assertEqualsWithDelta(100.0, SnapshotComparator::compare($black, $white), 0.01); + } + + public function testPartialDifference(): void + { + // Red vs slightly different red + $a = $this->createSolidPng(10, 10, 200, 0, 0); + $b = $this->createSolidPng(10, 10, 210, 0, 0); + + $diff = SnapshotComparator::compare($a, $b); + $this->assertGreaterThan(0.0, $diff); + $this->assertLessThan(5.0, $diff); // small difference + } + + public function testSizeMismatchThrows(): void + { + $a = $this->createSolidPng(10, 10, 0, 0, 0); + $b = $this->createSolidPng(20, 10, 0, 0, 0); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('size mismatch'); + SnapshotComparator::compare($a, $b); + } + + public function testInvalidPngThrows(): void + { + $this->expectException(\RuntimeException::class); + SnapshotComparator::compare('not-a-png', 'also-not-a-png'); + } + + public function testGenerateDiffImageReturnsPng(): void + { + $a = $this->createSolidPng(10, 10, 255, 0, 0); + $b = $this->createSolidPng(10, 10, 0, 0, 255); + + $diffPng = SnapshotComparator::generateDiffImage($a, $b); + + // Verify it's valid PNG + $img = imagecreatefromstring($diffPng); + $this->assertNotFalse($img); + + // Should be 3x width + 2x gap + $this->assertSame(10 * 3 + 4 * 2, imagesx($img)); + $this->assertSame(10, imagesy($img)); + imagedestroy($img); + } +} diff --git a/tests/Transpiler/PrefabTranspilerTest.php b/tests/Transpiler/PrefabTranspilerTest.php new file mode 100644 index 0000000..fa8a467 --- /dev/null +++ b/tests/Transpiler/PrefabTranspilerTest.php @@ -0,0 +1,101 @@ +register('SpriteRenderer', SpriteRenderer::class); + $registry->register('SpriteAnimator', SpriteAnimator::class); + $this->transpiler = new PrefabTranspiler($registry); + } + + public function testTranspilePrefab(): void + { + $data = [ + 'name' => 'Enemy', + 'transform' => ['position' => [0, 0, 0]], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'enemy.png', 'width' => 32, 'height' => 32], + ], + ]; + + $code = $this->transpiler->transpileArray($data, 'Enemy', 'VISU\\Generated\\Prefabs'); + + $this->assertStringContainsString('class Enemy', $code); + $this->assertStringContainsString('namespace VISU\\Generated\\Prefabs;', $code); + $this->assertStringContainsString("new NameComponent('Enemy')", $code); + $this->assertStringContainsString('new SpriteRenderer()', $code); + $this->assertStringContainsString("->sprite = 'enemy.png'", $code); + } + + public function testTranspilePrefabFromFile(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'prefab_') . '.json'; + file_put_contents($tmpFile, json_encode([ + 'name' => 'Bullet', + 'transform' => ['position' => [0, 0, 0]], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'bullet.png'], + ], + ])); + + try { + $code = $this->transpiler->transpile($tmpFile, 'Bullet'); + $this->assertStringContainsString("new NameComponent('Bullet')", $code); + $this->assertStringContainsString("Source: {$tmpFile}", $code); + } finally { + unlink($tmpFile); + } + } + + public function testTranspilePrefabWithAnimator(): void + { + $data = [ + 'name' => 'NPC', + 'transform' => [], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'npc.png'], + [ + 'type' => 'SpriteAnimator', + 'currentAnimation' => 'idle', + 'animations' => [ + 'idle' => ['frames' => [[0, 0, 0.25, 1]], 'fps' => 4, 'loop' => true], + ], + ], + ], + ]; + + $code = $this->transpiler->transpileArray($data, 'NPC'); + + $this->assertStringContainsString('new SpriteAnimator()', $code); + $this->assertStringContainsString("->currentAnimation = 'idle'", $code); + $this->assertStringContainsString('use VISU\\Component\\SpriteAnimator;', $code); + } + + public function testGeneratedCodeIsSyntacticallyValid(): void + { + $data = [ + 'name' => 'ValidPrefab', + 'transform' => ['position' => [10, 20, 0], 'scale' => [2, 2, 1]], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'test.png', 'width' => 16, 'height' => 24, 'flipX' => true], + ], + ]; + + $code = $this->transpiler->transpileArray($data, 'ValidPrefab'); + + $result = exec('echo ' . escapeshellarg($code) . ' | php -l 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, "Generated prefab code has syntax errors:\n" . implode("\n", $output)); + } +} diff --git a/tests/Transpiler/SceneTranspilerTest.php b/tests/Transpiler/SceneTranspilerTest.php new file mode 100644 index 0000000..39210b0 --- /dev/null +++ b/tests/Transpiler/SceneTranspilerTest.php @@ -0,0 +1,233 @@ +registry = new ComponentRegistry(); + $this->registry->register('SpriteRenderer', SpriteRenderer::class); + $this->registry->register('BoxCollider2D', BoxCollider2D::class); + $this->transpiler = new SceneTranspiler($this->registry); + } + + public function testTranspileEmptyScene(): void + { + $code = $this->transpiler->transpileArray( + ['entities' => []], + 'EmptyScene', + ); + + $this->assertStringContainsString('class EmptyScene', $code); + $this->assertStringContainsString('public static function load(EntitiesInterface $entities): array', $code); + $this->assertStringContainsString('return $ids;', $code); + $this->assertStringContainsString('AUTO-GENERATED', $code); + } + + public function testTranspileSingleEntity(): void + { + $data = [ + 'entities' => [[ + 'name' => 'Player', + 'transform' => ['position' => [10, 20, 0]], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'player.png', 'width' => 32], + ], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'TestScene'); + + $this->assertStringContainsString("new NameComponent('Player')", $code); + $this->assertStringContainsString('new Vec3(10.0, 20.0, 0.0)', $code); + $this->assertStringContainsString('new SpriteRenderer()', $code); + $this->assertStringContainsString("->sprite = 'player.png'", $code); + $this->assertStringContainsString('->width = 32', $code); + $this->assertStringContainsString('use VISU\\Component\\SpriteRenderer;', $code); + $this->assertStringContainsString('use VISU\\Component\\NameComponent;', $code); + } + + public function testTranspileChildEntities(): void + { + $data = [ + 'entities' => [[ + 'name' => 'Parent', + 'transform' => ['position' => [0, 0, 0]], + 'children' => [[ + 'name' => 'Child', + 'transform' => ['position' => [5, 5, 0]], + ]], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'HierarchyScene'); + + // Should have setParent call for child + $this->assertStringContainsString('->setParent($entities, $e0)', $code); + $this->assertStringContainsString("new NameComponent('Parent')", $code); + $this->assertStringContainsString("new NameComponent('Child')", $code); + } + + public function testTranspileOmitsDefaultValues(): void + { + $data = [ + 'entities' => [[ + 'transform' => ['position' => [0, 0, 0], 'scale' => [1, 1, 1]], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'DefaultScene'); + + // Default position (0,0,0) and scale (1,1,1) should be omitted + $this->assertStringNotContainsString('new Vec3(0.0, 0.0, 0.0)', $code); + $this->assertStringNotContainsString('->scale', $code); + $this->assertStringContainsString('markDirty()', $code); + } + + public function testTranspileNonDefaultScale(): void + { + $data = [ + 'entities' => [[ + 'transform' => ['position' => [0, 0, 0], 'scale' => [2, 2, 1]], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'ScaledScene'); + $this->assertStringContainsString('new Vec3(2.0, 2.0, 1.0)', $code); + } + + public function testTranspileRotation(): void + { + $data = [ + 'entities' => [[ + 'transform' => ['rotation' => [45, 0, 90]], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'RotatedScene'); + $this->assertStringContainsString('GLM::radians(45.0)', $code); + $this->assertStringContainsString('GLM::radians(90.0)', $code); + $this->assertStringContainsString('use GL\\Math\\GLM;', $code); + $this->assertStringContainsString('use GL\\Math\\Quat;', $code); + } + + public function testTranspileMultipleComponents(): void + { + $data = [ + 'entities' => [[ + 'transform' => [], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'a.png'], + ['type' => 'BoxCollider2D', 'halfWidth' => 16.0, 'halfHeight' => 16.0], + ], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'MultiCompScene'); + + $this->assertStringContainsString('new SpriteRenderer()', $code); + $this->assertStringContainsString('new BoxCollider2D()', $code); + $this->assertStringContainsString('->halfWidth = 16.0', $code); + $this->assertStringContainsString('use VISU\\Component\\BoxCollider2D;', $code); + } + + public function testTranspileBooleanProperties(): void + { + $data = [ + 'entities' => [[ + 'transform' => [], + 'components' => [ + ['type' => 'SpriteRenderer', 'flipX' => true, 'flipY' => false], + ], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'BoolScene'); + $this->assertStringContainsString('->flipX = true', $code); + $this->assertStringContainsString('->flipY = false', $code); + } + + public function testTranspileArrayProperties(): void + { + $data = [ + 'entities' => [[ + 'transform' => [], + 'components' => [ + ['type' => 'SpriteRenderer', 'color' => [1.0, 0.5, 0.0, 1.0]], + ], + ]], + ]; + + $code = $this->transpiler->transpileArray($data, 'ArrayScene'); + $this->assertStringContainsString('[1.0, 0.5, 0.0, 1.0]', $code); + } + + public function testTranspileFromFile(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'scene_') . '.json'; + file_put_contents($tmpFile, json_encode([ + 'entities' => [[ + 'name' => 'FileTest', + 'transform' => ['position' => [1, 2, 3]], + ]], + ])); + + try { + $code = $this->transpiler->transpile($tmpFile, 'FileScene'); + $this->assertStringContainsString("new NameComponent('FileTest')", $code); + $this->assertStringContainsString("Source: {$tmpFile}", $code); + } finally { + unlink($tmpFile); + } + } + + public function testTranspileCustomNamespace(): void + { + $code = $this->transpiler->transpileArray( + ['entities' => []], + 'CustomScene', + 'App\\Scenes', + ); + + $this->assertStringContainsString('namespace App\\Scenes;', $code); + } + + public function testGeneratedCodeIsSyntacticallyValid(): void + { + $data = [ + 'entities' => [ + [ + 'name' => 'Root', + 'transform' => ['position' => [10, 20, 0], 'scale' => [2, 2, 1]], + 'components' => [ + ['type' => 'SpriteRenderer', 'sprite' => 'test.png', 'width' => 64, 'height' => 64, 'flipX' => true], + ['type' => 'BoxCollider2D', 'halfWidth' => 32.0, 'halfHeight' => 32.0, 'isTrigger' => true], + ], + 'children' => [ + [ + 'name' => 'Child1', + 'transform' => ['position' => [5, 5, 0]], + 'components' => [['type' => 'SpriteRenderer', 'sprite' => 'child.png']], + ], + ], + ], + ], + ]; + + $code = $this->transpiler->transpileArray($data, 'SyntaxTestScene'); + + // Verify the generated code can be parsed by PHP + $result = exec('echo ' . escapeshellarg($code) . ' | php -l 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, "Generated code has syntax errors:\n" . implode("\n", $output)); + } +} diff --git a/tests/Transpiler/TranspilerRegistryTest.php b/tests/Transpiler/TranspilerRegistryTest.php new file mode 100644 index 0000000..07a585b --- /dev/null +++ b/tests/Transpiler/TranspilerRegistryTest.php @@ -0,0 +1,130 @@ +tmpDir = sys_get_temp_dir() . '/visu_registry_test_' . uniqid(); + } + + protected function tearDown(): void + { + $registryFile = $this->tmpDir . '/transpiler_registry.json'; + if (file_exists($registryFile)) { + unlink($registryFile); + } + if (is_dir($this->tmpDir)) { + rmdir($this->tmpDir); + } + } + + public function testNewFileNeedsUpdate(): void + { + $registry = new TranspilerRegistry($this->tmpDir); + $this->assertTrue($registry->needsUpdate('/some/file.json')); + } + + public function testRecordedFileDoesNotNeedUpdate(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'src_'); + file_put_contents($tmpFile, '{"test": true}'); + + try { + $registry = new TranspilerRegistry($this->tmpDir); + $registry->record($tmpFile, '/output/file.php'); + + $this->assertFalse($registry->needsUpdate($tmpFile)); + $this->assertSame('/output/file.php', $registry->getOutputPath($tmpFile)); + } finally { + unlink($tmpFile); + } + } + + public function testModifiedFileNeedsUpdate(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'src_'); + file_put_contents($tmpFile, '{"version": 1}'); + + try { + $registry = new TranspilerRegistry($this->tmpDir); + $registry->record($tmpFile, '/output/file.php'); + $this->assertFalse($registry->needsUpdate($tmpFile)); + + // Modify the file + file_put_contents($tmpFile, '{"version": 2}'); + $this->assertTrue($registry->needsUpdate($tmpFile)); + } finally { + unlink($tmpFile); + } + } + + public function testPersistence(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'src_'); + file_put_contents($tmpFile, 'content'); + + try { + $registry1 = new TranspilerRegistry($this->tmpDir); + $registry1->record($tmpFile, '/out.php'); + $registry1->save(); + + // Load fresh registry + $registry2 = new TranspilerRegistry($this->tmpDir); + $this->assertFalse($registry2->needsUpdate($tmpFile)); + $this->assertSame('/out.php', $registry2->getOutputPath($tmpFile)); + } finally { + unlink($tmpFile); + } + } + + public function testRemove(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'src_'); + file_put_contents($tmpFile, 'data'); + + try { + $registry = new TranspilerRegistry($this->tmpDir); + $registry->record($tmpFile, '/out.php'); + $this->assertFalse($registry->needsUpdate($tmpFile)); + + $registry->remove($tmpFile); + $this->assertTrue($registry->needsUpdate($tmpFile)); + $this->assertNull($registry->getOutputPath($tmpFile)); + } finally { + unlink($tmpFile); + } + } + + public function testClear(): void + { + $registry = new TranspilerRegistry($this->tmpDir); + $tmpFile = tempnam(sys_get_temp_dir(), 'src_'); + file_put_contents($tmpFile, 'x'); + + try { + $registry->record($tmpFile, '/out.php'); + $this->assertNotEmpty($registry->getEntries()); + + $registry->clear(); + $this->assertEmpty($registry->getEntries()); + } finally { + unlink($tmpFile); + } + } + + public function testNonexistentFileReturnsEmptyHash(): void + { + $registry = new TranspilerRegistry($this->tmpDir); + // Record a nonexistent file — should store empty hash + $registry->record('/nonexistent/file.json', '/out.php'); + // Any file should differ from empty hash + $this->assertTrue($registry->needsUpdate('/other/file.json')); + } +} diff --git a/tests/Transpiler/UITranspilerTest.php b/tests/Transpiler/UITranspilerTest.php new file mode 100644 index 0000000..758b965 --- /dev/null +++ b/tests/Transpiler/UITranspilerTest.php @@ -0,0 +1,189 @@ +transpiler = new UITranspiler(); + } + + public function testTranspileLabel(): void + { + $data = ['type' => 'label', 'text' => 'Hello World', 'fontSize' => 16, 'bold' => true]; + $code = $this->transpiler->transpileArray($data, 'LabelUI'); + + $this->assertStringContainsString("FlyUI::text('Hello World')", $code); + $this->assertStringContainsString('->fontSize(16.0)', $code); + $this->assertStringContainsString('->bold()', $code); + $this->assertStringContainsString('class LabelUI', $code); + } + + public function testTranspileLabelWithBinding(): void + { + $data = ['type' => 'label', 'text' => 'Money: {economy.money}']; + $code = $this->transpiler->transpileArray($data, 'BindingUI'); + + $this->assertStringContainsString("\$ctx->get('economy.money', '')", $code); + $this->assertStringContainsString("'Money: '", $code); + } + + public function testTranspilePureBinding(): void + { + $data = ['type' => 'label', 'text' => '{player.name}']; + $code = $this->transpiler->transpileArray($data, 'PureBindUI'); + + $this->assertStringContainsString("\$ctx->get('player.name', '')", $code); + } + + public function testTranspilePanel(): void + { + $data = [ + 'type' => 'panel', + 'layout' => 'row', + 'padding' => 10, + 'spacing' => 5, + 'children' => [ + ['type' => 'label', 'text' => 'A'], + ['type' => 'label', 'text' => 'B'], + ], + ]; + + $code = $this->transpiler->transpileArray($data, 'PanelUI'); + + $this->assertStringContainsString('FlyUI::beginLayout(', $code); + $this->assertStringContainsString('FUILayoutFlow::horizontal', $code); + $this->assertStringContainsString('->spacing(5.0)', $code); + $this->assertStringContainsString('Vec4(10.0, 10.0, 10.0, 10.0)', $code); + $this->assertStringContainsString('FlyUI::end()', $code); + $this->assertStringContainsString("FlyUI::text('A')", $code); + $this->assertStringContainsString("FlyUI::text('B')", $code); + } + + public function testTranspileButton(): void + { + $data = ['type' => 'button', 'label' => 'Click Me', 'event' => 'ui.click']; + $code = $this->transpiler->transpileArray($data, 'ButtonUI'); + + $this->assertStringContainsString("FlyUI::button('Click Me'", $code); + $this->assertStringContainsString("new UIEventSignal('ui.click'", $code); + $this->assertStringContainsString('use VISU\\Signals\\UI\\UIEventSignal;', $code); + } + + public function testTranspileProgressBar(): void + { + $data = ['type' => 'progressbar', 'value' => '{health}', 'color' => '#ff0000', 'height' => 12]; + $code = $this->transpiler->transpileArray($data, 'BarUI'); + + $this->assertStringContainsString("\$ctx->get('health', 0)", $code); + $this->assertStringContainsString('VGColor::rgb(', $code); + $this->assertStringContainsString('->height(12.0)', $code); + } + + public function testTranspileCheckbox(): void + { + $data = ['type' => 'checkbox', 'text' => 'Enable', 'id' => 'cb_enable', 'event' => 'ui.toggle']; + $code = $this->transpiler->transpileArray($data, 'CheckUI'); + + $this->assertStringContainsString('static $cbState_cb_enable = false', $code); + $this->assertStringContainsString("FlyUI::checkbox('Enable'", $code); + $this->assertStringContainsString("new UIEventSignal('ui.toggle'", $code); + } + + public function testTranspileSelect(): void + { + $data = ['type' => 'select', 'name' => 'priority', 'options' => ['High', 'Low'], 'event' => 'ui.sel']; + $code = $this->transpiler->transpileArray($data, 'SelectUI'); + + $this->assertStringContainsString("FlyUI::select('priority'", $code); + $this->assertStringContainsString("'High', 'Low'", $code); + } + + public function testTranspileSpace(): void + { + $data = ['type' => 'space', 'height' => 10]; + $code = $this->transpiler->transpileArray($data, 'SpaceUI'); + + $this->assertStringContainsString('FlyUI::spaceY(10.0)', $code); + } + + public function testTranspileImage(): void + { + $data = ['type' => 'image', 'width' => 64, 'height' => 64, 'color' => '#ff0000']; + $code = $this->transpiler->transpileArray($data, 'ImageUI'); + + $this->assertStringContainsString('fixedWidth(64.0)', $code); + $this->assertStringContainsString('fixedHeight(64.0)', $code); + } + + public function testTranspileFromFile(): void + { + $tmpFile = tempnam(sys_get_temp_dir(), 'ui_') . '.json'; + file_put_contents($tmpFile, json_encode([ + 'type' => 'label', + 'text' => 'File Test', + ])); + + try { + $code = $this->transpiler->transpile($tmpFile, 'FileUI'); + $this->assertStringContainsString("FlyUI::text('File Test')", $code); + $this->assertStringContainsString("Source: {$tmpFile}", $code); + } finally { + unlink($tmpFile); + } + } + + public function testTranspileSizingModes(): void + { + $data = [ + 'type' => 'panel', + 'horizontalSizing' => 'fit', + 'verticalSizing' => 'fill', + 'children' => [], + ]; + + $code = $this->transpiler->transpileArray($data, 'SizingUI'); + $this->assertStringContainsString('horizontalFit()', $code); + $this->assertStringContainsString('verticalFill()', $code); + } + + public function testTranspileBackgroundColor(): void + { + $data = [ + 'type' => 'panel', + 'backgroundColor' => '#1a2b3c', + 'children' => [], + ]; + + $code = $this->transpiler->transpileArray($data, 'BgUI'); + $this->assertStringContainsString('backgroundColor(VGColor::rgb(', $code); + } + + public function testGeneratedCodeIsSyntacticallyValid(): void + { + $data = [ + 'type' => 'panel', + 'layout' => 'column', + 'padding' => 15, + 'spacing' => 8, + 'children' => [ + ['type' => 'label', 'text' => 'Title: {name}', 'fontSize' => 20, 'bold' => true, 'color' => '#ffffff'], + ['type' => 'progressbar', 'value' => '{health}', 'color' => '#00ff00', 'height' => 12], + ['type' => 'space', 'height' => 5], + ['type' => 'button', 'label' => 'Action', 'event' => 'ui.action'], + ['type' => 'checkbox', 'text' => 'Toggle', 'id' => 'cb1', 'event' => 'ui.toggle'], + ], + ]; + + $code = $this->transpiler->transpileArray($data, 'FullUI'); + + $result = exec('echo ' . escapeshellarg($code) . ' | php -l 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, "Generated UI code has syntax errors:\n" . implode("\n", $output)); + } +} diff --git a/tests/UI/UIDataContextTest.php b/tests/UI/UIDataContextTest.php new file mode 100644 index 0000000..8fb68ff --- /dev/null +++ b/tests/UI/UIDataContextTest.php @@ -0,0 +1,79 @@ +set('economy.money', 1500); + $this->assertSame(1500, $ctx->get('economy.money')); + } + + public function testGetDefault(): void + { + $ctx = new UIDataContext(); + $this->assertNull($ctx->get('missing')); + $this->assertSame('fallback', $ctx->get('missing', 'fallback')); + } + + public function testSetAll(): void + { + $ctx = new UIDataContext(); + $ctx->setAll(['a' => 1, 'b' => 2]); + $this->assertSame(1, $ctx->get('a')); + $this->assertSame(2, $ctx->get('b')); + } + + public function testResolveBindings(): void + { + $ctx = new UIDataContext(); + $ctx->set('economy.money', 1500); + $ctx->set('player.name', 'Alice'); + + $this->assertSame('Geld: 1500', $ctx->resolveBindings('Geld: {economy.money}')); + $this->assertSame('Hallo Alice!', $ctx->resolveBindings('Hallo {player.name}!')); + } + + public function testResolveBindingsFloat(): void + { + $ctx = new UIDataContext(); + $ctx->set('player.oxygen', 0.75); + + $this->assertSame('O2: 0.75', $ctx->resolveBindings('O2: {player.oxygen}')); + } + + public function testResolveBindingsUnresolved(): void + { + $ctx = new UIDataContext(); + $this->assertSame('Value: {unknown.key}', $ctx->resolveBindings('Value: {unknown.key}')); + } + + public function testResolveValue(): void + { + $ctx = new UIDataContext(); + $ctx->set('player.health', 100); + + $this->assertSame(100, $ctx->resolveValue('{player.health}')); + $this->assertSame('plain text', $ctx->resolveValue('plain text')); + } + + public function testHasBindings(): void + { + $ctx = new UIDataContext(); + $this->assertTrue($ctx->hasBindings('Hello {name}')); + $this->assertFalse($ctx->hasBindings('Hello world')); + } + + public function testToArray(): void + { + $ctx = new UIDataContext(); + $ctx->set('a', 1); + $ctx->set('b', 'two'); + $this->assertSame(['a' => 1, 'b' => 'two'], $ctx->toArray()); + } +} diff --git a/tests/UI/UIInterpreterTest.php b/tests/UI/UIInterpreterTest.php new file mode 100644 index 0000000..6dafc75 --- /dev/null +++ b/tests/UI/UIInterpreterTest.php @@ -0,0 +1,121 @@ +assertSame('panel', UINodeType::Panel->value); + $this->assertSame('label', UINodeType::Label->value); + $this->assertSame('button', UINodeType::Button->value); + $this->assertSame('progressbar', UINodeType::ProgressBar->value); + $this->assertSame('checkbox', UINodeType::Checkbox->value); + $this->assertSame('select', UINodeType::Select->value); + $this->assertSame('image', UINodeType::Image->value); + $this->assertSame('space', UINodeType::Space->value); + } + + public function testNodeTypeFromString(): void + { + $this->assertSame(UINodeType::Panel, UINodeType::from('panel')); + $this->assertSame(UINodeType::Button, UINodeType::from('button')); + $this->assertNull(UINodeType::tryFrom('unknown')); + } + + public function testDataContextIntegration(): void + { + $dispatcher = new Dispatcher(); + $ctx = new UIDataContext(); + $ctx->set('economy.money', 5000); + + $interpreter = new UIInterpreter($dispatcher, $ctx); + $this->assertSame($ctx, $interpreter->getDataContext()); + } + + public function testSetDataContext(): void + { + $dispatcher = new Dispatcher(); + $interpreter = new UIInterpreter($dispatcher); + + $ctx = new UIDataContext(); + $ctx->set('test', 42); + $interpreter->setDataContext($ctx); + + $this->assertSame(42, $interpreter->getDataContext()->get('test')); + } + + public function testUIEventSignal(): void + { + $signal = new UIEventSignal('ui.new_project', ['cost' => 100]); + $this->assertSame('ui.new_project', $signal->event); + $this->assertSame(['cost' => 100], $signal->data); + } + + public function testUIEventSignalDispatching(): void + { + $dispatcher = new Dispatcher(); + $received = null; + + $dispatcher->register('ui.event', function (UIEventSignal $signal) use (&$received): void { + $received = $signal; + }); + + $dispatcher->dispatch('ui.event', new UIEventSignal('test.click', ['id' => 'btn1'])); + + $this->assertNotNull($received); + $this->assertSame('test.click', $received->event); + $this->assertSame(['id' => 'btn1'], $received->data); + } + + public function testResetStates(): void + { + $dispatcher = new Dispatcher(); + $interpreter = new UIInterpreter($dispatcher); + + // Just verify it doesn't throw + $interpreter->resetStates(); + $this->assertFalse($interpreter->getCheckboxState('nonexistent')); + $this->assertNull($interpreter->getSelectState('nonexistent')); + } + + public function testRenderFileNotFound(): void + { + $dispatcher = new Dispatcher(); + $interpreter = new UIInterpreter($dispatcher); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('UI layout file not found'); + $interpreter->renderFile('/nonexistent/path.json'); + } + + public function testParseUILayoutJson(): void + { + // Test that our JSON schema parses correctly + $json = '{ + "type": "panel", + "layout": "column", + "padding": 10, + "children": [ + { "type": "label", "text": "Money: {economy.money}", "fontSize": 16 }, + { "type": "progressbar", "value": "{player.oxygen}", "color": "#0088ff" }, + { "type": "button", "label": "New Project", "event": "ui.new_project" } + ] + }'; + + $data = json_decode($json, true); + $this->assertIsArray($data); + $this->assertSame('panel', $data['type']); + $this->assertCount(3, $data['children']); + $this->assertSame('label', $data['children'][0]['type']); + $this->assertSame('progressbar', $data['children'][1]['type']); + $this->assertSame('button', $data['children'][2]['type']); + } +} diff --git a/tests/UI/UIScreenStackTest.php b/tests/UI/UIScreenStackTest.php new file mode 100644 index 0000000..c91bae7 --- /dev/null +++ b/tests/UI/UIScreenStackTest.php @@ -0,0 +1,155 @@ +assertTrue($stack->isEmpty()); + + $screen = new UIScreen('main_menu'); + $stack->push($screen); + + $this->assertFalse($stack->isEmpty()); + $this->assertSame(1, $stack->count()); + $this->assertSame($screen, $stack->peek()); + $this->assertTrue($screen->isActive()); + } + + public function testPop(): void + { + $stack = new UIScreenStack(); + $screen = new UIScreen('main_menu'); + $stack->push($screen); + + $popped = $stack->pop(); + $this->assertSame($screen, $popped); + $this->assertFalse($screen->isActive()); + $this->assertTrue($stack->isEmpty()); + } + + public function testPopEmpty(): void + { + $stack = new UIScreenStack(); + $this->assertNull($stack->pop()); + } + + public function testPeekEmpty(): void + { + $stack = new UIScreenStack(); + $this->assertNull($stack->peek()); + } + + public function testReplace(): void + { + $stack = new UIScreenStack(); + $screen1 = new UIScreen('menu'); + $screen2 = new UIScreen('settings'); + + $stack->push($screen1); + $old = $stack->replace($screen2); + + $this->assertSame($screen1, $old); + $this->assertFalse($screen1->isActive()); + $this->assertSame(1, $stack->count()); + $this->assertSame($screen2, $stack->peek()); + $this->assertTrue($screen2->isActive()); + } + + public function testMultiplePushPop(): void + { + $stack = new UIScreenStack(); + $s1 = new UIScreen('screen1'); + $s2 = new UIScreen('screen2'); + $s3 = new UIScreen('screen3'); + + $stack->push($s1); + $stack->push($s2); + $stack->push($s3); + $this->assertSame(3, $stack->count()); + + $this->assertSame($s3, $stack->pop()); + $this->assertSame($s2, $stack->peek()); + $this->assertSame(2, $stack->count()); + } + + public function testClear(): void + { + $stack = new UIScreenStack(); + $s1 = new UIScreen('screen1'); + $s2 = new UIScreen('screen2'); + + $stack->push($s1); + $stack->push($s2); + $stack->clear(); + + $this->assertTrue($stack->isEmpty()); + $this->assertFalse($s1->isActive()); + $this->assertFalse($s2->isActive()); + } + + public function testGetScreens(): void + { + $stack = new UIScreenStack(); + $s1 = new UIScreen('s1'); + $s2 = new UIScreen('s2'); + + $stack->push($s1); + $stack->push($s2); + + $screens = $stack->getScreens(); + $this->assertCount(2, $screens); + $this->assertSame($s1, $screens[0]); + $this->assertSame($s2, $screens[1]); + } + + public function testUpdate(): void + { + $stack = new UIScreenStack(); + $screen = new UIScreen('menu'); + $screen->setEnterTransition(UITransitionType::FadeIn, 1.0); + $stack->push($screen); + + $stack->update(0.5); + + $transition = $screen->getEnterTransition(); + $this->assertNotNull($transition); + $this->assertEqualsWithDelta(0.5, $transition->getProgress(), 0.001); + } + + public function testScreenTransparentFlag(): void + { + $opaque = new UIScreen('opaque'); + $transparent = new UIScreen('overlay', transparent: true); + + $this->assertFalse($opaque->isTransparent()); + $this->assertTrue($transparent->isTransparent()); + } + + public function testScreenWithLayoutData(): void + { + $data = ['type' => 'panel', 'children' => []]; + $screen = new UIScreen('test', layoutData: $data); + + $this->assertSame($data, $screen->getLayoutData()); + $this->assertNull($screen->getLayoutFile()); + } + + public function testScreenSetLayout(): void + { + $screen = new UIScreen('test'); + $screen->setLayoutFile('/path/to/layout.json'); + $this->assertSame('/path/to/layout.json', $screen->getLayoutFile()); + + $data = ['type' => 'label', 'text' => 'Hello']; + $screen->setLayoutData($data); + $this->assertSame($data, $screen->getLayoutData()); + } +} diff --git a/tests/UI/UITransitionTest.php b/tests/UI/UITransitionTest.php new file mode 100644 index 0000000..d6841f0 --- /dev/null +++ b/tests/UI/UITransitionTest.php @@ -0,0 +1,150 @@ +assertEqualsWithDelta(0.0, $t->getProgress(), 0.001); + + $t->update(0.5); + $this->assertEqualsWithDelta(0.5, $t->getProgress(), 0.001); + + $t->update(0.5); + $this->assertEqualsWithDelta(1.0, $t->getProgress(), 0.001); + $this->assertTrue($t->isFinished()); + } + + public function testFadeInOpacity(): void + { + $t = new UITransition(UITransitionType::FadeIn, 1.0); + $this->assertEqualsWithDelta(0.0, $t->getOpacity(), 0.001); + + $t->update(1.0); + $this->assertEqualsWithDelta(1.0, $t->getOpacity(), 0.001); + } + + public function testFadeOutOpacity(): void + { + $t = new UITransition(UITransitionType::FadeOut, 1.0); + $t->update(0.0); + $this->assertEqualsWithDelta(1.0, $t->getOpacity(), 0.001); + + $t->update(1.0); + $this->assertEqualsWithDelta(0.0, $t->getOpacity(), 0.001); + } + + public function testSlideInLeftOffset(): void + { + $t = new UITransition(UITransitionType::SlideInLeft, 1.0); + $this->assertEqualsWithDelta(-800.0, $t->getOffsetX(800.0), 1.0); + + $t->update(1.0); + $this->assertEqualsWithDelta(0.0, $t->getOffsetX(800.0), 0.1); + } + + public function testSlideInRightOffset(): void + { + $t = new UITransition(UITransitionType::SlideInRight, 1.0); + $this->assertEqualsWithDelta(800.0, $t->getOffsetX(800.0), 1.0); + + $t->update(1.0); + $this->assertEqualsWithDelta(0.0, $t->getOffsetX(800.0), 0.1); + } + + public function testSlideInTopOffset(): void + { + $t = new UITransition(UITransitionType::SlideInTop, 1.0); + $this->assertEqualsWithDelta(-600.0, $t->getOffsetY(600.0), 1.0); + + $t->update(1.0); + $this->assertEqualsWithDelta(0.0, $t->getOffsetY(600.0), 0.1); + } + + public function testSlideInBottomOffset(): void + { + $t = new UITransition(UITransitionType::SlideInBottom, 1.0); + $this->assertEqualsWithDelta(600.0, $t->getOffsetY(600.0), 1.0); + + $t->update(1.0); + $this->assertEqualsWithDelta(0.0, $t->getOffsetY(600.0), 0.1); + } + + public function testScaleInScale(): void + { + $t = new UITransition(UITransitionType::ScaleIn, 1.0); + $this->assertEqualsWithDelta(0.0, $t->getScale(), 0.001); + + $t->update(1.0); + $this->assertEqualsWithDelta(1.0, $t->getScale(), 0.001); + } + + public function testScaleOutScale(): void + { + $t = new UITransition(UITransitionType::ScaleOut, 1.0); + $t->update(0.0); + $this->assertEqualsWithDelta(1.0, $t->getScale(), 0.001); + + $t->update(1.0); + $this->assertEqualsWithDelta(0.0, $t->getScale(), 0.001); + } + + public function testDelay(): void + { + $t = new UITransition(UITransitionType::FadeIn, 1.0, 0.5); + + $t->update(0.25); + $this->assertEqualsWithDelta(0.0, $t->getProgress(), 0.001); + $this->assertFalse($t->isFinished()); + + $t->update(0.25); + $this->assertEqualsWithDelta(0.0, $t->getProgress(), 0.001); + + $t->update(0.5); + $this->assertEqualsWithDelta(0.5, $t->getProgress(), 0.001); + + $t->update(0.5); + $this->assertTrue($t->isFinished()); + } + + public function testReset(): void + { + $t = new UITransition(UITransitionType::FadeIn, 1.0); + $t->update(1.0); + $this->assertTrue($t->isFinished()); + + $t->reset(); + $this->assertFalse($t->isFinished()); + $this->assertEqualsWithDelta(0.0, $t->getProgress(), 0.001); + } + + public function testEasedProgressNonLinear(): void + { + $t = new UITransition(UITransitionType::FadeIn, 1.0); + $t->update(0.5); + + // Ease-out cubic at 0.5: 1 - (1-0.5)^3 = 1 - 0.125 = 0.875 + $this->assertEqualsWithDelta(0.875, $t->getEasedProgress(), 0.001); + } + + public function testNoOffsetForNonSlideTransitions(): void + { + $t = new UITransition(UITransitionType::FadeIn, 1.0); + $t->update(0.5); + $this->assertEqualsWithDelta(0.0, $t->getOffsetX(800.0), 0.001); + $this->assertEqualsWithDelta(0.0, $t->getOffsetY(600.0), 0.001); + } + + public function testScaleIsOneForNonScaleTransitions(): void + { + $t = new UITransition(UITransitionType::FadeIn, 1.0); + $t->update(0.5); + $this->assertEqualsWithDelta(1.0, $t->getScale(), 0.001); + } +} diff --git a/tests/WorldEditor/WebSocketServerTest.php b/tests/WorldEditor/WebSocketServerTest.php new file mode 100644 index 0000000..835c988 --- /dev/null +++ b/tests/WorldEditor/WebSocketServerTest.php @@ -0,0 +1,90 @@ +assertInstanceOf(WebSocketServer::class, $server); + } + + public function testClientCountStartsAtZero(): void + { + $server = new WebSocketServer('127.0.0.1', 19877); + $this->assertSame(0, $server->getClientCount()); + } + + public function testCanRegisterHandlers(): void + { + $server = new WebSocketServer('127.0.0.1', 19878); + $called = false; + $server->on('test', function () use (&$called) { + $called = true; + }); + // Handler registered without error + $this->assertFalse($called); + } + + public function testBroadcastWithNoClientsDoesNotError(): void + { + $server = new WebSocketServer('127.0.0.1', 19879); + // Should not throw + $server->broadcast(['type' => 'test', 'data' => []]); + $this->assertTrue(true); + } + + public function testSendToNonExistentClientDoesNotError(): void + { + $server = new WebSocketServer('127.0.0.1', 19880); + $server->send(9999, ['type' => 'test']); + $this->assertTrue(true); + } + + public function testEditorBridgeCanBeConstructed(): void + { + $bridge = new EditorBridge('127.0.0.1', 19881); + $this->assertInstanceOf(EditorBridge::class, $bridge); + } + + public function testEditorBridgeReturnsServer(): void + { + $bridge = new EditorBridge('127.0.0.1', 19882); + $this->assertInstanceOf(WebSocketServer::class, $bridge->getServer()); + } + + public function testEditorBridgeNotifySceneChangedWithNoClients(): void + { + $bridge = new EditorBridge('127.0.0.1', 19883); + // Should not throw even with no clients + $bridge->notifySceneChanged('test_world', 'world'); + $this->assertTrue(true); + } + + public function testEditorBridgeNotifyTranspileComplete(): void + { + $bridge = new EditorBridge('127.0.0.1', 19884); + $bridge->notifyTranspileComplete('/path/to/scene.json', ['success' => true, 'output' => 'Scene.php']); + $this->assertTrue(true); + } + + public function testEditorBridgeChangeNotificationFile(): void + { + $tmpFile = sys_get_temp_dir() . '/visu_ws_test_changes_' . uniqid() . '.json'; + $bridge = new EditorBridge('127.0.0.1', 19885, $tmpFile); + $bridge->notifySceneChanged('my_world', 'world'); + + $this->assertFileExists($tmpFile); + $data = json_decode((string) file_get_contents($tmpFile), true); + $this->assertIsArray($data); + $this->assertSame('my_world', $data['name']); + $this->assertSame('world', $data['type']); + + unlink($tmpFile); + } +} diff --git a/tests/WorldEditor/WorldFileTest.php b/tests/WorldEditor/WorldFileTest.php new file mode 100644 index 0000000..911bab9 --- /dev/null +++ b/tests/WorldEditor/WorldFileTest.php @@ -0,0 +1,175 @@ +tmpDir = sys_get_temp_dir() . '/visu_worldfile_test_' . uniqid(); + mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + // Clean up temp files + $files = glob($this->tmpDir . '/*') ?: []; + foreach ($files as $file) { + unlink($file); + } + if (is_dir($this->tmpDir)) { + rmdir($this->tmpDir); + } + } + + public function testCreateReturnsDefaultWorld(): void + { + $world = WorldFile::create('TestWorld'); + + $this->assertSame('1.0', $world->version); + $this->assertSame('TestWorld', $world->meta['name']); + $this->assertSame('2d_topdown', $world->meta['type']); + $this->assertSame(32, $world->meta['tileSize']); + $this->assertCount(2, $world->layers); + $this->assertSame('bg', $world->layers[0]['id']); + $this->assertSame('tile', $world->layers[0]['type']); + $this->assertSame('entities', $world->layers[1]['id']); + $this->assertSame('entity', $world->layers[1]['type']); + } + + public function testFromArrayParsesData(): void + { + $data = [ + 'version' => '2.0', + 'meta' => ['name' => 'Custom', 'type' => '3d', 'tileSize' => 64], + 'camera' => ['position' => ['x' => 10, 'y' => 20], 'zoom' => 2.0], + 'layers' => [ + ['id' => 'l1', 'name' => 'Layer 1', 'type' => 'entity', 'entities' => []], + ], + 'lights' => [['type' => 'point', 'position' => ['x' => 0, 'y' => 0]]], + 'tilesets' => [['id' => 'ts1', 'path' => 'tiles.png']], + ]; + + $world = WorldFile::fromArray($data); + + $this->assertSame('2.0', $world->version); + $this->assertSame('Custom', $world->meta['name']); + $this->assertCount(1, $world->layers); + $this->assertCount(1, $world->lights); + $this->assertCount(1, $world->tilesets); + } + + public function testFromArrayWithDefaults(): void + { + $world = WorldFile::fromArray([]); + + $this->assertSame('1.0', $world->version); + $this->assertSame('Untitled', $world->meta['name']); + $this->assertEmpty($world->layers); + } + + public function testToArrayRoundTrip(): void + { + $world = WorldFile::create('RoundTrip'); + $array = $world->toArray(); + + $this->assertSame('1.0', $array['version']); + $this->assertSame('RoundTrip', $array['meta']['name']); + $this->assertCount(2, $array['layers']); + $this->assertArrayHasKey('camera', $array); + $this->assertArrayHasKey('lights', $array); + $this->assertArrayHasKey('tilesets', $array); + } + + public function testSaveAndLoad(): void + { + $path = $this->tmpDir . '/test.world.json'; + $world = WorldFile::create('SaveTest'); + $world->layers[1]['entities'][] = [ + 'id' => 1, + 'name' => 'Player', + 'type' => 'player_spawn', + 'position' => ['x' => 100, 'y' => 200], + ]; + $world->save($path); + + $this->assertFileExists($path); + + $loaded = WorldFile::load($path); + $this->assertSame('SaveTest', $loaded->meta['name']); + $this->assertCount(2, $loaded->layers); + $this->assertCount(1, $loaded->layers[1]['entities']); + $this->assertSame('Player', $loaded->layers[1]['entities'][0]['name']); + } + + public function testSaveCreatesDirectory(): void + { + $path = $this->tmpDir . '/sub/dir/test.world.json'; + $world = WorldFile::create('DirTest'); + $world->save($path); + + $this->assertFileExists($path); + + // Clean up nested dirs + unlink($path); + rmdir($this->tmpDir . '/sub/dir'); + rmdir($this->tmpDir . '/sub'); + } + + public function testSaveUpdatesModifiedTimestamp(): void + { + $path = $this->tmpDir . '/time.world.json'; + $world = WorldFile::create('TimeTest'); + // Override to a known past timestamp + $world->meta['modified'] = '2020-01-01T00:00:00+00:00'; + + $world->save($path); + + $loaded = WorldFile::load($path); + $this->assertNotSame('2020-01-01T00:00:00+00:00', $loaded->meta['modified']); + } + + public function testLoadNonExistentThrows(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('World file not found'); + WorldFile::load('/nonexistent/path.world.json'); + } + + public function testLoadInvalidJsonThrows(): void + { + $path = $this->tmpDir . '/invalid.world.json'; + file_put_contents($path, 'not json at all'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON'); + WorldFile::load($path); + } + + public function testGetLayers(): void + { + $world = WorldFile::create('LayerTest'); + $layers = $world->getLayers(); + + $this->assertCount(2, $layers); + $this->assertSame('bg', $layers[0]['id']); + $this->assertSame('entities', $layers[1]['id']); + } + + public function testJsonOutputIsPrettyPrinted(): void + { + $path = $this->tmpDir . '/pretty.world.json'; + $world = WorldFile::create('PrettyTest'); + $world->save($path); + + $content = file_get_contents($path); + $this->assertIsString($content); + $this->assertStringContainsString("\n", $content); + $this->assertStringContainsString(' ', $content); + } +} diff --git a/tests/WorldEditor/WorldsControllerTest.php b/tests/WorldEditor/WorldsControllerTest.php new file mode 100644 index 0000000..3be40da --- /dev/null +++ b/tests/WorldEditor/WorldsControllerTest.php @@ -0,0 +1,183 @@ +worldsDir = sys_get_temp_dir() . '/visu_ctrl_test_worlds_' . uniqid(); + $this->resourcesDir = sys_get_temp_dir() . '/visu_ctrl_test_res_' . uniqid(); + mkdir($this->worldsDir, 0755, true); + mkdir($this->resourcesDir, 0755, true); + } + + protected function tearDown(): void + { + $this->removeDir($this->worldsDir); + $this->removeDir($this->resourcesDir); + } + + private function removeDir(string $dir): void + { + if (!is_dir($dir)) return; + $items = scandir($dir) ?: []; + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $path = $dir . '/' . $item; + is_dir($path) ? $this->removeDir($path) : unlink($path); + } + rmdir($dir); + } + + private function capture(WorldsController $controller, string $method, string $path): string + { + ob_start(); + $controller->handle($method, $path); + return (string) ob_get_clean(); + } + + /** + * @return array + */ + private function captureJson(WorldsController $controller, string $method, string $path): array + { + $output = $this->capture($controller, $method, $path); + $data = json_decode($output, true); + $this->assertIsArray($data); + return $data; + } + + private function makeController(): WorldsController + { + return new WorldsController($this->worldsDir, $this->resourcesDir); + } + + public function testCanBeConstructed(): void + { + $this->assertInstanceOf(WorldsController::class, $this->makeController()); + } + + public function testListWorldsReturnsEmptyArray(): void + { + $data = $this->captureJson($this->makeController(), 'GET', '/api/worlds'); + $this->assertCount(0, $data); + } + + public function testListWorldsReturnsCreatedWorlds(): void + { + WorldFile::create('TestMap')->save($this->worldsDir . '/testmap.world.json'); + + $data = $this->captureJson($this->makeController(), 'GET', '/api/worlds'); + $this->assertCount(1, $data); + $this->assertArrayHasKey(0, $data); + $this->assertSame('testmap', $data[0]['name']); + } + + public function testGetWorldReturnsWorldData(): void + { + WorldFile::create('GetTest')->save($this->worldsDir . '/gettest.world.json'); + + $data = $this->captureJson($this->makeController(), 'GET', '/api/worlds/gettest'); + $this->assertSame('GetTest', $data['meta']['name']); + } + + public function testGetNonExistentWorldReturnsError(): void + { + $data = $this->captureJson($this->makeController(), 'GET', '/api/worlds/nonexistent'); + $this->assertArrayHasKey('error', $data); + } + + public function testGetConfigReturnsConfig(): void + { + $data = $this->captureJson($this->makeController(), 'GET', '/api/config'); + $this->assertArrayHasKey('tileSize', $data); + } + + public function testBrowseAssetsReturnsEntries(): void + { + file_put_contents($this->resourcesDir . '/test.png', 'fake'); + mkdir($this->resourcesDir . '/subdir'); + + $data = $this->captureJson($this->makeController(), 'GET', '/api/assets/browse'); + $this->assertArrayHasKey('entries', $data); + $this->assertCount(2, $data['entries']); + $this->assertSame('directory', $data['entries'][0]['type']); + } + + public function testBrowseAssetsDetectsFileTypes(): void + { + file_put_contents($this->resourcesDir . '/sprite.png', 'x'); + file_put_contents($this->resourcesDir . '/model.glb', 'x'); + file_put_contents($this->resourcesDir . '/data.json', 'x'); + file_put_contents($this->resourcesDir . '/music.ogg', 'x'); + + $data = $this->captureJson($this->makeController(), 'GET', '/api/assets/browse'); + $types = array_column($data['entries'], 'type', 'name'); + + $this->assertSame('json', $types['data.json']); + $this->assertSame('model', $types['model.glb']); + $this->assertSame('audio', $types['music.ogg']); + $this->assertSame('image', $types['sprite.png']); + } + + public function testListScenesReturnsEmpty(): void + { + $data = $this->captureJson($this->makeController(), 'GET', '/api/scenes'); + $this->assertCount(0, $data); + } + + public function testListScenesReturnsCreatedScenes(): void + { + mkdir($this->resourcesDir . '/scenes'); + file_put_contents($this->resourcesDir . '/scenes/level1.json', '{"entities":[]}'); + + $data = $this->captureJson($this->makeController(), 'GET', '/api/scenes'); + $this->assertCount(1, $data); + $this->assertArrayHasKey(0, $data); + $this->assertSame('level1', $data[0]['name']); + } + + public function testGetSceneReturnsContent(): void + { + mkdir($this->resourcesDir . '/scenes'); + file_put_contents($this->resourcesDir . '/scenes/test.json', json_encode(['entities' => [['name' => 'Player']]])); + + $data = $this->captureJson($this->makeController(), 'GET', '/api/scenes/test'); + $this->assertSame('Player', $data['entities'][0]['name']); + } + + public function testUnknownEndpointReturnsError(): void + { + $data = $this->captureJson($this->makeController(), 'GET', '/api/unknown'); + $this->assertArrayHasKey('error', $data); + } + + public function testOptionsReturnsEmpty(): void + { + $output = $this->capture($this->makeController(), 'OPTIONS', '/api/worlds'); + $this->assertEmpty($output); + } + + public function testDeleteWorldRemovesFile(): void + { + $path = $this->worldsDir . '/todelete.world.json'; + WorldFile::create('ToDelete')->save($path); + $this->assertFileExists($path); + + $data = $this->captureJson($this->makeController(), 'DELETE', '/api/worlds/todelete'); + $this->assertTrue($data['ok']); + $this->assertFileDoesNotExist($path); + } +} diff --git a/visu.ctn b/visu.ctn index ab98e95..f18432f 100644 --- a/visu.ctn +++ b/visu.ctn @@ -45,6 +45,20 @@ import app @visu.command.dump_signal_handlers: VISU\Command\SignalDumpCommand(@visu.dispatcher) = command: 'signals:dump' +@visu.command.world_editor: VISU\Command\WorldEditorCommand + = command: 'world-editor' + +@visu.component_registry: VISU\ECS\ComponentRegistry + +@visu.command.transpile: VISU\Command\TranspileCommand(@visu.component_registry) + = command: 'transpile' + +@visu.command.setup: VISU\Command\SetupCommand + = command: 'setup' + +@visu.command.build: VISU\Command\BuildCommand + = command: 'build' + /** * Maker / CodeGenerator *